mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-16 13:26:27 -05:00
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:
committed by
GitHub
parent
773c165e2c
commit
c59cd48a6d
@@ -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 */,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
579
Sources/App/Settings/Notifications/NotificationSoundsView.swift
Normal file
579
Sources/App/Settings/Notifications/NotificationSoundsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,8 +218,7 @@ struct SettingsServersView: View {
|
||||
|
||||
struct SettingsNotificationsView: View {
|
||||
var body: some View {
|
||||
embed(NotificationSettingsViewController())
|
||||
.navigationTitle(L10n.Settings.DetailsSection.NotificationSettingsRow.title)
|
||||
NotificationSettingsView()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user