mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-16 13:26:27 -05:00
## 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>
246 lines
7.3 KiB
Swift
246 lines
7.3 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|