Migrate Notification settings + leaf screens to SwiftUI (#4562)

## Summary
Migrate the notification settings screen and its three leaf screens to
SwiftUI:
- `NotificationSettingsView`: permission status, learn-more link, sounds
/ categories / rate-limit / debug navigation, badge reset + auto-clear,
push ID share, push ID reset.
- `NotificationSoundsView`: imported / bundled / system segmented lists,
audio playback, swipe delete, `.fileImporter`, file-sharing + system
import, AKConverter progress HUD, alert handling.
- `NotificationRateLimitView`: pull-to-refresh on iOS, toolbar refresh
on Catalyst, 1-second reset countdown, retry state, parent
remaining-count callback.
- `NotificationDebugNotificationsView`: `UserDefaults`-backed toggles.

Removes the Eureka `row(for:)` extensions in
`NotificationRateLimitsAPI.swift`, deletes the four old
`*ViewController.swift` files, and rewires `SettingsItem.notifications`
and `NotificationManager.openSettingsFor` to the SwiftUI view.

## Screenshots
_Pending — to be added before merge._

## Link to pull request in Documentation repository
Documentation: home-assistant/companion.home-assistant#

## Any other notes
Part of a five-PR Eureka → SwiftUI migration tracked in
`UIKitToSwiftUIMigration.md` (siblings: #4560, #4561, #4563, #4564). The
`Eureka`, `ColorPickerRow`, and `ViewRow` pods stay until all slices
land.

**Reconciliation with #4563:** the categories PR temporarily embeds
`NotificationCategoryListView` inside the old
`NotificationSettingsViewController` via `UIHostingController`. This PR
deletes that controller entirely; after both merge, the
`categoriesDestination` in `NotificationSettingsView` should link
directly to `NotificationCategoryListView` from the categories PR.

`bundle exec fastlane lint` passes. Not build-verified locally yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Bruno Pantaleão Gonçalves
2026-04-28 20:55:21 +02:00
committed by GitHub
parent 773c165e2c
commit c59cd48a6d
13 changed files with 1246 additions and 1142 deletions

View File

@@ -453,8 +453,8 @@
11F3847C24FB27FC00CB0D74 /* DeviceWrapperBatteryObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F3847A24FB27FB00CB0D74 /* DeviceWrapperBatteryObserver.swift */; };
11F3D74C2495377B00C05BBA /* SensorListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F3D74B2495377B00C05BBA /* SensorListView.swift */; };
11F55EBC25D3A2A3003977AC /* NotificationCategoryListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F55EBB25D3A2A3003977AC /* NotificationCategoryListViewController.swift */; };
11F55ECD25D3A364003977AC /* NotificationRateLimitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F55ECC25D3A364003977AC /* NotificationRateLimitViewController.swift */; };
11F55EED25D3B088003977AC /* NotificationDebugNotificationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F55EEC25D3B088003977AC /* NotificationDebugNotificationsViewController.swift */; };
11F55ECD25D3A364003977AC /* NotificationRateLimitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F55ECC25D3A364003977AC /* NotificationRateLimitView.swift */; };
11F55EED25D3B088003977AC /* NotificationDebugNotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F55EEC25D3B088003977AC /* NotificationDebugNotificationsView.swift */; };
11F855D624DF6C7A0018013E /* MaterialDesignIcons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 11F855D224DF6C7A0018013E /* MaterialDesignIcons.ttf */; };
11F855D724DF6C7A0018013E /* MaterialDesignIcons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 11F855D224DF6C7A0018013E /* MaterialDesignIcons.ttf */; };
11F855D824DF6C7A0018013E /* MaterialDesignIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F855D324DF6C7A0018013E /* MaterialDesignIcons.swift */; };
@@ -1438,7 +1438,7 @@
B657A90C1CA646EB00121384 /* HomeAssistantUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B657A90B1CA646EB00121384 /* HomeAssistantUITests.swift */; };
B658AA7E2250B2A000C9BFE3 /* MobileAppUpdateRegistrationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B658AA7C2250B25D00C9BFE3 /* MobileAppUpdateRegistrationRequest.swift */; };
B658AA7F2250B2A100C9BFE3 /* MobileAppUpdateRegistrationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B658AA7C2250B25D00C9BFE3 /* MobileAppUpdateRegistrationRequest.swift */; };
B65C0B522282BA13007E057B /* NotificationSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65C0B512282BA13007E057B /* NotificationSettingsViewController.swift */; };
B65C0B522282BA13007E057B /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65C0B512282BA13007E057B /* NotificationSettingsView.swift */; };
B6617EED1CFE79AD004DEE6D /* NSURL+QueryDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6617EEC1CFE79AD004DEE6D /* NSURL+QueryDictionary.swift */; };
B661FB68226B961400E541DD /* WebSocketBridge.js in Resources */ = {isa = PBXBuildFile; fileRef = B661FB67226B961400E541DD /* WebSocketBridge.js */; };
B661FC7E226C87BB00E541DD /* home.json in Resources */ = {isa = PBXBuildFile; fileRef = B661FC7D226C87BB00E541DD /* home.json */; };
@@ -1524,7 +1524,7 @@
B6D3B4ED225B26900082BB4F /* SensorContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D3B4EB225B26300082BB4F /* SensorContainer.swift */; };
B6D3B4EE225B26910082BB4F /* SensorContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D3B4EB225B26300082BB4F /* SensorContainer.swift */; };
B6D8A3282271448E00FA765D /* error.json in Resources */ = {isa = PBXBuildFile; fileRef = B6D8A3272271448D00FA765D /* error.json */; };
B6DA3C7122690B1F00DE811C /* NotificationSoundsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DA3C7022690B1F00DE811C /* NotificationSoundsViewController.swift */; };
B6DA3C7122690B1F00DE811C /* NotificationSoundsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DA3C7022690B1F00DE811C /* NotificationSoundsView.swift */; };
B6DA3C7322691A5000DE811C /* AKConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DA3C7222691A5000DE811C /* AKConverter.swift */; };
B6DD5E6A24940F6F003A0154 /* OpenInFirefoxControllerSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DD5E6924940F6F003A0154 /* OpenInFirefoxControllerSwift.swift */; };
B6DDF8534A4176416CFAC79A /* KioskSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49767602CA2066683EC638F /* KioskSettingsView.swift */; };
@@ -2251,8 +2251,8 @@
11F3847A24FB27FB00CB0D74 /* DeviceWrapperBatteryObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceWrapperBatteryObserver.swift; sourceTree = "<group>"; };
11F3D74B2495377B00C05BBA /* SensorListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorListView.swift; sourceTree = "<group>"; };
11F55EBB25D3A2A3003977AC /* NotificationCategoryListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCategoryListViewController.swift; sourceTree = "<group>"; };
11F55ECC25D3A364003977AC /* NotificationRateLimitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRateLimitViewController.swift; sourceTree = "<group>"; };
11F55EEC25D3B088003977AC /* NotificationDebugNotificationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugNotificationsViewController.swift; sourceTree = "<group>"; };
11F55ECC25D3A364003977AC /* NotificationRateLimitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRateLimitView.swift; sourceTree = "<group>"; };
11F55EEC25D3B088003977AC /* NotificationDebugNotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugNotificationsView.swift; sourceTree = "<group>"; };
11F855D224DF6C7A0018013E /* MaterialDesignIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = MaterialDesignIcons.ttf; path = Tools/MaterialDesignIcons.ttf; sourceTree = SOURCE_ROOT; };
11F855D324DF6C7A0018013E /* MaterialDesignIcons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MaterialDesignIcons.swift; sourceTree = "<group>"; };
11F855D424DF6C7A0018013E /* IconDrawable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IconDrawable.swift; sourceTree = "<group>"; };
@@ -3363,7 +3363,7 @@
B658AA7622506DAF00C9BFE3 /* GoogleService-Info-Beta.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info-Beta.plist"; sourceTree = "<group>"; };
B658AA7C2250B25D00C9BFE3 /* MobileAppUpdateRegistrationRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MobileAppUpdateRegistrationRequest.swift; sourceTree = "<group>"; };
B65B15042273188300635D5C /* Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = "<group>"; };
B65C0B512282BA13007E057B /* NotificationSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsViewController.swift; sourceTree = "<group>"; };
B65C0B512282BA13007E057B /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = "<group>"; };
B6617EEC1CFE79AD004DEE6D /* NSURL+QueryDictionary.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSURL+QueryDictionary.swift"; sourceTree = "<group>"; };
B661FB67226B961400E541DD /* WebSocketBridge.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = WebSocketBridge.js; sourceTree = "<group>"; };
B661FC7D226C87BB00E541DD /* home.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = home.json; sourceTree = "<group>"; };
@@ -3426,7 +3426,7 @@
B6CC5D9B2159D10F00833E5D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B6D3B4EB225B26300082BB4F /* SensorContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorContainer.swift; sourceTree = "<group>"; };
B6D8A3272271448D00FA765D /* error.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = error.json; sourceTree = "<group>"; };
B6DA3C7022690B1F00DE811C /* NotificationSoundsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundsViewController.swift; sourceTree = "<group>"; };
B6DA3C7022690B1F00DE811C /* NotificationSoundsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundsView.swift; sourceTree = "<group>"; };
B6DA3C7222691A5000DE811C /* AKConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AKConverter.swift; sourceTree = "<group>"; };
B6DAC734215F069300727D2A /* NotificationCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCategory.swift; sourceTree = "<group>"; };
B6DAC736215F06B100727D2A /* NotificationAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAction.swift; sourceTree = "<group>"; };
@@ -4148,12 +4148,12 @@
children = (
B68EDD02215F0E2900DD6B28 /* NotificationCategoryConfigurator.swift */,
B68EDD04215F12C900DD6B28 /* NotificationActionConfigurator.swift */,
B6DA3C7022690B1F00DE811C /* NotificationSoundsViewController.swift */,
B65C0B512282BA13007E057B /* NotificationSettingsViewController.swift */,
B6DA3C7022690B1F00DE811C /* NotificationSoundsView.swift */,
B65C0B512282BA13007E057B /* NotificationSettingsView.swift */,
11C4629324B189B100031902 /* NotificationRateLimitsAPI.swift */,
11F55EBB25D3A2A3003977AC /* NotificationCategoryListViewController.swift */,
11F55ECC25D3A364003977AC /* NotificationRateLimitViewController.swift */,
11F55EEC25D3B088003977AC /* NotificationDebugNotificationsViewController.swift */,
11F55ECC25D3A364003977AC /* NotificationRateLimitView.swift */,
11F55EEC25D3B088003977AC /* NotificationDebugNotificationsView.swift */,
);
path = Notifications;
sourceTree = "<group>";
@@ -9382,7 +9382,7 @@
1112AEBB25F717E9007A541A /* LocationHistoryDetailViewController.swift in Sources */,
42FA83392F4722A30050095A /* FrontEndConnectionState.swift in Sources */,
11BD7B4D25B53D7F001826F0 /* AppMacBridgeStatusItemConfiguration.swift in Sources */,
11F55EED25D3B088003977AC /* NotificationDebugNotificationsViewController.swift in Sources */,
11F55EED25D3B088003977AC /* NotificationDebugNotificationsView.swift in Sources */,
42DFE8742EFAE33D0058DADB /* AssistWavesAnimation.swift in Sources */,
42462E732DA4114800ECC8A7 /* Sizes.swift in Sources */,
42175EC12F436811001073AF /* ClientCertificateOnboardingView.swift in Sources */,
@@ -9524,7 +9524,7 @@
4238E8572EB0EA4A00BDF010 /* ConnectionSecurityLevelBlockViewModel.swift in Sources */,
42AAEE882EC4B9880049E1F3 /* URLOpener.swift in Sources */,
42DF7DE22EC53DBC003F3C4A /* HomeAssistantAccountRowView.swift in Sources */,
B6DA3C7122690B1F00DE811C /* NotificationSoundsViewController.swift in Sources */,
B6DA3C7122690B1F00DE811C /* NotificationSoundsView.swift in Sources */,
424DD05A2B3509170057E456 /* CarPlayQuickAccessTemplate.swift in Sources */,
451D63FF25213F1E2AF7C73A /* CarPlayAssistSession.swift in Sources */,
429AFE5E2DB7DED300AF0836 /* GeneralSettingsTemplateEditor.swift in Sources */,
@@ -9571,7 +9571,7 @@
42790C442C48077200E31B38 /* ImprovSuccessView.swift in Sources */,
429AFE5C2DB7BE4000AF0836 /* GeneralSettingsView.swift in Sources */,
425573ED2B58904000145217 /* CarPlayEntityListItem.swift in Sources */,
11F55ECD25D3A364003977AC /* NotificationRateLimitViewController.swift in Sources */,
11F55ECD25D3A364003977AC /* NotificationRateLimitView.swift in Sources */,
4210CCEB2F152AD000B71FB9 /* CameraPlayerView.swift in Sources */,
3E4087F02CEC7F210085DF29 /* WidgetBasicSensorView.swift in Sources */,
42F73F5A2E264A9D00B704A9 /* WebViewControllerButtons.swift in Sources */,
@@ -9642,7 +9642,7 @@
1185DFB1271FF53800ED7D9A /* OnboardingAuthStepNotify.swift in Sources */,
4278CB852D01F0B200CFAAC9 /* GesturesSetupViewModel.swift in Sources */,
42AF759B2DDB5AA900ACDF45 /* WebViewSettingsUpdateReason.swift in Sources */,
B65C0B522282BA13007E057B /* NotificationSettingsViewController.swift in Sources */,
B65C0B522282BA13007E057B /* NotificationSettingsView.swift in Sources */,
42C1012B2CD3DB8A0012BA78 /* CoverIntent.swift in Sources */,
429997BF2DDB59E400CC6C12 /* WebViewRestorationType.swift in Sources */,
11A183B32511BCF300CA326A /* LifecycleManager.swift in Sources */,

View File

@@ -336,8 +336,11 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
_ center: UNUserNotificationCenter,
openSettingsFor notification: UNNotification?
) {
let view = NotificationSettingsViewController()
view.doneButton = true
let rootView = NavigationView {
NotificationSettingsView(showsDoneButton: true)
}
.navigationViewStyle(.stack)
let hostingController = rootView.embeddedInHostingController()
Current.sceneManager.webViewWindowControllerPromise.done {
var rootViewController = $0.window.rootViewController
@@ -345,8 +348,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
rootViewController = navigationController.viewControllers.first
}
rootViewController?.dismiss(animated: false, completion: {
let navController = UINavigationController(rootViewController: view)
rootViewController?.present(navController, animated: true, completion: nil)
rootViewController?.present(hostingController, animated: true, completion: nil)
})
}
}

View File

@@ -0,0 +1,73 @@
import Shared
import SwiftUI
struct NotificationDebugNotificationsView: View {
private struct Toggle: Identifiable {
let id: String
let title: String
}
private let toggles: [Toggle] = [
.init(id: "enterNotifications", title: L10n.SettingsDetails.Location.Notifications.Enter.title),
.init(id: "exitNotifications", title: L10n.SettingsDetails.Location.Notifications.Exit.title),
.init(id: "beaconEnterNotifications", title: L10n.SettingsDetails.Location.Notifications.BeaconEnter.title),
.init(id: "beaconExitNotifications", title: L10n.SettingsDetails.Location.Notifications.BeaconExit.title),
.init(
id: "significantLocationChangeNotifications",
title: L10n.SettingsDetails.Location.Notifications.LocationChange.title
),
.init(
id: "backgroundFetchLocationChangeNotifications",
title: L10n.SettingsDetails.Location.Notifications.BackgroundFetch.title
),
.init(
id: "pushLocationRequestNotifications",
title: L10n.SettingsDetails.Location.Notifications.PushNotification.title
),
.init(
id: "urlSchemeLocationRequestNotifications",
title: L10n.SettingsDetails.Location.Notifications.UrlScheme.title
),
.init(
id: "xCallbackURLLocationRequestNotifications",
title: L10n.SettingsDetails.Location.Notifications.XCallbackUrl.title
),
]
var body: some View {
List {
Section {
ForEach(toggles) { toggle in
PrefsToggleRow(key: toggle.id, title: toggle.title)
}
}
}
.navigationTitle(L10n.SettingsDetails.Location.Notifications.header)
.navigationBarTitleDisplayMode(.inline)
}
}
private struct PrefsToggleRow: View {
let key: String
let title: String
@State private var value: Bool
init(key: String, title: String) {
self.key = key
self.title = title
_value = State(initialValue: prefs.bool(forKey: key))
}
var body: some View {
SwiftUI.Toggle(isOn: Binding(
get: { value },
set: { newValue in
value = newValue
prefs.set(newValue, forKey: key)
}
)) {
Text(title)
}
}
}

View File

@@ -1,86 +0,0 @@
import Eureka
import RealmSwift
import Shared
class NotificationDebugNotificationsViewController: HAFormViewController {
override func viewDidLoad() {
super.viewDidLoad()
title = L10n.SettingsDetails.Location.Notifications.header
form +++ Section()
<<< SwitchRow {
$0.title = L10n.SettingsDetails.Location.Notifications.Enter.title
$0.value = prefs.bool(forKey: "enterNotifications")
}.onChange({ row in
if let val = row.value {
prefs.set(val, forKey: "enterNotifications")
}
})
<<< SwitchRow {
$0.title = L10n.SettingsDetails.Location.Notifications.Exit.title
$0.value = prefs.bool(forKey: "exitNotifications")
}.onChange({ row in
if let val = row.value {
prefs.set(val, forKey: "exitNotifications")
}
})
<<< SwitchRow {
$0.title = L10n.SettingsDetails.Location.Notifications.BeaconEnter.title
$0.value = prefs.bool(forKey: "beaconEnterNotifications")
}.onChange({ row in
if let val = row.value {
prefs.set(val, forKey: "beaconEnterNotifications")
}
})
<<< SwitchRow {
$0.title = L10n.SettingsDetails.Location.Notifications.BeaconExit.title
$0.value = prefs.bool(forKey: "beaconExitNotifications")
}.onChange({ row in
if let val = row.value {
prefs.set(val, forKey: "beaconExitNotifications")
}
})
<<< SwitchRow {
$0.title = L10n.SettingsDetails.Location.Notifications.LocationChange.title
$0.value = prefs.bool(forKey: "significantLocationChangeNotifications")
}.onChange({ row in
if let val = row.value {
prefs.set(val, forKey: "significantLocationChangeNotifications")
}
})
<<< SwitchRow {
$0.title = L10n.SettingsDetails.Location.Notifications.BackgroundFetch.title
$0.value = prefs.bool(forKey: "backgroundFetchLocationChangeNotifications")
}.onChange({ row in
if let val = row.value {
prefs.set(val, forKey: "backgroundFetchLocationChangeNotifications")
}
})
<<< SwitchRow {
$0.title = L10n.SettingsDetails.Location.Notifications.PushNotification.title
$0.value = prefs.bool(forKey: "pushLocationRequestNotifications")
}.onChange({ row in
if let val = row.value {
prefs.set(val, forKey: "pushLocationRequestNotifications")
}
})
<<< SwitchRow {
$0.title = L10n.SettingsDetails.Location.Notifications.UrlScheme.title
$0.value = prefs.bool(forKey: "urlSchemeLocationRequestNotifications")
}.onChange({ row in
if let val = row.value {
prefs.set(val, forKey: "urlSchemeLocationRequestNotifications")
}
})
<<< SwitchRow {
$0.title = L10n.SettingsDetails.Location.Notifications.XCallbackUrl.title
$0.value = prefs.bool(forKey: "xCallbackURLLocationRequestNotifications")
}.onChange({ row in
if let val = row.value {
prefs.set(val, forKey: "xCallbackURLLocationRequestNotifications")
}
})
}
}

View File

@@ -0,0 +1,245 @@
import PromiseKit
import Shared
import SwiftUI
struct NotificationRateLimitView: View {
@StateObject private var viewModel: NotificationRateLimitViewModel
var onChange: (RateLimitResponse) -> Void
init(
initialPromise: Promise<RateLimitResponse>? = nil,
onChange: @escaping (RateLimitResponse) -> Void = { _ in }
) {
_viewModel = StateObject(wrappedValue: NotificationRateLimitViewModel(initialPromise: initialPromise))
self.onChange = onChange
}
var body: some View {
List {
content
}
.navigationTitle(L10n.SettingsDetails.Notifications.RateLimits.header)
.navigationBarTitleDisplayMode(.inline)
.modifier(ConditionalRefreshableModifier(enabled: !Current.isCatalyst) {
await viewModel.refresh()
})
.toolbar {
// `if` directly inside `.toolbar` requires iOS 16+ ToolbarContentBuilder.
// Move the conditional inside the item so it works on iOS 15 too.
ToolbarItem(placement: .primaryAction) {
if Current.isCatalyst {
Button {
Task { await viewModel.refresh() }
} label: {
Image(systemSymbol: .arrowClockwise)
}
.disabled(viewModel.isRefreshing)
}
}
}
.onAppear {
viewModel.onChange = onChange
Task { await viewModel.refreshIfNeeded() }
viewModel.startTimer()
}
.onDisappear {
viewModel.stopTimer()
}
}
@ViewBuilder
private var content: some View {
switch viewModel.state {
case .loading:
Section {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
case let .loaded(response):
Section {
row(
title: L10n.SettingsDetails.Notifications.RateLimits.attempts,
value: format(response.rateLimits.attempts)
)
row(
title: L10n.SettingsDetails.Notifications.RateLimits.delivered,
value: format(response.rateLimits.successful)
)
row(
title: L10n.SettingsDetails.Notifications.RateLimits.errors,
value: format(response.rateLimits.errors)
)
row(
title: L10n.SettingsDetails.Notifications.RateLimits.total,
value: format(response.rateLimits.total)
)
row(
title: L10n.SettingsDetails.Notifications.RateLimits.resetsIn,
value: viewModel.resetsInText ?? resetsAtAbsolute(response.rateLimits.resetsAt)
)
} footer: {
Text(L10n.SettingsDetails.Notifications.RateLimits.footerWithParam(response.rateLimits.maximum))
}
case let .error(message):
Section {
Text(message)
.foregroundColor(.secondary)
Button(L10n.retryLabel) {
Task { await viewModel.refresh() }
}
}
}
}
private func row(title: String, value: String) -> some View {
HStack {
Text(title)
Spacer()
Text(value)
.foregroundColor(.secondary)
}
}
private func format(_ value: Int) -> String {
NumberFormatter.localizedString(from: NSNumber(value: value), number: .none)
}
private func resetsAtAbsolute(_ date: Date) -> String {
DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .medium)
}
}
// MARK: - View Model
@MainActor
final class NotificationRateLimitViewModel: ObservableObject {
enum State {
case loading
case loaded(RateLimitResponse)
case error(String)
}
enum RateLimitError: Error {
case noPushId
}
@Published private(set) var state: State = .loading
@Published private(set) var resetsInText: String?
@Published private(set) var isRefreshing = false
var onChange: (RateLimitResponse) -> Void = { _ in }
private var initialPromise: Promise<RateLimitResponse>?
private var timer: Timer?
private let utc = TimeZone(identifier: "UTC") ?? .current
init(initialPromise: Promise<RateLimitResponse>?) {
self.initialPromise = initialPromise
}
static func newPromise() -> Promise<RateLimitResponse> {
if let pushID = Current.settingsStore.pushID {
return NotificationRateLimitsAPI.rateLimits(pushID: pushID)
} else {
return .init(error: RateLimitError.noPushId)
}
}
func refreshIfNeeded() async {
if case .loading = state {
await refresh()
}
}
func refresh() async {
isRefreshing = true
defer { isRefreshing = false }
do {
let response: RateLimitResponse
if let initialPromise {
self.initialPromise = nil
response = try await initialPromise.asyncValue
} else {
response = try await Self.newPromise().asyncValue
}
state = .loaded(response)
onChange(response)
updateResetsIn()
} catch {
Current.Log.error("couldn't load rate limit: \(error)")
state = .error(error.localizedDescription)
}
}
func startTimer() {
stopTimer()
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.updateResetsIn()
}
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}
func stopTimer() {
timer?.invalidate()
timer = nil
}
private func updateResetsIn() {
var calendar = Calendar.current
calendar.timeZone = utc
guard let startOfNextDay = calendar.nextDate(
after: Date(),
matching: DateComponents(hour: 0, minute: 0),
matchingPolicy: .nextTimePreservingSmallerComponents
) else {
resetsInText = nil
return
}
let formatter = DateComponentsFormatter()
formatter.zeroFormattingBehavior = .pad
formatter.allowedUnits = [.hour, .minute, .second]
resetsInText = formatter.string(from: Date(), to: startOfNextDay)
}
}
// MARK: - Promise async helper
private extension Promise {
var asyncValue: T {
get async throws {
try await withCheckedThrowingContinuation { continuation in
self.done { value in
continuation.resume(returning: value)
}.catch { error in
continuation.resume(throwing: error)
}
}
}
}
}
// MARK: - Conditional refreshable modifier
private struct ConditionalRefreshableModifier: ViewModifier {
let enabled: Bool
let action: () async -> Void
@ViewBuilder
func body(content: Content) -> some View {
if enabled {
content.refreshable { await action() }
} else {
content
}
}
}

View File

@@ -1,164 +0,0 @@
import Eureka
import PromiseKit
import RealmSwift
import Shared
class NotificationRateLimitListViewController: HAFormViewController {
let utc = TimeZone(identifier: "UTC")!
let refreshControl = UIRefreshControl()
private var initialPromise: Promise<RateLimitResponse>?
static func newPromise() -> Promise<RateLimitResponse> {
if let pushID = Current.settingsStore.pushID {
return NotificationRateLimitsAPI.rateLimits(pushID: pushID)
} else {
return .init(error: RateLimitError.noPushId)
}
}
init(initialPromise: Promise<RateLimitResponse>?) {
self.initialPromise = initialPromise
super.init()
}
var rateLimitDidChange: (RateLimitResponse) -> Void = { _ in }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
setupTimer()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
teardownTimer()
}
override func viewDidLoad() {
super.viewDidLoad()
title = L10n.SettingsDetails.Notifications.RateLimits.header
if Current.isCatalyst {
navigationItem.rightBarButtonItems = [
UIBarButtonItem(
barButtonSystemItem: .refresh,
target: self,
action: #selector(refresh)
),
]
} else {
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged)
}
refresh()
form +++ Section {
$0.tag = "rateLimits"
}
}
private var timer: Timer? {
willSet {
timer?.invalidate()
}
}
private func setupTimer() {
timer = Timer.scheduledTimer(
timeInterval: 1,
target: self,
selector: #selector(updateTimer),
userInfo: nil,
repeats: true
)
}
private func teardownTimer() {
timer = nil
}
private enum RateLimitError: Error {
case noPushId
}
@objc private func refresh() {
if initialPromise == nil {
refreshControl.beginRefreshing()
}
firstly { () -> Promise<RateLimitResponse> in
if let initialPromise {
self.initialPromise = nil
return initialPromise
} else {
return Self.newPromise()
}
}.done { [form, rateLimitDidChange] response in
guard let section = form.sectionBy(tag: "rateLimits") else {
return
}
Current.Log.debug("updated rate limits: \(response)")
section.footer = HeaderFooterView(
title: L10n.SettingsDetails.Notifications.RateLimits.footerWithParam(response.rateLimits.maximum)
)
UIView.performWithoutAnimation {
section.removeAll()
section
<<< response.rateLimits.row(for: \.attempts)
<<< response.rateLimits.row(for: \.successful)
<<< response.rateLimits.row(for: \.errors)
<<< response.rateLimits.row(for: \.total)
<<< response.rateLimits.row(for: \.resetsAt)
}
rateLimitDidChange(response)
}.done { [weak self] _ in
self?.updateTimer()
}.ensure { [refreshControl] in
refreshControl.endRefreshing()
}.catch { [form] error in
Current.Log.error("couldn't load rate limit: \(error)")
guard let section = form.sectionBy(tag: "rateLimits") else {
return
}
section.removeAll()
section <<< ButtonRow {
$0.title = L10n.retryLabel
$0.onCellSelection { [weak self] _, _ in
self?.refresh()
}
}
}
}
@objc func updateTimer() {
var calendar = Calendar.current
calendar.timeZone = utc
guard let startOfNextDay = calendar.nextDate(
after: Date(),
matching: DateComponents(hour: 0, minute: 0),
matchingPolicy: .nextTimePreservingSmallerComponents
) else {
return
}
guard let row = form.rowBy(tag: "resetsIn") as? LabelRow else { return }
let formatter = DateComponentsFormatter()
formatter.zeroFormattingBehavior = .pad
formatter.allowedUnits = [.hour, .minute, .second]
row.value = formatter.string(from: Date(), to: startOfNextDay)
row.updateCell()
}
}

View File

@@ -1,4 +1,3 @@
import Eureka
import Foundation
import PromiseKit
import Shared
@@ -49,48 +48,3 @@ class NotificationRateLimitsAPI {
}
}
}
extension RateLimitResponse.RateLimits {
func row(for keyPath: KeyPath<Self, Int>) -> BaseRow {
LabelRow {
$0.value = NumberFormatter.localizedString(
from: NSNumber(value: self[keyPath: keyPath]),
number: .none
)
$0.title = { () -> String in
switch keyPath {
case \.attempts:
return L10n.SettingsDetails.Notifications.RateLimits.attempts
case \.successful:
return L10n.SettingsDetails.Notifications.RateLimits.delivered
case \.errors:
return L10n.SettingsDetails.Notifications.RateLimits.errors
case \.total:
return L10n.SettingsDetails.Notifications.RateLimits.total
case \.maximum:
return ""
default:
fatalError("missing key: \(keyPath)")
}
}()
}
}
func row(for keyPath: KeyPath<Self, Date>) -> BaseRow {
LabelRow { row in
row.value = DateFormatter.localizedString(
from: self[keyPath: keyPath],
dateStyle: .none,
timeStyle: .medium
)
switch keyPath {
case \.resetsAt:
row.title = L10n.SettingsDetails.Notifications.RateLimits.resetsIn
row.tag = "resetsIn"
default:
fatalError("missing key: \(keyPath)")
}
}
}
}

View File

@@ -0,0 +1,321 @@
import PromiseKit
import Shared
import SwiftUI
import UserNotifications
struct NotificationSettingsView: View {
/// Set to `true` when presented from an in-notification "open settings" flow so we show a Done button.
var showsDoneButton: Bool = false
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = NotificationSettingsViewModel()
@State private var showShareSheet = false
@State private var shareItems: [Any] = []
@State private var resetAlert: ResetAlertInfo?
@State private var ratePromise: Promise<RateLimitResponse>?
@State private var rateLimitRemaining: Int?
private struct ResetAlertInfo: Identifiable {
let id = UUID()
let title: String
let message: String
}
var body: some View {
List {
overviewSection
soundsSection
badgeSection
categoriesSection
debugSection
}
.navigationTitle(L10n.SettingsDetails.Notifications.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
// `if` directly inside `.toolbar` requires iOS 16+ ToolbarContentBuilder.
// Move the conditional inside the item so it works on iOS 15 too.
ToolbarItem(placement: .confirmationAction) {
if showsDoneButton {
Button(L10n.doneLabel) {
dismiss()
}
}
}
}
.onAppear {
viewModel.refreshPermissionStatus()
if ratePromise == nil {
let promise = NotificationRateLimitViewModel.newPromise()
promise.done { response in
rateLimitRemaining = response.rateLimits.remaining
}.cauterize()
ratePromise = promise
}
}
.onReceive(
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
) { _ in
viewModel.refreshPermissionStatus()
viewModel.refreshBadgeCount()
}
.sheet(isPresented: $showShareSheet) {
NotificationsShareSheet(activityItems: shareItems)
}
.alert(item: $resetAlert) { info in
Alert(
title: Text(info.title),
message: Text(info.message),
dismissButton: .default(Text(L10n.okLabel))
)
}
}
// MARK: - Sections
private var overviewSection: some View {
Section {
Text(L10n.SettingsDetails.Notifications.info)
.foregroundColor(.primary)
Button {
handlePermissionTap()
} label: {
HStack {
Text(L10n.SettingsDetails.Notifications.Permission.title)
.foregroundColor(.primary)
Spacer()
Text(viewModel.permissionText)
.foregroundColor(.secondary)
Image(systemSymbol: .chevronRight)
.font(.caption)
.foregroundColor(.secondary)
}
}
Link(destination: URL(string: "https://companion.home-assistant.io/app/ios/notifications")!) {
HStack {
Text(L10n.SettingsDetails.learnMore)
Spacer()
Image(systemSymbol: .arrowUpForwardSquare)
.font(.caption)
}
}
}
}
private var soundsSection: some View {
Section {
NavigationLink {
NotificationSoundsView()
} label: {
Text(L10n.SettingsDetails.Notifications.Sounds.title)
}
} footer: {
Text(L10n.SettingsDetails.Notifications.Sounds.footer)
}
}
private var badgeSection: some View {
Section {
Button {
UIApplication.shared.applicationIconBadgeNumber = 0
viewModel.refreshBadgeCount()
} label: {
HStack {
Text(L10n.SettingsDetails.Notifications.BadgeSection.Button.title)
.foregroundColor(.primary)
Spacer()
Text(viewModel.badgeCountText)
.foregroundColor(.secondary)
}
}
SwiftUI.Toggle(
L10n.SettingsDetails.Notifications.BadgeSection.AutomaticSetting.title,
isOn: $viewModel.clearBadgeAutomatically
)
} footer: {
Text(L10n.SettingsDetails.Notifications.BadgeSection.AutomaticSetting.description)
}
}
private var categoriesSection: some View {
Section {
NavigationLink {
categoriesDestination
} label: {
Text(L10n.SettingsDetails.Notifications.Categories.header)
}
Text(L10n.SettingsDetails.Notifications.Categories.deprecatedNote)
.font(.footnote)
.foregroundColor(.secondary)
}
}
private var categoriesDestination: some View {
// Category list migration is handled by the notification-categories slice.
// Keep the existing Eureka controller until that migration lands.
embed(NotificationCategoryListViewController())
.navigationTitle(L10n.SettingsDetails.Notifications.Categories.header)
}
private var debugSection: some View {
Section {
NavigationLink {
NotificationRateLimitView(initialPromise: ratePromise) { response in
rateLimitRemaining = response.rateLimits.remaining
}
} label: {
HStack {
Text(L10n.SettingsDetails.Notifications.RateLimits.header)
Spacer()
if let remaining = rateLimitRemaining {
Text(NumberFormatter.localizedString(from: NSNumber(value: remaining), number: .decimal))
.foregroundColor(.secondary)
}
}
}
NavigationLink {
NotificationDebugNotificationsView()
} label: {
Text(L10n.SettingsDetails.Location.Notifications.header)
}
Button {
guard let id = viewModel.pushID else { return }
shareItems = [id]
showShareSheet = true
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(L10n.SettingsDetails.Notifications.PushIdSection.header)
.foregroundColor(.primary)
Text(viewModel.pushIDDisplay)
.foregroundColor(.secondary)
.font(.footnote)
.lineLimit(1)
.truncationMode(.middle)
}
}
Button {
viewModel.resetPushID { result in
switch result {
case .success:
break
case let .failure(error):
resetAlert = ResetAlertInfo(
title: L10n.errorLabel,
message: error.localizedDescription
)
}
}
} label: {
Text(L10n.Settings.ResetSection.ResetRow.title)
.foregroundColor(.red)
}
} header: {
Text(L10n.debugSectionLabel)
}
}
// MARK: - Actions
private func handlePermissionTap() {
let wasDetermined = viewModel.lastPermissionSeen != nil && viewModel.lastPermissionSeen != .notDetermined
UNUserNotificationCenter.current().requestAuthorization(options: .defaultOptions) { _, _ in
Task { @MainActor in
viewModel.refreshPermissionStatus()
if wasDetermined {
URLOpener.shared.openSettings(destination: .notification, completionHandler: nil)
}
}
}
}
}
// MARK: - Share Sheet
struct NotificationsShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
}
// MARK: - View Model
@MainActor
final class NotificationSettingsViewModel: ObservableObject {
@Published var permissionText: String = ""
@Published var lastPermissionSeen: UNAuthorizationStatus?
@Published var badgeCountText: String = ""
// `Self` can't be referenced from a stored-property initializer in a class; use the
// type name explicitly.
@Published var pushIDDisplay: String = NotificationSettingsViewModel
.displayForPushID(Current.settingsStore.pushID)
@Published var clearBadgeAutomatically: Bool = Current.settingsStore.clearBadgeAutomatically {
didSet {
Current.settingsStore.clearBadgeAutomatically = clearBadgeAutomatically
}
}
var pushID: String? { Current.settingsStore.pushID }
private static func displayForPushID(_ id: String?) -> String {
id ?? L10n.SettingsDetails.Notifications.PushIdSection.notRegistered
}
init() {
refreshBadgeCount()
}
func refreshBadgeCount() {
let value = UIApplication.shared.applicationIconBadgeNumber
badgeCountText = NumberFormatter.localizedString(from: NSNumber(value: value), number: .decimal)
}
func refreshPermissionStatus() {
UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async {
self.lastPermissionSeen = settings.authorizationStatus
self.permissionText = Self.permissionText(for: settings.authorizationStatus)
}
}
}
private static func permissionText(for status: UNAuthorizationStatus) -> String {
switch status {
case .ephemeral, .authorized, .provisional:
return L10n.SettingsDetails.Notifications.Permission.enabled
case .denied:
return L10n.SettingsDetails.Notifications.Permission.disabled
case .notDetermined:
return L10n.SettingsDetails.Notifications.Permission.needsRequest
@unknown default:
return L10n.SettingsDetails.Notifications.Permission.disabled
}
}
// PromiseKit also exports a single-parameter `Result`, so qualify with `Swift.Result`.
func resetPushID(completion: @escaping (Swift.Result<Void, Error>) -> Void) {
Current.Log.verbose("Resetting push token!")
firstly {
Current.notificationManager.resetPushID()
}.done { [weak self] newToken in
self?.pushIDDisplay = Self.displayForPushID(newToken)
}.then { _ in
when(fulfilled: Current.apis.map { $0.updateRegistration() })
}.done { _ in
completion(.success(()))
}.catch { error in
Current.Log.error("Error resetting push token: \(error)")
completion(.failure(error))
}
}
}

View File

@@ -1,311 +0,0 @@
import Eureka
import FirebaseInstallations
import FirebaseMessaging
import PromiseKit
import RealmSwift
import Shared
import UIKit
class NotificationSettingsViewController: HAFormViewController {
public var doneButton: Bool = false
private var observerTokens: [Any] = []
deinit {
for token in observerTokens {
NotificationCenter.default.removeObserver(token)
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if doneButton {
navigationItem.rightBarButtonItem = nil
doneButton = false
}
}
override func viewDidLoad() {
super.viewDidLoad()
if doneButton {
let doneButton = UIBarButtonItem(
barButtonSystemItem: .done,
target: self,
action: #selector(closeSettingsDetailView(_:))
)
navigationItem.setRightBarButton(doneButton, animated: true)
}
title = L10n.SettingsDetails.Notifications.title
form
+++ Section()
<<< InfoLabelRow {
$0.title = L10n.SettingsDetails.Notifications.info
$0.displayType = .primary
}
<<< notificationPermissionRow()
<<< LearnMoreButtonRow {
$0.value = URL(string: "https://companion.home-assistant.io/app/ios/notifications")!
}
+++ Section(
footer: L10n.SettingsDetails.Notifications.Sounds.footer
)
<<< ButtonRow {
$0.title = L10n.SettingsDetails.Notifications.Sounds.title
$0.presentationMode = .show(controllerProvider: ControllerProvider.callback {
NotificationSoundsViewController()
}, onDismiss: nil)
}
+++ Section(footer: L10n.SettingsDetails.Notifications.BadgeSection.AutomaticSetting.description)
<<< ButtonRow {
$0.title = L10n.SettingsDetails.Notifications.BadgeSection.Button.title
$0.cellStyle = .value1
var lastValue: Int?
let update = { [weak row = $0] in
guard let row else { return }
let value = UIApplication.shared.applicationIconBadgeNumber
guard value != lastValue else { return }
row.value = NumberFormatter.localizedString(
from: NSNumber(value: value),
number: .decimal
)
row.updateCell()
lastValue = value
}
// timer because kvo only works on manually changing it, and this is easiest/cheap
let timer = Timer.scheduledTimer(
withTimeInterval: 1.0,
repeats: true,
block: { _ in update() }
)
// kvo so internally setting it updates instantly
let token = UIApplication.shared.observe(
\.applicationIconBadgeNumber,
changeHandler: { _, _ in update() }
)
update()
after(life: self).done {
token.invalidate()
timer.invalidate()
}
$0.cellUpdate { cell, row in
cell.textLabel?.textAlignment = .natural
cell.detailTextLabel?.text = row.value
}
$0.onCellSelection { _, _ in
UIApplication.shared.applicationIconBadgeNumber = 0
}
}
<<< SwitchRow {
$0.title = L10n.SettingsDetails.Notifications.BadgeSection.AutomaticSetting.title
$0.value = Current.settingsStore.clearBadgeAutomatically
$0.onChange { row in
Current.settingsStore.clearBadgeAutomatically = row.value ?? true
}
}
+++ Section()
<<< ButtonRow {
$0.title = L10n.SettingsDetails.Notifications.Categories.header
$0.presentationMode = .show(controllerProvider: ControllerProvider.callback {
NotificationCategoryListViewController()
}, onDismiss: { vc in
_ = vc.navigationController?.popViewController(animated: true)
})
}
<<< InfoLabelRow {
$0.title = L10n.SettingsDetails.Notifications.Categories.deprecatedNote
}
+++ Section(
header: L10n.debugSectionLabel,
footer: nil
)
<<< ButtonRowOf<Int> { row in
let value = NotificationRateLimitListViewController.newPromise()
row.cellStyle = .value1
func update(for response: RateLimitResponse) {
row.value = response.rateLimits.remaining
row.updateCell()
}
value.done { response in
update(for: response)
}.cauterize()
row.title = L10n.SettingsDetails.Notifications.RateLimits.header
row.presentationMode = .show(controllerProvider: ControllerProvider.callback {
let controller = NotificationRateLimitListViewController(initialPromise: value)
controller.rateLimitDidChange = { rateLimit in
update(for: rateLimit)
}
return controller
}, onDismiss: nil)
row.displayValueFor = { value in
value.map {
NumberFormatter.localizedString(
from: NSNumber(value: $0),
number: .decimal
)
}
}
}
<<< ButtonRow {
$0.title = L10n.SettingsDetails.Location.Notifications.header
$0.presentationMode = .show(controllerProvider: ControllerProvider.callback {
NotificationDebugNotificationsViewController()
}, onDismiss: nil)
}
<<< LabelRow("pushID") {
$0.title = L10n.SettingsDetails.Notifications.PushIdSection.header
if let pushID = Current.settingsStore.pushID {
$0.value = pushID
} else {
$0.value = L10n.SettingsDetails.Notifications.PushIdSection.notRegistered
}
$0.cellSetup { cell, _ in
cell.detailTextLabel?.lineBreakMode = .byTruncatingMiddle
}
$0.cellUpdate { cell, _ in
cell.selectionStyle = .default
}
$0.onCellSelection { [weak self] cell, row in
guard let id = Current.settingsStore.pushID else { return }
let vc = UIActivityViewController(activityItems: [id], applicationActivities: nil)
with(vc.popoverPresentationController) {
$0?.sourceView = cell
$0?.sourceRect = cell.bounds
}
self?.present(vc, animated: true, completion: nil)
row.deselect(animated: true)
}
}
<<< ButtonRow {
$0.tag = "resetPushID"
$0.title = L10n.Settings.ResetSection.ResetRow.title
}.cellUpdate { cell, _ in
cell.textLabel?.textColor = .red
}.onCellSelection { [weak self] cell, _ in
Current.Log.verbose("Resetting push token!")
firstly {
Current.notificationManager.resetPushID()
}.done { token in
guard let idRow = self?.form.rowBy(tag: "pushID") as? LabelRow else { return }
idRow.value = token
idRow.updateCell()
}.then { _ in
when(fulfilled: Current.apis.map { $0.updateRegistration() })
}.catch { error in
Current.Log.error("Error resetting push token: \(error)")
let alert = UIAlertController(
title: L10n.errorLabel,
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: L10n.okLabel, style: .default, handler: nil))
self?.present(alert, animated: true, completion: nil)
alert.popoverPresentationController?.sourceView = cell.formViewController()?.view
}
}
}
@objc func closeSettingsDetailView(_ sender: UIButton) {
dismiss(animated: true, completion: nil)
}
private func notificationPermissionRow() -> BaseRow {
var lastPermissionSeen: UNAuthorizationStatus?
func update(_ row: LabelRow) {
UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async {
lastPermissionSeen = settings.authorizationStatus
row.value = {
switch settings.authorizationStatus {
case .ephemeral:
return L10n.SettingsDetails.Notifications.Permission.enabled
case .authorized, .provisional:
return L10n.SettingsDetails.Notifications.Permission.enabled
case .denied:
return L10n.SettingsDetails.Notifications.Permission.disabled
case .notDetermined:
return L10n.SettingsDetails.Notifications.Permission.needsRequest
@unknown default:
return L10n.SettingsDetails.Notifications.Permission.disabled
}
}()
row.updateCell()
}
}
}
return LabelRow { row in
row.title = L10n.SettingsDetails.Notifications.Permission.title
update(row)
observerTokens.append(NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
// in case the user jumps to settings and changes while we're open, update the value
update(row)
})
row.cellUpdate { cell, _ in
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
}
row.onCellSelection { _, row in
UNUserNotificationCenter.current().requestAuthorization(options: .defaultOptions) { _, _ in
DispatchQueue.main.async {
update(row)
row.deselect(animated: true)
if lastPermissionSeen != .notDetermined {
// if we weren't prompting for permission with this request, open settings
// we can't avoid the request code-path since getting settings is async
URLOpener.shared.openSettings(destination: .notification, completionHandler: nil)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,579 @@
import AVFoundation
import PromiseKit
import Shared
import SwiftUI
import UniformTypeIdentifiers
struct NotificationSoundsView: View {
enum SoundCategory: Int, CaseIterable, Identifiable {
case imported
case bundled
case system
var id: Int { rawValue }
var title: String {
switch self {
case .imported: return L10n.SettingsDetails.Notifications.Sounds.imported
case .bundled: return L10n.SettingsDetails.Notifications.Sounds.bundled
case .system: return L10n.SettingsDetails.Notifications.Sounds.system
}
}
}
@StateObject private var viewModel = NotificationSoundsViewModel()
@EnvironmentObject private var viewControllerProvider: ViewControllerProvider
@State private var selected: SoundCategory = .imported
@State private var showImporter = false
@State private var alert: AlertInfo?
private struct AlertInfo: Identifiable {
let id = UUID()
let title: String
let message: String
}
private var categories: [SoundCategory] {
if Current.isCatalyst {
return [.imported, .bundled]
}
return SoundCategory.allCases
}
var body: some View {
List {
Section {
Picker("", selection: $selected) {
ForEach(categories) { category in
Text(category.title).tag(category)
}
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
switch selected {
case .imported:
importedSection
case .bundled:
bundledSection
case .system:
systemSection
}
}
.navigationTitle(L10n.SettingsDetails.Notifications.Sounds.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
if let url = URL(string: "https://companion.home-assistant.io/app/ios/notifications-sounds") {
openURLInBrowser(url, viewControllerProvider.viewController)
}
} label: {
Image(systemSymbol: .questionmarkCircle)
}
}
}
.overlay {
if viewModel.isBusy {
ProgressOverlay()
}
}
.fileImporter(
isPresented: $showImporter,
allowedContentTypes: [.audio, .data],
allowsMultipleSelection: true
) { result in
handleImport(result: result)
}
.alert(item: $alert) { info in
Alert(
title: Text(info.title),
message: Text(info.message),
dismissButton: .default(Text(L10n.okLabel))
)
}
.onAppear {
viewModel.loadSounds()
}
.onDisappear {
viewModel.stopPlayback()
}
}
// MARK: - Sections
private var importedSection: some View {
Section {
ForEach(viewModel.imported, id: \.self) { url in
soundRow(url: url)
}
.onDelete { indexSet in
// Iterate descending so each removal doesn't shift the indices we still
// need to read out of the source array.
for index in indexSet.sorted(by: >) {
guard index < viewModel.imported.count else { continue }
let url = viewModel.imported[index]
do {
try viewModel.deleteSound(url)
} catch {
presentError(error)
}
}
}
if Current.isCatalyst {
Text(L10n.SettingsDetails.Notifications.Sounds.importMacInstructions)
.foregroundColor(.secondary)
Button(L10n.SettingsDetails.Notifications.Sounds.importMacOpenFolder) {
viewModel.openLibrarySoundsFolder()
}
} else {
Button(L10n.SettingsDetails.Notifications.Sounds.importCustom) {
showImporter = true
}
Button(L10n.SettingsDetails.Notifications.Sounds.importFileSharing) {
Task {
do {
let count = try await viewModel.importFromFileSharing()
presentImported(count: count)
} catch {
presentError(error)
}
}
}
}
} footer: {
Text(L10n.SettingsDetails.Notifications.Sounds.footer)
}
}
private var bundledSection: some View {
Section {
ForEach(viewModel.bundled, id: \.self) { url in
soundRow(url: url)
}
}
}
private var systemSection: some View {
Section {
ForEach(viewModel.system, id: \.self) { url in
soundRow(url: url)
}
.onDelete { indexSet in
// Iterate descending so each removal doesn't shift the indices we still
// need to read out of the source array.
for index in indexSet.sorted(by: >) {
guard index < viewModel.system.count else { continue }
let url = viewModel.system[index]
do {
try viewModel.deleteSound(url)
} catch {
presentError(error)
}
}
}
Button(L10n.SettingsDetails.Notifications.Sounds.importSystem) {
Task {
do {
let count = try await viewModel.importSystemSounds()
presentImported(count: count)
} catch {
presentError(error)
}
}
}
}
}
private func soundRow(url: URL) -> some View {
Button {
viewModel.play(url: url) { error in
presentError(error)
}
} label: {
HStack {
Text(url.lastPathComponent)
.foregroundColor(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
Button {
UIPasteboard.general.string = url.lastPathComponent
} label: {
Text(L10n.copyLabel)
}
.buttonStyle(.borderless)
}
}
}
// MARK: - Helpers
// PromiseKit also exports a single-parameter `Result`, so qualify with `Swift.Result`.
private func handleImport(result: Swift.Result<[URL], Error>) {
switch result {
case let .success(urls):
Task { await viewModel.importPickedFiles(urls) { error in
await MainActor.run { presentError(error) }
} }
case let .failure(error):
presentError(error)
}
}
private func presentError(_ error: Error) {
alert = AlertInfo(title: L10n.errorLabel, message: error.localizedDescription)
}
private func presentImported(count: Int) {
alert = AlertInfo(
title: L10n.SettingsDetails.Notifications.Sounds.ImportedAlert.title,
message: L10n.SettingsDetails.Notifications.Sounds.ImportedAlert.message(count)
)
}
}
// MARK: - Progress Overlay
private struct ProgressOverlay: View {
var body: some View {
ZStack {
Color.black.opacity(0.3)
.ignoresSafeArea()
ProgressView()
.progressViewStyle(.circular)
.padding(24)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
}
// MARK: - View Model
@MainActor
final class NotificationSoundsViewModel: ObservableObject {
@Published var imported: [URL] = []
@Published var bundled: [URL] = []
@Published var system: [URL] = []
@Published var isBusy = false
private var audioPlayer: AVAudioPlayer?
func loadSounds() {
imported = (try? importedFilesWithSuffix(".wav")) ?? []
if Current.isCatalyst {
imported = []
}
imported.sort(by: { $0.lastPathComponent < $1.lastPathComponent })
bundled = (Bundle.main.urls(forResourcesWithExtension: "wav", subdirectory: nil) ?? [])
.sorted(by: { $0.lastPathComponent < $1.lastPathComponent })
system = ((try? importedFilesWithSuffix(".caf")) ?? [])
.sorted(by: { $0.lastPathComponent < $1.lastPathComponent })
}
func play(url: URL, onError: (Error) -> Void) {
do {
audioPlayer = try AVAudioPlayer(contentsOf: url)
audioPlayer?.play()
} catch {
Current.Log.error("Error when playing sound \(url.lastPathComponent): \(error)")
onError(error)
}
}
func stopPlayback() {
audioPlayer?.stop()
audioPlayer = nil
}
func deleteSound(_ url: URL) throws {
Current.Log.verbose("Deleting sound at \(url)")
do {
try FileManager.default.removeItem(at: url)
} catch {
throw SoundError(soundURL: nil, kind: .deleteError, underlying: error)
}
imported.removeAll { $0 == url }
system.removeAll { $0 == url }
}
func openLibrarySoundsFolder() {
do {
let url = try librarySoundsURL()
URLOpener.shared.open(url, options: [:], completionHandler: nil)
} catch {
Current.Log.error("couldn't open folder: \(error)")
}
}
func importFromFileSharing() async throws -> Int {
isBusy = true
defer { isBusy = false }
let sharingURL = try fileSharingPath()
let sounds = soundsInDirectory(sharingURL) ?? []
let copied = try await copySounds(sounds, category: .imported)
return copied.count
}
func importSystemSounds() async throws -> Int {
isBusy = true
defer { isBusy = false }
let soundsPath = URL(fileURLWithPath: "/System/Library/Audio/UISounds", isDirectory: true)
let systemSounds = await Task.detached(priority: .userInitiated) { () -> [URL] in
Self.enumerateSounds(path: soundsPath) ?? []
}.value
let copied = try await copySounds(systemSounds, category: .system)
return copied.count
}
// Pure file-system work opt out of the surrounding `@MainActor` isolation so
// it can be called from `Task.detached` for off-main enumeration.
private nonisolated static func enumerateSounds(path: URL) -> [URL]? {
guard let enu = FileManager.default.enumerator(at: path, includingPropertiesForKeys: [.isDirectoryKey]) else {
Current.Log.error("Unable to get enumerator!")
return nil
}
var foundURLs: [URL] = []
while let fileURL = enu.nextObject() as? URL {
if FileManager.default.isDirectory(fileURL) == false, ensureDurationStatic(fileURL) {
foundURLs.append(fileURL)
}
}
return foundURLs
}
private nonisolated static func ensureDurationStatic(_ soundURL: URL) -> Bool {
let duration = Double(CMTimeGetSeconds(AVURLAsset(url: soundURL).duration))
return duration > 0.0 && duration <= 30.0
}
func importPickedFiles(_ urls: [URL], onError: @escaping (Error) async -> Void) async {
isBusy = true
defer { isBusy = false }
let destinationURL: URL
do {
destinationURL = try librarySoundsURL()
} catch {
await onError(error)
return
}
for pickedURL in urls {
var options = AKConverter.Options()
options.format = "wav"
options.sampleRate = 48000
options.bitDepth = 32
options.eraseFile = true
let fileName = pickedURL.deletingPathExtension().lastPathComponent
let newSoundPath = destinationURL.appendingPathComponent("\(fileName).wav")
let didStart = pickedURL.startAccessingSecurityScopedResource()
defer {
if didStart { pickedURL.stopAccessingSecurityScopedResource() }
}
await withCheckedContinuation { continuation in
AKConverter(inputURL: pickedURL, outputURL: newSoundPath, options: options).start { error in
Task { @MainActor in
if let error {
let sError = SoundError(
soundURL: newSoundPath,
kind: .conversionFailed,
underlying: error
)
Current.Log.error("Experienced error during convert \(sError) (\(error))")
await onError(sError)
} else {
if !self.imported.contains(newSoundPath) {
self.imported.append(newSoundPath)
self.imported.sort(by: { $0.lastPathComponent < $1.lastPathComponent })
}
}
continuation.resume()
}
}
}
}
}
// MARK: - File helpers
func librarySoundsURL() throws -> URL {
do {
let librarySoundsPath = try FileManager.default.url(
for: .libraryDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false
).appendingPathComponent("Sounds")
if !Current.isCatalyst {
Current.Log.verbose("Creating sounds directory at \(librarySoundsPath)")
try FileManager.default.createDirectory(
at: librarySoundsPath,
withIntermediateDirectories: true,
attributes: nil
)
}
return librarySoundsPath
} catch {
throw SoundError(soundURL: nil, kind: .cantBuildLibrarySoundsPath, underlying: error)
}
}
private func importedFilesWithSuffix(_ suffix: String) throws -> [URL] {
do {
let files = try FileManager.default.contentsOfDirectory(
at: librarySoundsURL(),
includingPropertiesForKeys: nil
)
return files.filter { $0.lastPathComponent.hasSuffix(suffix) }
} catch {
throw SoundError(soundURL: nil, kind: .cantGetDirectoryContents, underlying: error)
}
}
private func fileSharingPath() throws -> URL {
do {
return try FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false
)
} catch {
throw SoundError(soundURL: nil, kind: .cantGetFileSharingPath, underlying: error)
}
}
private func soundsInDirectory(_ path: URL) -> [URL]? {
guard let enu = FileManager.default.enumerator(at: path, includingPropertiesForKeys: [.isDirectoryKey]) else {
Current.Log.error("Unable to get enumerator!")
return nil
}
var foundURLs: [URL] = []
while let fileURL = enu.nextObject() as? URL {
if FileManager.default.isDirectory(fileURL) == false, ensureDuration(fileURL) {
foundURLs.append(fileURL)
}
}
return foundURLs
}
private func ensureDuration(_ soundURL: URL) -> Bool {
let duration = Double(CMTimeGetSeconds(AVURLAsset(url: soundURL).duration))
return duration > 0.0 && duration <= 30.0
}
private func copySounds(_ soundURLs: [URL], category: NotificationSoundsView.SoundCategory) async throws -> [URL] {
guard !soundURLs.isEmpty else { return [] }
let destination = try librarySoundsURL()
var copied: [URL] = []
for soundURL in soundURLs {
let soundName = soundURL.lastPathComponent
let newURL = destination.appendingPathComponent(soundName)
Current.Log.verbose("Copying sound \(soundName) from \(soundURL) to \(newURL)")
if FileManager.default.fileExists(atPath: newURL.path) {
Current.Log.verbose("Sound \(soundName) already exists in ~/Library/Sounds, removing")
do {
try FileManager.default.removeItem(at: newURL)
} catch {
throw SoundError(soundURL: nil, kind: .deleteError, underlying: error)
}
}
do {
try FileManager.default.copyItem(at: soundURL, to: newURL)
} catch {
throw SoundError(soundURL: nil, kind: .copyError, underlying: error)
}
copied.append(newURL)
switch category {
case .imported:
if !imported.contains(newURL) {
imported.append(newURL)
}
case .system:
if !system.contains(newURL) {
system.append(newURL)
}
case .bundled:
break
}
}
imported.sort(by: { $0.lastPathComponent < $1.lastPathComponent })
system.sort(by: { $0.lastPathComponent < $1.lastPathComponent })
return copied
}
}
// MARK: - File Manager helper
private extension FileManager {
func isDirectory(_ url: URL) -> Bool? {
var isDir = ObjCBool(false)
if fileExists(atPath: url.path, isDirectory: &isDir) {
return isDir.boolValue
}
return nil
}
}
// MARK: - Error type
private struct SoundError: LocalizedError {
enum ErrorKind {
case cantBuildLibrarySoundsPath
case cantGetFileSharingPath
case cantGetDirectoryContents
case conversionFailed
case copyError
case deleteError
}
let soundURL: URL?
let kind: ErrorKind
let underlying: Error
var errorDescription: String? {
let description = underlying.localizedDescription
switch kind {
case .cantBuildLibrarySoundsPath:
return L10n.SettingsDetails.Notifications.Sounds.Error.cantBuildLibrarySoundsPath(description)
case .cantGetFileSharingPath:
return L10n.SettingsDetails.Notifications.Sounds.Error.cantGetFileSharingPath(description)
case .cantGetDirectoryContents:
return L10n.SettingsDetails.Notifications.Sounds.Error.cantGetDirectoryContents(description)
case .conversionFailed:
return L10n.SettingsDetails.Notifications.Sounds.Error.conversionFailed(description)
case .copyError:
return L10n.SettingsDetails.Notifications.Sounds.Error.copyError(description)
case .deleteError:
return L10n.SettingsDetails.Notifications.Sounds.Error.deleteError(description)
}
}
}

View File

@@ -1,512 +0,0 @@
import AVFoundation
import Eureka
import MBProgressHUD
import MobileCoreServices
import PromiseKit
import Shared
import UIKit
private var buttonAssociatedString: String = ""
class NotificationSoundsViewController: HAFormViewController, UIDocumentPickerDelegate {
public var onDismissCallback: ((UIViewController) -> Void)?
var audioPlayer: AVAudioPlayer?
override func viewDidLoad() {
super.viewDidLoad()
title = L10n.SettingsDetails.Notifications.Sounds.title
navigationItem.rightBarButtonItems = [
with(AppConstants.helpBarButtonItem) {
$0.action = #selector(help)
$0.target = self
},
]
var importedFileSharingSounds: [URL] = []
do {
if Current.isCatalyst {
importedFileSharingSounds = []
} else {
importedFileSharingSounds = try importedFilesWithSuffix(".wav")
}
} catch {
Current.Log.error("Error while getting imported file sharing sounds \(error)")
}
var importedSystemSounds: [URL] = []
do {
importedSystemSounds = try importedFilesWithSuffix(".caf")
} catch {
Current.Log.error("Error while getting imported system sounds \(error)")
}
form +++ SegmentedRow<String>("soundListChooser") {
var options = [String]()
options.append(L10n.SettingsDetails.Notifications.Sounds.imported)
options.append(L10n.SettingsDetails.Notifications.Sounds.bundled)
if !Current.isCatalyst {
options.append(L10n.SettingsDetails.Notifications.Sounds.system)
}
$0.options = options
$0.value = L10n.SettingsDetails.Notifications.Sounds.imported
}
form.append(getSoundsSection(
"imported",
L10n.SettingsDetails.Notifications.Sounds.imported,
fileURLs: importedFileSharingSounds
))
if let urls = Bundle.main.urls(forResourcesWithExtension: "wav", subdirectory: nil) {
form.append(getSoundsSection(
"bundled",
L10n.SettingsDetails.Notifications.Sounds.bundled,
fileURLs: urls
))
}
form.append(getSoundsSection(
"system",
L10n.SettingsDetails.Notifications.Sounds.system,
fileURLs: importedSystemSounds
))
}
func getSoundsSection(_ tag: String, _ header: String, fileURLs: [URL]) -> Section {
let sortedURLs = fileURLs.sorted(by: { $0.lastPathComponent < $1.lastPathComponent })
let section = Section()
section.tag = tag
section.hidden = Condition.predicate(NSPredicate(format: "$soundListChooser != %@", header))
let isImportedSection = header == L10n.SettingsDetails.Notifications.Sounds.imported
for sound in sortedURLs {
section.append(getSoundRow(sound, isImportedSection))
}
if isImportedSection, Current.isCatalyst {
section <<< InfoLabelRow {
$0.title = L10n.SettingsDetails.Notifications.Sounds.importMacInstructions
}
<<< ButtonRow {
$0.title = L10n.SettingsDetails.Notifications.Sounds.importMacOpenFolder
$0.onCellSelection { [weak self] _, _ in
guard let self else { return }
do {
try URLOpener.shared.open(librarySoundsURL(), options: [:], completionHandler: nil)
} catch {
Current.Log.error("couldn't open folder: \(error)")
}
}
}
} else if isImportedSection {
section
<<< ButtonRow {
$0.tag = "import_custom_sound"
$0.title = L10n.SettingsDetails.Notifications.Sounds.importCustom
}.onCellSelection { _, _ in
self.importTapped()
}
<<< ButtonRow {
$0.title = L10n.SettingsDetails.Notifications.Sounds.importFileSharing
}.onCellSelection { cell, _ in
MBProgressHUD.showAdded(to: self.view, animated: true)
firstly {
self.fileSharingPath()
}.then { path -> Promise<[URL]> in
let sounds: [URL] = self.soundsInDirectory(path) ?? []
return self.copySounds(sounds, "imported")
}.done { copied in
let title = L10n.SettingsDetails.Notifications.Sounds.ImportedAlert.title
let message = L10n.SettingsDetails.Notifications.Sounds.ImportedAlert.message(copied.count)
self.showAlert(message, title, popoverView: cell.contentView)
}.catch { error in
self.showAlert(error.localizedDescription, nil, popoverView: cell.contentView)
}.finally {
MBProgressHUD.hide(for: self.view, animated: true)
}
}
}
if header == L10n.SettingsDetails.Notifications.Sounds.system {
section
<<< ButtonRow {
$0.title = L10n.SettingsDetails.Notifications.Sounds.importSystem
}.onCellSelection { cell, _ in
MBProgressHUD.showAdded(to: self.view, animated: true)
DispatchQueue.global(qos: .userInitiated).async {
let soundsPath = URL(fileURLWithPath: "/System/Library/Audio/UISounds", isDirectory: true)
let systemSounds: [URL] = self.soundsInDirectory(soundsPath) ?? []
self.copySounds(systemSounds, "system").done { copied in
let title = L10n.SettingsDetails.Notifications.Sounds.ImportedAlert.title
let message = L10n.SettingsDetails.Notifications.Sounds.ImportedAlert.message(copied.count)
self.showAlert(message, title, popoverView: cell.contentView)
}.catch { error in
self.showAlert(error.localizedDescription, nil, popoverView: cell.contentView)
}.finally {
MBProgressHUD.hide(for: self.view, animated: true)
}
}
}
}
return section
}
@objc private func copyButtonTapped(_ button: UIButton) {
guard let string = objc_getAssociatedObject(button, &buttonAssociatedString) as? String else {
Current.Log.info("failed to copy from button \(button)")
return
}
UIPasteboard.general.string = string
}
func getSoundRow(_ fileURL: URL, _ enableDelete: Bool = false) -> ButtonRowOf<URL> {
let copyButton = with(UIButton(type: .system)) {
$0.setTitle(L10n.copyLabel, for: .normal)
$0.sizeToFit()
$0.addTarget(self, action: #selector(copyButtonTapped(_:)), for: .touchUpInside)
objc_setAssociatedObject($0, &buttonAssociatedString, fileURL.lastPathComponent, .OBJC_ASSOCIATION_COPY)
}
return ButtonRowOf<URL> {
$0.value = fileURL
$0.tag = fileURL.lastPathComponent
$0.title = fileURL.lastPathComponent
if enableDelete {
$0.trailingSwipe.actions = [SwipeAction(
style: .destructive,
title: L10n.delete,
handler: self.handleSwipeDelete
)]
$0.trailingSwipe.performsFirstActionWithFullSwipe = true
}
}.cellSetup { cell, _ in
cell.accessoryView = copyButton
}.cellUpdate { cell, _ in
cell.textLabel?.numberOfLines = 0
cell.textLabel?.textAlignment = .natural
cell.textLabel?.textColor = nil
}.onCellSelection { cell, _ in
do {
self.audioPlayer = try AVAudioPlayer(contentsOf: fileURL)
self.audioPlayer?.play()
} catch {
Current.Log.error("Error when playing sound \(fileURL.lastPathComponent): \(error)")
self.showAlert(error.localizedDescription, nil, popoverView: cell.contentView)
}
}
}
func handleSwipeDelete(action: SwipeAction, row: BaseRow, completionHandler: ((Bool) -> Void)?) {
guard let urlRow = row as? ButtonRowOf<URL>, let url = urlRow.value else { completionHandler?(false); return }
do {
try deleteSound(url)
} catch {
Current.Log.error("Error when deleting sound \(url): \(error)")
showAlert(error.localizedDescription, nil, popoverView: row.baseCell.contentView)
completionHandler?(false)
return
}
if let section = row.section, let indexPath = row.indexPath {
section.remove(at: indexPath.row)
}
completionHandler?(true)
}
@objc func importTapped() {
let picker = UIDocumentPickerViewController(documentTypes: [
String(kUTTypeAudio),
String(kUTTypeData),
], in: .import)
picker.delegate = self
picker.modalPresentationStyle = .fullScreen
picker.allowsMultipleSelection = true
present(picker, animated: true, completion: nil)
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
Current.Log.verbose("Did pick sounds at \(urls)")
for pickedURL in urls {
Current.Log.verbose("Processing picked sound at \(pickedURL)")
var options = AKConverter.Options()
options.format = "wav"
options.sampleRate = 48000
options.bitDepth = 32
options.eraseFile = true
let librarySoundsURL: URL
do {
librarySoundsURL = try self.librarySoundsURL()
} catch {
Current.Log.error("Error when getting library sounds URL \(error)")
showAlert(error.localizedDescription)
return
}
let fileName = pickedURL.deletingPathExtension().lastPathComponent
let newSoundPath = librarySoundsURL.appendingPathComponent("\(fileName).wav")
Current.Log.verbose("New sound path is \(newSoundPath)")
AKConverter(inputURL: pickedURL, outputURL: newSoundPath, options: options).start { error in
if let error {
let sError = SoundError(soundURL: newSoundPath, kind: .conversionFailed, underlying: error)
Current.Log.error("Experienced error during convert \(sError) (\(error))")
self.showAlert(sError.localizedDescription)
return
}
if self.form.rowBy(tag: newSoundPath.lastPathComponent) == nil,
var section = self.form.sectionBy(tag: "imported") {
section.insert(self.getSoundRow(newSoundPath, true), at: section.count - 1)
}
}
}
}
func librarySoundsURL() throws -> URL {
do {
let librarySoundsPath = try FileManager.default.url(
for: .libraryDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false
).appendingPathComponent("Sounds")
if !Current.isCatalyst {
// on Catalyst the sounds folder is a symlink to the global one, we don't want to mess with it
Current.Log.verbose("Creating sounds directory at \(librarySoundsPath)")
try FileManager.default.createDirectory(
at: librarySoundsPath,
withIntermediateDirectories: true,
attributes: nil
)
}
return librarySoundsPath
} catch {
throw SoundError(soundURL: nil, kind: .cantBuildLibrarySoundsPath, underlying: error)
}
}
func importedFilesWithSuffix(_ suffix: String) throws -> [URL] {
do {
let files = try FileManager.default.contentsOfDirectory(
at: librarySoundsURL(),
includingPropertiesForKeys: nil
)
return files.filter({ $0.lastPathComponent.hasSuffix(suffix) })
} catch {
throw SoundError(soundURL: nil, kind: .cantGetDirectoryContents, underlying: error)
}
}
func soundsInDirectory(_ path: URL) -> [URL]? {
guard let enu = FileManager.default.enumerator(at: path, includingPropertiesForKeys: [.isDirectoryKey]) else {
Current.Log.error("Unable to get enumerator!")
return nil
}
var foundURLs: [URL] = []
while let fileURL = enu.nextObject() as? URL {
if FileManager.default.isDirectory(fileURL) == false, ensureDuration(fileURL) {
foundURLs.append(fileURL)
}
}
return foundURLs
}
func fileSharingPath() -> Promise<URL> {
Promise { seal in
do {
try seal.fulfill(FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false
))
} catch {
seal.reject(SoundError(soundURL: nil, kind: .cantGetFileSharingPath, underlying: error))
}
}
}
// Thanks to http://stackoverflow.com/a/35624018/486182
// Must reboot device after installing new push sounds (http://stackoverflow.com/q/34998278/486182)
func copySounds(_ soundURLs: [URL], _ formSectionTag: String) throws -> [URL] {
guard !soundURLs.isEmpty else { return [URL]() }
let librarySoundsURL = try librarySoundsURL()
var copiedSounds: [URL] = []
for soundURL in soundURLs {
let soundName = soundURL.lastPathComponent
let newURL = librarySoundsURL.appendingPathComponent(soundName)
Current.Log.verbose("Copying sound \(soundName) from \(soundURL) to \(newURL)")
if FileManager.default.fileExists(atPath: newURL.path) {
Current.Log.verbose("Sound \(soundName) already exists in ~/Library/Sounds, removing")
try deleteSound(newURL)
}
do {
try FileManager.default.copyItem(at: soundURL, to: newURL)
} catch {
throw SoundError(soundURL: nil, kind: .copyError, underlying: error)
}
copiedSounds.append(newURL)
if form.rowBy(tag: newURL.lastPathComponent) == nil,
var section = form.sectionBy(tag: formSectionTag) {
section.insert(
getSoundRow(newURL),
at: formSectionTag == "system" ? section.count - 1 : section.count
)
}
}
return copiedSounds
}
func deleteSound(_ soundURL: URL) throws {
Current.Log.verbose("Deleting sound at \(soundURL)")
do {
try FileManager.default.removeItem(at: soundURL)
} catch {
throw SoundError(soundURL: nil, kind: .deleteError, underlying: error)
}
}
func copySounds(_ soundURLs: [URL], _ formSectionTag: String) -> Promise<[URL]> {
guard !soundURLs.isEmpty else { return Promise.value([URL]()) }
do {
let librarySoundsURL = try librarySoundsURL()
let promises: [Promise<URL>] = soundURLs.map { self.copySound(librarySoundsURL, $0, formSectionTag) }
return when(fulfilled: promises)
} catch {
return Promise(error: error)
}
}
func copySound(_ librarySoundsURL: URL, _ soundURL: URL, _ formSectionTag: String) -> Promise<URL> {
Promise { seal in
let soundName = soundURL.lastPathComponent
let newURL = librarySoundsURL.appendingPathComponent(soundName)
Current.Log.verbose("Copying sound \(soundName) from \(soundURL) to \(newURL)")
if FileManager.default.fileExists(atPath: newURL.path) {
Current.Log.verbose("Sound \(soundName) already exists in ~/Library/Sounds, removing")
do {
try FileManager.default.removeItem(at: newURL)
} catch {
seal.reject(SoundError(soundURL: nil, kind: .deleteError, underlying: error))
}
}
do {
try FileManager.default.copyItem(at: soundURL, to: newURL)
} catch {
seal.reject(SoundError(soundURL: nil, kind: .copyError, underlying: error))
}
DispatchQueue.main.async {
if self.form.rowBy(tag: newURL.lastPathComponent) === nil,
let section = self.form.sectionBy(tag: formSectionTag) {
section.append(self.getSoundRow(newURL))
}
}
seal.fulfill(newURL)
}
}
func ensureDuration(_ soundURL: URL) -> Bool {
let duration = Double(CMTimeGetSeconds(AVURLAsset(url: soundURL).duration))
return duration > 0.0 && duration <= 30.0
}
func showAlert(_ message: String, _ title: String? = L10n.errorLabel, popoverView: UIView? = nil) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: L10n.okLabel, style: .default, handler: nil))
present(alert, animated: true, completion: nil)
if let view = popoverView {
alert.popoverPresentationController?.sourceView = view
}
}
@objc private func help() {
openURLInBrowser(
URL(string: "https://companion.home-assistant.io/app/ios/notifications-sounds")!,
self
)
}
}
extension FileManager {
func isDirectory(_ url: URL) -> Bool? {
var isDir = ObjCBool(false)
if fileExists(atPath: url.path, isDirectory: &isDir) {
return isDir.boolValue
}
return nil
}
}
private struct SoundError: LocalizedError {
enum ErrorKind {
case cantBuildLibrarySoundsPath
case cantGetFileSharingPath
case cantGetDirectoryContents
case conversionFailed
case copyError
case deleteError
}
let soundURL: URL?
let kind: ErrorKind
let underlying: Error
public var errorDescription: String? {
let description = underlying.localizedDescription
switch kind {
case .cantBuildLibrarySoundsPath:
return L10n.SettingsDetails.Notifications.Sounds.Error.cantBuildLibrarySoundsPath(description)
case .cantGetFileSharingPath:
return L10n.SettingsDetails.Notifications.Sounds.Error.cantGetFileSharingPath(description)
case .cantGetDirectoryContents:
return L10n.SettingsDetails.Notifications.Sounds.Error.cantGetDirectoryContents(description)
case .conversionFailed:
return L10n.SettingsDetails.Notifications.Sounds.Error.conversionFailed(description)
case .copyError:
return L10n.SettingsDetails.Notifications.Sounds.Error.copyError(description)
case .deleteError:
return L10n.SettingsDetails.Notifications.Sounds.Error.deleteError(description)
}
}
}

View File

@@ -218,8 +218,7 @@ struct SettingsServersView: View {
struct SettingsNotificationsView: View {
var body: some View {
embed(NotificationSettingsViewController())
.navigationTitle(L10n.Settings.DetailsSection.NotificationSettingsRow.title)
NotificationSettingsView()
}
}

View File

@@ -46,7 +46,11 @@ enum SettingsRootDataSource {
$0.title = L10n.Settings.DetailsSection.NotificationSettingsRow.title
$0.icon = .bellOutlineIcon
$0.presentationMode = .show(controllerProvider: ControllerProvider.callback {
NotificationSettingsViewController()
let view = NavigationView {
NotificationSettingsView()
}
.navigationViewStyle(.stack)
return view.embeddedInHostingController()
}, onDismiss: nil)
}
}