iOS/Sources/App/Settings/Connection/ConnectionSettingsView.swift
Bruno Pantaleão Gonçalves 5f1ae83d05
Make app database update in settings on-demand trigger (#4244)
<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->

## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->

## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->
2026-01-21 12:58:22 +01:00

590 lines
20 KiB
Swift

import HAKit
import Shared
import SwiftUI
import Version
/// SwiftUI view for managing server connection settings
struct ConnectionSettingsView: View {
@StateObject private var viewModel: ConnectionSettingsViewModel
@Environment(\.dismiss) private var dismiss
@State private var showShareSheet = false
@State private var showSecurityLevelPicker = false
@State private var activityViewController: UIActivityViewController?
@State private var isDeleteConfirmationPresented = false
@State private var deleteError: Error?
@State private var showDeleteError = false
@State private var showInternalURLSheet = false
@State private var showExternalURLSheet = false
@State private var showLocationPrivacySheet = false
@State private var showSensorPrivacySheet = false
let onDismiss: (() -> Void)?
init(server: Server, onDismiss: (() -> Void)? = nil) {
self._viewModel = StateObject(wrappedValue: ConnectionSettingsViewModel(server: server))
self.onDismiss = onDismiss
}
var body: some View {
List {
detailsSection
privacySection
statusSection
deleteSection
}
.navigationTitle(viewModel.serverName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if viewModel.hasMultipleServers {
activateSection
}
}
ToolbarItem(placement: .topBarTrailing) {
if viewModel.canShareServer {
Button {
if let activityVC = viewModel.shareServer() {
activityViewController = activityVC
showShareSheet = true
}
} label: {
Image(systemSymbol: .squareAndArrowUp)
}
.tint(.haPrimary)
.modify { view in
if #available(iOS 26.0, *), !Current.isCatalyst {
view.buttonStyle(.glassProminent)
} else {
view
}
}
}
}
}
.sheet(isPresented: $showShareSheet) {
if let activityVC = activityViewController {
embed(activityVC)
}
}
.sheet(isPresented: $showInternalURLSheet) {
NavigationView {
ConnectionURLView(
server: viewModel.server,
urlType: .internal
)
.navigationViewStyle(.stack)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
CloseButton {
showInternalURLSheet = false
}
}
}
}
}
.sheet(isPresented: $showExternalURLSheet) {
NavigationView {
ConnectionURLView(
server: viewModel.server,
urlType: .external
)
.navigationViewStyle(.stack)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
CloseButton {
showExternalURLSheet = false
}
}
}
}
}
.sheet(isPresented: $showLocationPrivacySheet) {
NavigationView {
PrivacyPickerView(
title: L10n.Settings.ConnectionSection.LocationSendType.title,
options: ServerLocationPrivacy.allCases,
selection: Binding(
get: { viewModel.locationPrivacy },
set: { viewModel.updateLocationPrivacy($0) }
),
isDisabled: viewModel.versionRequiresLocationGPSOptional,
footerMessage: viewModel.versionRequiresLocationGPSOptional
? Version.updateLocationGPSOptional.coreRequiredString
: nil
)
.navigationViewStyle(.stack)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
CloseButton {
showLocationPrivacySheet = false
}
}
}
}
}
.sheet(isPresented: $showSensorPrivacySheet) {
NavigationView {
PrivacyPickerView(
title: L10n.Settings.ConnectionSection.SensorSendType.title,
options: ServerSensorPrivacy.allCases,
selection: Binding(
get: { viewModel.sensorPrivacy },
set: { viewModel.updateSensorPrivacy($0) }
),
isDisabled: false,
footerMessage: nil
)
.navigationViewStyle(.stack)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
CloseButton {
showSensorPrivacySheet = false
}
}
}
}
}
.sheet(isPresented: $showSecurityLevelPicker) {
LocalAccessPermissionViewInNavigationView(
initialSelection: viewModel.securityLevel,
action: { level in
viewModel.updateSecurityLevel(level)
showSecurityLevelPicker = false
}
)
}
.onDisappear {
onDismiss?()
}
.alert(
L10n.Settings.ConnectionSection.DeleteServer.title,
isPresented: $showDeleteError,
presenting: deleteError
) { _ in
Button(L10n.okLabel, role: .cancel) {}
} message: { error in
Text(error.localizedDescription)
}
}
// MARK: - Status Section
private var statusSection: some View {
Section(header: Text(L10n.Settings.StatusSection.header)) {
LabelRow(
title: L10n.Settings.ConnectionSection.connectingVia,
value: viewModel.connectionPath
)
LabelRow(
title: L10n.Settings.StatusSection.VersionRow.title,
value: viewModel.version
)
WebSocketStatusView(state: viewModel.websocketState)
LabelRow(
title: L10n.SettingsDetails.Notifications.LocalPush.title,
value: viewModel.localPushStatus
)
LabelRow(
title: L10n.Settings.ConnectionSection.loggedInAs,
value: viewModel.loggedInUser
)
}
}
// MARK: - Details Section
private var detailsSection: some View {
Section(header: Text(L10n.Settings.ConnectionSection.details)) {
TextFieldRow(
title: L10n.Settings.StatusSection.LocationNameRow.title,
placeholder: viewModel.server.info.remoteName,
text: Binding(
get: { viewModel.locationName },
set: { viewModel.updateLocationName($0.isEmpty ? nil : $0) }
)
)
TextFieldRow(
title: L10n.SettingsDetails.General.DeviceName.title,
placeholder: Current.device.deviceName(),
text: Binding(
get: { viewModel.deviceName },
set: { viewModel.updateDeviceName($0.isEmpty ? nil : $0) }
)
)
if #available(iOS 26.0, *) {
NavigationLink {
ConnectionURLView(
server: viewModel.server,
urlType: .internal
)
} label: {
NavigationRow(
title: L10n.Settings.ConnectionSection.InternalBaseUrl.title,
value: viewModel.internalURL
)
}
NavigationLink {
ConnectionURLView(
server: viewModel.server,
urlType: .external
)
} label: {
NavigationRow(
title: L10n.Settings.ConnectionSection.ExternalBaseUrl.title,
value: viewModel.externalURL
)
}
} else {
Button {
showInternalURLSheet = true
} label: {
NavigationRow(
title: L10n.Settings.ConnectionSection.InternalBaseUrl.title,
value: viewModel.internalURL
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Button {
showExternalURLSheet = true
} label: {
NavigationRow(
title: L10n.Settings.ConnectionSection.ExternalBaseUrl.title,
value: viewModel.externalURL
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
Button {
showSecurityLevelPicker = true
} label: {
NavigationRow(
title: L10n.Settings.ConnectionSection.ConnectionAccessSecurityLevel.title,
value: viewModel.securityLevel.description
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Button {
viewModel.updateAppDatabase()
} label: {
Label(L10n.Settings.ConnectionSection.refreshServer, systemSymbol: .arrowClockwise)
}
}
}
// MARK: - Privacy Section
private var privacySection: some View {
Section(header: Text(L10n.SettingsDetails.Privacy.title)) {
if #available(iOS 26.0, *) {
NavigationLink {
PrivacyPickerView(
title: L10n.Settings.ConnectionSection.LocationSendType.title,
options: ServerLocationPrivacy.allCases,
selection: Binding(
get: { viewModel.locationPrivacy },
set: { viewModel.updateLocationPrivacy($0) }
),
isDisabled: viewModel.versionRequiresLocationGPSOptional,
footerMessage: viewModel.versionRequiresLocationGPSOptional
? Version.updateLocationGPSOptional.coreRequiredString
: nil
)
} label: {
NavigationRow(
title: L10n.Settings.ConnectionSection.LocationSendType.title,
value: viewModel.locationPrivacy.localizedDescription,
valueColor: .secondary
)
}
NavigationLink {
PrivacyPickerView(
title: L10n.Settings.ConnectionSection.SensorSendType.title,
options: ServerSensorPrivacy.allCases,
selection: Binding(
get: { viewModel.sensorPrivacy },
set: { viewModel.updateSensorPrivacy($0) }
),
isDisabled: false,
footerMessage: nil
)
} label: {
NavigationRow(
title: L10n.Settings.ConnectionSection.SensorSendType.title,
value: viewModel.sensorPrivacy.localizedDescription,
valueColor: .secondary
)
}
} else {
Button {
showLocationPrivacySheet = true
} label: {
NavigationRow(
title: L10n.Settings.ConnectionSection.LocationSendType.title,
value: viewModel.locationPrivacy.localizedDescription,
valueColor: .secondary
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Button {
showSensorPrivacySheet = true
} label: {
NavigationRow(
title: L10n.Settings.ConnectionSection.SensorSendType.title,
value: viewModel.sensorPrivacy.localizedDescription,
valueColor: .secondary
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}
// MARK: - Activate Section
private var activateSection: some View {
Button {
viewModel.activateServer()
} label: {
Text(L10n.Settings.ConnectionSection.activateServer)
}
}
// MARK: - Delete Section
private var deleteSection: some View {
Section {
Button(role: .destructive) {
isDeleteConfirmationPresented = true
} label: {
if viewModel.isDeleting {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
Text(L10n.Settings.ConnectionSection.DeleteServer.progress)
}
} else {
Text(L10n.Settings.ConnectionSection.DeleteServer.title)
}
}
.disabled(viewModel.isDeleting)
.confirmationDialog(
L10n.Settings.ConnectionSection.DeleteServer.title,
isPresented: $isDeleteConfirmationPresented,
titleVisibility: .visible
) {
Button(L10n.Settings.ConnectionSection.DeleteServer.title, role: .destructive) {
Task {
do {
try await viewModel.deleteServer()
dismiss()
} catch {
Current.Log.error("Failed to delete server: \(error)")
deleteError = error
showDeleteError = true
}
}
}
Button(L10n.cancelLabel, role: .cancel) {}
} message: {
Text(L10n.Settings.ConnectionSection.DeleteServer.message)
}
}
}
}
// MARK: - Supporting Views
private struct NavigationRow: View {
let title: String
let value: String
let valueColor: Color
init(title: String, value: String, valueColor: Color = .haPrimary) {
self.title = title
self.value = value
self.valueColor = valueColor
}
var body: some View {
HStack {
Text(title)
Spacer()
Text(value)
.foregroundColor(valueColor)
.lineLimit(1)
}
}
}
private struct LabelRow: View {
let title: String
let value: String
var body: some View {
HStack {
Text(title)
Spacer()
Text(value)
.foregroundColor(.secondary)
}
}
}
private struct TextFieldRow: View {
let title: String
let placeholder: String
@Binding var text: String
var body: some View {
HStack {
Text(title)
Spacer()
TextField(placeholder, text: $text)
.multilineTextAlignment(.trailing)
.foregroundColor(.secondary)
}
}
}
private struct WebSocketStatusView: View {
let state: HAConnectionState?
@State private var showAlert = false
var body: some View {
Button {
showAlert = true
} label: {
HStack {
Text(L10n.Settings.ConnectionSection.Websocket.title)
.foregroundColor(.primary)
Spacer()
Text(statusMessage)
.foregroundColor(.secondary)
if case .disconnected = state {
Image(systemSymbol: .infoCircle)
.foregroundColor(.accentColor)
}
}
}
.alert(L10n.Settings.ConnectionSection.Websocket.title, isPresented: $showAlert) {
Button(L10n.copyLabel) {
UIPasteboard.general.string = detailedMessage
}
Button(L10n.cancelLabel, role: .cancel) {}
} message: {
Text(detailedMessage)
}
}
private var statusMessage: String {
guard let state else { return "" }
switch state {
case .connecting:
return L10n.Settings.ConnectionSection.Websocket.Status.connecting
case .authenticating:
return L10n.Settings.ConnectionSection.Websocket.Status.authenticating
case .disconnected:
return L10n.Settings.ConnectionSection.Websocket.Status.Disconnected.title
case .ready:
return L10n.Settings.ConnectionSection.Websocket.Status.connected
}
}
private var detailedMessage: String {
guard let state else { return "" }
switch state {
case let .disconnected(reason):
switch reason {
case let .waitingToReconnect(lastError: error, atLatest: atLatest, retryCount: count):
var components = [String]()
if let error {
components.append(L10n.Settings.ConnectionSection.Websocket.Status.Disconnected.error(
error.localizedDescription
))
}
components.append(L10n.Settings.ConnectionSection.Websocket.Status.Disconnected.retryCount(count))
components.append(L10n.Settings.ConnectionSection.Websocket.Status.Disconnected.nextRetry(
DateFormatter.localizedString(from: atLatest, dateStyle: .none, timeStyle: .medium)
))
return components.joined(separator: "\n\n")
case .disconnected:
return L10n.Settings.ConnectionSection.Websocket.Status.Disconnected.title
case .rejected:
return L10n.Settings.ConnectionSection.Websocket.Status.Rejected.title
}
default:
return statusMessage
}
}
}
private struct PrivacyPickerView<T: CaseIterable & Hashable>: View where T: RawRepresentable, T.RawValue == String {
let title: String
let options: [T]
@Binding var selection: T
let isDisabled: Bool
let footerMessage: String?
@Environment(\.dismiss) private var dismiss
var body: some View {
List {
Section {
ForEach(options, id: \.self) { option in
Button {
if !isDisabled {
selection = option
dismiss()
}
} label: {
HStack {
Text(localizedDescription(for: option))
.foregroundColor(isDisabled ? .secondary : .primary)
Spacer()
if selection == option {
Image(systemSymbol: .checkmark)
.foregroundColor(.accentColor)
}
}
}
.disabled(isDisabled)
}
} footer: {
if let footerMessage {
Text(footerMessage)
}
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
}
private func localizedDescription(for option: T) -> String {
if let locationPrivacy = option as? ServerLocationPrivacy {
return locationPrivacy.localizedDescription
} else if let sensorPrivacy = option as? ServerSensorPrivacy {
return sensorPrivacy.localizedDescription
}
return String(describing: option)
}
}