Files
iOS/Sources/App/Settings/Notifications/NotificationCategoryEditorView.swift
Bruno Pantaleão Gonçalves 814152e5c3 Migrate Notification category/action editors to SwiftUI (#4563)
## Summary
Migrate the notification category list and the category/action editors
to SwiftUI:
- `NotificationCategoryListView`: local insert/delete, Realm-observed
server categories section, navigation to editor.
- `NotificationCategoryEditorView`: required fields, server-controlled
read-only mode, hidden preview/summary text areas, reorder/insert/delete
action rows, YAML service-call preview, toolbar help,
preview-notification action.
- `NotificationActionEditorView`: required title/identifier, conditional
text-input fields, foreground/destructive/authentication toggles,
server-controlled read-only mode, YAML trigger preview.

Also adds three reusable components:
- `YamlPreviewSection` — replaces Eureka `YamlSection` within this
slice; generalises the inline version previously in `NFCTagView`.
- `NotificationIdentifierField` — SwiftUI text-field helper enforcing
identifier casing/validation (replaces
`NotificationIdentifierEurekaRow`).
- `RealmResultsObserver` — `ObservableObject` wrapping
`AnyRealmCollection` with a notification token (reusable by future
slices).

Deletes the three old `*Configurator.swift` / `*ViewController.swift`
files plus `NotificationIdentifierEurekaRow.swift`.

## 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, #4562, #4564). The
`Eureka`, `ColorPickerRow`, and `ViewRow` pods stay until all slices
land.

**Reconciliation with #4562:** this PR embeds
`NotificationCategoryListView` inside the old
`NotificationSettingsViewController` via `UIHostingController` so the
list is reachable while the parent screen is still Eureka. Once #4562
merges, `NotificationSettingsView` should link directly to
`NotificationCategoryListView` — the hosting-controller wrapper becomes
unnecessary.

`YamlSection.swift` and `RealmSection.swift` are retained — still used
by `ActionConfigurator`, `NFCTagViewController`,
`SettingsDetailViewController`, and `ComplicationListViewController`.

`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>
2026-04-29 02:16:27 +02:00

392 lines
13 KiB
Swift

import RealmSwift
import Shared
import SwiftUI
import UserNotifications
/// SwiftUI replacement for `NotificationCategoryConfigurator`.
///
/// Edits a `NotificationCategory`, its hidden-preview placeholder, category
/// summary format, and the list of `NotificationAction`s. Supports
/// reorder/insert/delete for actions (capped at `maxActionsForCategory`), a
/// live YAML service-call preview, read-only mode when the category is
/// server-controlled, a help link in the toolbar and a preview-notification
/// action.
struct NotificationCategoryEditorView: View {
let existingCategory: NotificationCategory?
let onDismiss: (NotificationCategory?) -> Void
@Environment(\.dismiss) private var dismiss
@State private var name: String
@State private var identifier: String
@State private var hiddenPreviewsPlaceholder: String
@State private var categorySummaryFormat: String
@State private var actions: [NotificationAction]
@State private var editingAction: NotificationAction?
@State private var showingNewAction = false
@State private var showValidationAlert = false
@EnvironmentObject private var viewControllerProvider: ViewControllerProvider
private let isNewCategory: Bool
private let isServerControlled: Bool
private let category: NotificationCategory
private let maxActionsForCategory = 10
init(
category: NotificationCategory?,
onDismiss: @escaping (NotificationCategory?) -> Void
) {
self.existingCategory = category
self.onDismiss = onDismiss
let resolved = category ?? NotificationCategory()
self.category = resolved
self.isNewCategory = (category == nil)
self.isServerControlled = resolved.isServerControlled
_name = State(initialValue: resolved.Name)
_identifier = State(initialValue: resolved.Identifier)
let placeholderDefault = L10n.NotificationsConfigurator.Category.Rows.HiddenPreviewPlaceholder.default
let placeholder = resolved.HiddenPreviewsBodyPlaceholder ?? ""
_hiddenPreviewsPlaceholder = State(
initialValue: placeholder.isEmpty ? placeholderDefault : placeholder
)
let summaryDefault = L10n.NotificationsConfigurator.Category.Rows.CategorySummary.default
let summary = resolved.CategorySummaryFormat ?? ""
_categorySummaryFormat = State(
initialValue: summary.isEmpty ? summaryDefault : summary
)
_actions = State(initialValue: Array(resolved.Actions))
}
var body: some View {
Form {
settingsSection
if !isServerControlled {
hiddenPreviewSection
categorySummarySection
}
actionsSection
YamlPreviewSection(
header: L10n.NotificationsConfigurator.Category.ExampleCall.title,
yaml: yamlPreview
)
}
.navigationTitle(navigationTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarContent }
.sheet(isPresented: $showingNewAction) {
editorSheet(for: nil)
}
.sheet(item: $editingAction) { action in
editorSheet(for: action)
}
.alert(L10n.errorLabel, isPresented: $showValidationAlert) {
Button(L10n.okLabel, role: .cancel) {}
} message: {
Text(L10n.NotificationsConfigurator.Settings.footer)
}
}
// MARK: - Sections
private var settingsSection: some View {
Section(
header: Text(L10n.NotificationsConfigurator.Settings.header),
footer: Text(settingsFooter)
) {
HStack {
Text(L10n.NotificationsConfigurator.Category.Rows.Name.title)
Spacer()
TextField("", text: $name)
.multilineTextAlignment(.trailing)
.foregroundColor(name.isEmpty ? .red : .primary)
.disabled(isServerControlled)
}
NotificationIdentifierTextField(
title: L10n.NotificationsConfigurator.identifier,
text: $identifier,
uppercaseOnly: false,
isDisabled: isServerControlled || !isNewCategory
)
}
}
private var hiddenPreviewSection: some View {
Section(
header: Text(L10n.NotificationsConfigurator.Category.Rows.HiddenPreviewPlaceholder.header),
footer: Text(L10n.NotificationsConfigurator.Category.Rows.HiddenPreviewPlaceholder.footer)
) {
TextEditor(text: $hiddenPreviewsPlaceholder)
.frame(minHeight: 80)
}
}
private var categorySummarySection: some View {
Section(
header: Text(L10n.NotificationsConfigurator.Category.Rows.CategorySummary.header),
footer: Text(L10n.NotificationsConfigurator.Category.Rows.CategorySummary.footer)
) {
TextEditor(text: $categorySummaryFormat)
.frame(minHeight: 80)
}
}
private var actionsSection: some View {
Section(
header: Text(L10n.NotificationsConfigurator.Category.Rows.Actions.header),
footer: Text(isServerControlled ? "" : L10n.NotificationsConfigurator.Category.Rows.Actions.footer)
) {
ForEach(actions, id: \.uuid) { action in
Button {
editingAction = action
} label: {
HStack {
Text(action.Title.isEmpty ? action.Identifier : action.Title)
.foregroundColor(.primary)
Spacer()
if !isServerControlled {
Image(systemSymbol: .chevronRight)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
.disabled(isServerControlled)
}
.onDelete(perform: isServerControlled ? nil : deleteActions)
.onMove(perform: isServerControlled ? nil : moveActions)
if !isServerControlled, actions.count < maxActionsForCategory {
Button {
showingNewAction = true
} label: {
Label(L10n.addButtonLabel, systemSymbol: .plus)
}
}
}
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
// `if` directly inside a `@ToolbarContentBuilder` requires the iOS 16+
// ToolbarContentBuilder. Always emit the items and gate their content
// (a regular ViewBuilder context) so this compiles on iOS 15.
ToolbarItem(placement: .cancellationAction) {
if !isServerControlled {
Button(L10n.cancelLabel) {
onDismiss(nil)
dismiss()
}
}
}
ToolbarItem(placement: .confirmationAction) {
if !isServerControlled {
Button(L10n.saveLabel) {
if validate() {
save()
} else {
showValidationAlert = true
}
}
}
}
ToolbarItem(placement: .bottomBar) {
Button {
openHelp()
} label: {
Image(systemSymbol: .questionmarkCircle)
}
}
ToolbarItem(placement: .bottomBar) {
Spacer()
}
ToolbarItem(placement: .bottomBar) {
Button {
triggerPreviewNotification()
} label: {
Image(systemSymbol: .eye)
}
.disabled(identifier.isEmpty)
}
}
// MARK: - Derived values
private var navigationTitle: String {
if isNewCategory {
return L10n.NotificationsConfigurator.Category.NavigationBar.title
}
return name.isEmpty ? category.Name : name
}
private var settingsFooter: String {
if isServerControlled {
return ""
} else if isNewCategory {
return L10n.NotificationsConfigurator.Settings.footer
} else {
return L10n.NotificationsConfigurator.Settings.Footer.idSet
}
}
private var yamlPreview: String {
// Build a throwaway category with the current form values so the YAML
// reflects unsaved edits, matching the original Eureka behaviour.
let preview = NotificationCategory()
preview.Identifier = identifier
preview.Name = name
for action in actions {
preview.Actions.append(action)
}
return preview.exampleServiceCall
}
// MARK: - Actions mutation
private func deleteActions(at offsets: IndexSet) {
let removed = offsets.map { actions[$0] }
actions.remove(atOffsets: offsets)
let realm = Current.realm()
realm.reentrantWrite {
// Remove from the owning list if already persisted.
if category.realm != nil {
let indexes = category.Actions.enumerated().reduce(into: IndexSet()) { set, val in
if removed.contains(where: { $0.uuid == val.element.uuid }) {
set.insert(val.offset)
}
}
category.Actions.remove(atOffsets: indexes)
} else {
for action in removed {
if let index = category.Actions.firstIndex(where: { $0.uuid == action.uuid }) {
category.Actions.remove(at: index)
}
}
}
let uuids = removed.map(\.uuid)
realm.delete(realm.objects(NotificationAction.self).filter("uuid IN %@", uuids))
}
}
private func moveActions(from source: IndexSet, to destination: Int) {
actions.move(fromOffsets: source, toOffset: destination)
guard category.realm != nil else { return }
let realm = Current.realm()
realm.reentrantWrite {
category.Actions.removeAll()
for action in actions {
category.Actions.append(action)
}
}
}
// MARK: - Editor sheet
@ViewBuilder
private func editorSheet(for action: NotificationAction?) -> some View {
NavigationView {
NotificationActionEditorView(
category: category,
action: action
) { savedAction in
if let saved = savedAction {
if let index = actions.firstIndex(where: { $0.uuid == saved.uuid }) {
actions[index] = saved
} else {
actions.append(saved)
}
}
editingAction = nil
showingNewAction = false
}
}
.navigationViewStyle(.stack)
}
// MARK: - Save / validation
private func validate() -> Bool {
guard !name.isEmpty else { return false }
guard NotificationIdentifierField.isValid(identifier, uppercaseOnly: false) else { return false }
return true
}
private func save() {
let realm = Current.realm()
let target = existingCategory ?? category
realm.reentrantWrite {
target.Name = name
target.Identifier = identifier
target.HiddenPreviewsBodyPlaceholder = hiddenPreviewsPlaceholder
target.CategorySummaryFormat = categorySummaryFormat
// Replace action list using current order. This must run for both
// managed and unmanaged categories so the caller can persist the
// full object graph with `realm.add(_:update:)`.
target.Actions.removeAll()
for action in actions {
target.Actions.append(action)
}
}
onDismiss(target)
dismiss()
}
// MARK: - Toolbar actions
private func openHelp() {
guard let url = URL(string: "https://companion.home-assistant.io/app/ios/actionable-notifications") else {
return
}
// Pass the hosting view controller so the SafariInApp browser preference works
// (it requires a non-nil presenter to show its in-app browser).
openURLInBrowser(url, viewControllerProvider.viewController)
}
private func triggerPreviewNotification() {
let content = UNMutableNotificationContent()
content.title = L10n.NotificationsConfigurator.Category.PreviewNotification.title
content.body = L10n.NotificationsConfigurator.Category.PreviewNotification
.body(name.isEmpty ? identifier : name)
content.sound = .default
// `UNNotificationCategory` instances are registered with the uppercased identifier
// (see `NotificationCategory.categories`); match that here so the registered
// actions actually attach to the preview notification.
content.categoryIdentifier = identifier.uppercased()
content.userInfo = ["preview": true]
UNUserNotificationCenter.current().add(UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil
))
}
}
// MARK: - NotificationAction Identifiable conformance
extension NotificationAction: Identifiable {
public var id: String { uuid }
}