Files
iOS/Sources/App/Settings/Connection/ConnectionSettingsView.swift
Bruno Pantaleão Gonçalves e5130275e1 Improve labs label spacing and rename it (#4462)
<!-- 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. -->
<img width="2030" height="494" alt="CleanShot 2026-03-31 at 10 36 48@2x"
src="https://github.com/user-attachments/assets/539853f4-f8dc-46d6-a167-25af8ca0b399"
/>

## 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-03-31 08:58:25 +00:00

722 lines
26 KiB
Swift

import HAKit
import Shared
import SwiftUI
import UniformTypeIdentifiers
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
@State private var showCertificateImporter = false
@State private var showCertificatePasswordPrompt = false
@State private var certificatePassword = ""
@State private var pendingCertificateURL: URL?
@State private var showRemoveCertificateConfirmation = 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
clientCertificateSection
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
}
)
}
.fileImporter(
isPresented: $showCertificateImporter,
allowedContentTypes: ClientCertificateFile.allowedFormats,
allowsMultipleSelection: false
) { result in
switch result {
case let .success(urls):
if let url = urls.first {
pendingCertificateURL = url
showCertificatePasswordPrompt = true
}
case let .failure(error):
Current.Log.error("Failed to select certificate file: \(error)")
}
}
.alert(
L10n.Settings.ConnectionSection.ClientCertificate.PasswordPrompt.title,
isPresented: $showCertificatePasswordPrompt
) {
SecureField(
L10n.Settings.ConnectionSection.ClientCertificate.PasswordPrompt.placeholder,
text: $certificatePassword
)
Button(L10n.cancelLabel, role: .cancel) {
certificatePassword = ""
pendingCertificateURL = nil
}
Button(L10n.Settings.ConnectionSection.ClientCertificate.PasswordPrompt.importButton) {
if let url = pendingCertificateURL {
Task {
await viewModel.importCertificate(from: url, password: certificatePassword)
certificatePassword = ""
pendingCertificateURL = nil
}
}
}
} message: {
Text(L10n.Settings.ConnectionSection.ClientCertificate.PasswordPrompt.message)
}
.alert(
L10n.Settings.ConnectionSection.ClientCertificate.ImportError.title,
isPresented: Binding(
get: { viewModel.certificateError != nil },
set: { if !$0 { viewModel.certificateError = nil } }
),
presenting: viewModel.certificateError
) { _ in
Button(L10n.okLabel, role: .cancel) {}
} message: { error in
Text(error.localizedDescription)
}
.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)
}
if viewModel.shouldShowSecurityLevelPicker {
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: - Client Certificate Section
private var clientCertificateSection: some View {
Section {
if let certificate = viewModel.clientCertificate {
// Certificate is configured
HStack {
VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) {
Text(certificate.displayName)
.font(.body)
if certificate.isExpired {
Text(L10n.Settings.ConnectionSection.ClientCertificate.expired)
.font(.caption)
.foregroundColor(.red)
} else if let expiresAt = certificate.expiresAt {
Text(L10n.Settings.ConnectionSection.ClientCertificate.expiresAt(expiresAt.formatted(
date: .abbreviated,
time: .omitted
)))
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
Button(role: .destructive) {
showRemoveCertificateConfirmation = true
} label: {
Image(systemSymbol: .trash)
}
.confirmationDialog(
L10n.Settings.ConnectionSection.ClientCertificate.RemoveConfirmation.title,
isPresented: $showRemoveCertificateConfirmation,
titleVisibility: .visible
) {
Button(
L10n.Settings.ConnectionSection.ClientCertificate.RemoveConfirmation.remove,
role: .destructive
) {
viewModel.removeCertificate()
}
Button(L10n.cancelLabel, role: .cancel) {}
} message: {
Text(L10n.Settings.ConnectionSection.ClientCertificate.RemoveConfirmation.message)
}
}
} else {
// No certificate configured
Button {
showCertificateImporter = true
} label: {
if viewModel.isImportingCertificate {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
Text(L10n.Settings.ConnectionSection.ClientCertificate.importing)
}
} else {
Label(L10n.Settings.ConnectionSection.ClientCertificate.import, systemSymbol: .plusCircle)
}
}
.disabled(viewModel.isImportingCertificate)
}
} header: {
HStack {
Text(L10n.Settings.ConnectionSection.ClientCertificate.header)
MTLSLabsLabel()
}
} footer: {
Text(L10n.Settings.ConnectionSection.ClientCertificate.footer)
}
}
// 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)
}
}