iOS/Sources/App/Settings/Connection/ConnectionURLView.swift
Bruno Pantaleão Gonçalves 43e12d039c
Reintroduce reorder servers (#3970)
<!-- 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. -->
2025-11-13 10:48:39 +01:00

315 lines
11 KiB
Swift

import CoreLocation
import Foundation
import PromiseKit
import Shared
import SwiftUI
// Migrated to SwiftUI using Copilot agent https://github.com/home-assistant/iOS/pull/3956
struct ConnectionURLView: View {
@Environment(\.dismiss) private var dismiss
let urlType: ConnectionInfo.URLType
@StateObject private var viewModel: ConnectionURLViewModel
init(server: Server, urlType: ConnectionInfo.URLType) {
self.urlType = urlType
_viewModel = StateObject(wrappedValue: ConnectionURLViewModel(server: server, urlType: urlType))
}
var body: some View {
Form {
cloudToggleSection
urlSection
ssidSection
hardwareAddressSection
localPushSection
}
.navigationTitle(urlType.description)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
saveButton
}
}
.alert(L10n.Settings.ConnectionSection.ValidateError.title, isPresented: $viewModel.showError) {
if viewModel.canCommitAnyway {
Button(L10n.Settings.ConnectionSection.ValidateError.useAnyway) {
viewModel.save(onSuccess: {
dismiss()
})
}
}
Button(L10n.Settings.ConnectionSection.ValidateError.editUrl, role: .cancel) {}
} message: {
Text(viewModel.errorMessage)
}
}
// MARK: - Cloud Toggle Section
/// Shows cloud toggle only if the URL type can be overridden by cloud
/// (typically external URLs) and the server has cloud capabilities enabled.
@ViewBuilder
private var cloudToggleSection: some View {
if urlType.isAffectedByCloud, viewModel.server.info.connection.canUseCloud {
Section {
Toggle(L10n.Settings.ConnectionSection.HomeAssistantCloud.title, isOn: $viewModel.useCloud)
}
}
}
// MARK: - URL Section
@ViewBuilder
private var urlSection: some View {
Section("URL") {
urlInputOrCloudMessage
}
}
/// Shows URL text field if cloud is disabled, URL type is not affected by cloud,
/// or server doesn't support cloud. Otherwise, shows informational text that cloud overrides the URL.
@ViewBuilder
private var urlInputOrCloudMessage: some View {
if !viewModel.useCloud || !urlType.isAffectedByCloud || !viewModel.server.info.connection.canUseCloud {
urlTextField
securityWarning
} else {
Text(L10n.Settings.ConnectionSection.cloudOverridesExternal)
.foregroundColor(.secondary)
.font(.footnote)
}
}
private var urlTextField: some View {
TextField(viewModel.placeholder, text: $viewModel.url)
.textContentType(.URL)
.keyboardType(.URL)
.autocapitalization(.none)
.autocorrectionDisabled()
}
/// Security warning displayed for external URLs that don't use HTTPS.
@ViewBuilder
private var securityWarning: some View {
if shouldShowSecurityWarning {
HStack(alignment: .top, spacing: DesignSystem.Spaces.one) {
Image(systemSymbol: .exclamationmarkShieldFill)
.foregroundColor(.orange)
.imageScale(.medium)
VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) {
Text(L10n.SettingsDetails.Http.Warning.title)
.font(DesignSystem.Font.subheadline)
.fontWeight(.semibold)
.foregroundColor(.primary)
Text(L10n.SettingsDetails.Http.Warning.message)
.font(DesignSystem.Font.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, DesignSystem.Spaces.one)
}
}
// MARK: - SSID Section
@ViewBuilder
private var ssidSection: some View {
if urlType.isAffectedBySSID {
locationPermissionSection
ssidListSection
}
}
private var ssidListSection: some View {
Section {
ForEach(viewModel.ssids.indices, id: \.self) { index in
HStack {
TextField(
L10n.Settings.ConnectionSection.InternalUrlSsids.placeholder,
text: $viewModel.ssids[index]
)
.autocapitalization(.none)
.autocorrectionDisabled()
Button(action: { viewModel.removeSSID(at: index) }) {
Image(systemSymbol: .minusCircleFill)
.foregroundColor(.red)
}
}
}
.onDelete { indexSet in
viewModel.removeSSIDs(at: indexSet)
}
Button(action: viewModel.addSSID) {
Text(L10n.Settings.ConnectionSection.InternalUrlSsids.addNewSsid)
}
} header: {
Text(L10n.Settings.ConnectionSection.InternalUrlSsids.header)
} footer: {
Text(L10n.Settings.ConnectionSection.InternalUrlSsids.footer)
}
}
// MARK: - Hardware Address Section
@ViewBuilder
private var hardwareAddressSection: some View {
if urlType.isAffectedByHardwareAddress {
Section {
ForEach(viewModel.hardwareAddresses.indices, id: \.self) { index in
HStack {
TextField("aa:bb:cc:dd:ee:ff", text: $viewModel.hardwareAddresses[index])
.autocapitalization(.none)
.autocorrectionDisabled()
Button(action: { viewModel.removeHardwareAddress(at: index) }) {
Image(systemSymbol: .minusCircleFill)
.foregroundColor(.red)
}
}
}
.onDelete { indexSet in
viewModel.removeHardwareAddresses(at: indexSet)
}
Button(action: viewModel.addHardwareAddress) {
Text(L10n.Settings.ConnectionSection.InternalUrlHardwareAddresses.addNewSsid)
}
} header: {
Text(L10n.Settings.ConnectionSection.InternalUrlHardwareAddresses.header)
} footer: {
Text(L10n.Settings.ConnectionSection.InternalUrlHardwareAddresses.footer)
}
}
}
// MARK: - Local Push Section
@ViewBuilder
private var localPushSection: some View {
if urlType.hasLocalPush {
Section {
Toggle(L10n.SettingsDetails.Notifications.LocalPush.title, isOn: $viewModel.localPush)
Button(action: {
openURLInBrowser(
AppConstants.WebURLs.companionLocalPush,
nil
)
}) {
Text(L10n.SettingsDetails.learnMore)
}
} footer: {
Text(L10n.Settings.ConnectionSection.localPushDescription)
}
}
}
// MARK: - Toolbar
@ViewBuilder
private var saveButton: some View {
if viewModel.isChecking {
ProgressView()
} else {
Button(L10n.saveLabel) {
viewModel.save(onSuccess: {
dismiss()
})
}
.tint(.haPrimary)
.modify { view in
if #available(iOS 26.0, *) {
view.buttonStyle(.glassProminent)
} else {
view
}
}
}
}
// MARK: - Location Permission Section
@ViewBuilder
private var locationPermissionSection: some View {
if shouldShowLocationPermission {
Section {
Button(action: handleLocationPermission) {
Text(L10n.Settings.ConnectionSection.ssidPermissionAndAccuracyMessage)
.foregroundColor(.primary)
.multilineTextAlignment(.leading)
}
}
}
}
/// Determines if the security warning should be shown for non-HTTPS external URLs.
///
/// The warning is displayed when:
/// 1. The URL type is external (remote/internet connections)
/// 2. The URL does not use HTTPS protocol
///
/// This helps encourage users to use encrypted connections for remote access,
/// protecting their credentials and data from potential interception.
private var shouldShowSecurityWarning: Bool {
guard urlType == .external else { return false }
guard let url = URL(string: viewModel.url.trimmingCharacters(in: .whitespaces)) else { return false }
return url.scheme?.lowercased() != "https"
}
/// Determines if the location permission prompt should be shown.
///
/// The prompt is shown when location permissions are insufficient for SSID detection:
/// - Requires both "Always Allow" authorization AND full accuracy
///
/// SSID information requires "Always Allow" because the app needs to detect
/// network changes in the background. Full accuracy is needed on iOS 14+ to
/// access detailed network information including SSID names.
private var shouldShowLocationPermission: Bool {
Current.locationManager.currentPermissionState != .authorizedAlways ||
Current.locationManager.accuracyAuthorization != .fullAccuracy
}
/// Handles location permission requests based on current authorization state.
///
/// - If permissions have never been requested (.notDetermined):
/// Requests "Always Allow" authorization from the system
/// - If permissions were previously requested (any other state):
/// Opens Settings app to the location permissions page for manual adjustment
///
/// This two-step approach is necessary because:
/// 1. iOS only allows requesting permissions once programmatically
/// 2. After the initial request, users must change permissions in Settings
private func handleLocationPermission() {
if Current.locationManager.currentPermissionState == .notDetermined {
Current.locationManager.requestLocationPermission()
} else {
URLOpener.shared.openSettings(destination: .location, completionHandler: nil)
}
}
}
#if DEBUG
struct ConnectionURLView_Previews: PreviewProvider {
static var previews: some View {
Group {
NavigationView {
ConnectionURLView(
server: ServerFixture.standard,
urlType: .internal
)
}
.previewDisplayName("Internal URL")
NavigationView {
ConnectionURLView(
server: ServerFixture.standard,
urlType: .external
)
}
.previewDisplayName("External URL")
}
}
}
#endif