mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-24 20:17:30 -05:00
<!-- 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="2542" height="1494" alt="CleanShot 2026-06-23 at 15 37 01@2x" src="https://github.com/user-attachments/assets/02570900-d21f-4f56-9ad5-396e19f20c4f" /> ## 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. --> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
420 lines
14 KiB
Swift
420 lines
14 KiB
Swift
import SFSafeSymbols
|
|
import Shared
|
|
import SwiftUI
|
|
|
|
struct WebViewEmptyStateView: View {
|
|
@State private var selectedReauthURLType: ConnectionInfo.URLType
|
|
@State private var showURLPicker = false
|
|
@State private var isPerformingPrimaryAction = false
|
|
@State private var errorMessage: String?
|
|
|
|
private let headerAccessorySize = CGSize(width: 44, height: 44)
|
|
|
|
let style: WebViewEmptyStateStyle
|
|
let server: Server
|
|
let showsErrorDetailsButton: Bool
|
|
let availableReauthURLTypes: [ConnectionInfo.URLType]
|
|
let retryAction: (() -> Void)?
|
|
let settingsAction: (() -> Void)?
|
|
let errorDetailsAction: (() -> Void)?
|
|
let reauthAction: ((ConnectionInfo.URLType) -> Void)?
|
|
let recoveredServerReauthAction: ((ConnectionInfo.URLType, @escaping (Swift.Result<Void, Error>) -> Void) -> Void)?
|
|
let serverSelectionAction: ((Server) -> Void)?
|
|
let dismissAction: (() -> Void)?
|
|
|
|
init(
|
|
style: WebViewEmptyStateStyle,
|
|
server: Server,
|
|
showsErrorDetailsButton: Bool = false,
|
|
availableReauthURLTypes: [ConnectionInfo.URLType] = [],
|
|
retryAction: (() -> Void)? = nil,
|
|
settingsAction: (() -> Void)? = nil,
|
|
errorDetailsAction: (() -> Void)? = nil,
|
|
reauthAction: ((ConnectionInfo.URLType) -> Void)? = nil,
|
|
recoveredServerReauthAction: (
|
|
(ConnectionInfo.URLType, @escaping (Swift.Result<Void, Error>) -> Void) -> Void
|
|
)? =
|
|
nil,
|
|
serverSelectionAction: ((Server) -> Void)? = nil,
|
|
dismissAction: (() -> Void)? = nil
|
|
) {
|
|
self.style = style
|
|
self.server = server
|
|
self.showsErrorDetailsButton = showsErrorDetailsButton
|
|
self.availableReauthURLTypes = availableReauthURLTypes
|
|
self._selectedReauthURLType = State(initialValue: availableReauthURLTypes.first ?? .external)
|
|
self.retryAction = retryAction
|
|
self.settingsAction = settingsAction
|
|
self.errorDetailsAction = errorDetailsAction
|
|
self.reauthAction = reauthAction
|
|
self.recoveredServerReauthAction = recoveredServerReauthAction
|
|
self.serverSelectionAction = serverSelectionAction
|
|
self.dismissAction = dismissAction
|
|
}
|
|
|
|
var body: some View {
|
|
content
|
|
.safeAreaInset(edge: .top, content: {
|
|
header
|
|
})
|
|
.safeAreaInset(edge: .bottom, content: {
|
|
actionButtons
|
|
})
|
|
.alert(L10n.errorLabel, isPresented: .init(
|
|
get: { errorMessage != nil },
|
|
set: { newValue in
|
|
if !newValue {
|
|
errorMessage = nil
|
|
}
|
|
}
|
|
)) {
|
|
Button(L10n.okLabel, role: .cancel) {
|
|
errorMessage = nil
|
|
}
|
|
} message: {
|
|
Text(errorMessage ?? "")
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack {
|
|
headerAccessory(resolvedLeadingHeaderAccessory)
|
|
|
|
Spacer()
|
|
serverSelection
|
|
Spacer()
|
|
|
|
headerAccessory(style.trailingHeaderAccessory)
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
private var content: some View {
|
|
VStack(spacing: DesignSystem.Spaces.three) {
|
|
iconView
|
|
VStack(spacing: DesignSystem.Spaces.one) {
|
|
Text(style.title)
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
bodyText
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, DesignSystem.Spaces.three)
|
|
.padding(.top, DesignSystem.Spaces.five)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Color(uiColor: .systemBackground))
|
|
}
|
|
|
|
private var actionButtons: some View {
|
|
VStack(spacing: DesignSystem.Spaces.one) {
|
|
primaryButton
|
|
.buttonStyle(.primaryButton)
|
|
reauthURLHint
|
|
if canShowErrorDetailsButton {
|
|
errorDetailsButton
|
|
.buttonStyle(.secondaryButton)
|
|
}
|
|
if style.showsSecondarySettingsButton, !canShowErrorDetailsButton {
|
|
secondaryButton
|
|
.buttonStyle(.secondaryButton)
|
|
}
|
|
}
|
|
.frame(maxWidth: Sizes.maxWidthForLargerScreens)
|
|
.padding(.horizontal, DesignSystem.Spaces.two)
|
|
.padding(.top)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var serverSelection: some View {
|
|
if style.showsServerPicker, Current.servers.all.count > 1 {
|
|
if Current.isCatalyst {
|
|
macServerSelection
|
|
} else {
|
|
ServerPickerView(server: server, onSelect: serverSelectionAction)
|
|
// Using .secondarySystemBackground to visually distinguish the server selection view
|
|
.background(Color(uiColor: .secondarySystemBackground))
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|
|
|
|
private var macServerSelection: some View {
|
|
Menu {
|
|
ForEach(Current.servers.all, id: \.identifier) { availableServer in
|
|
Button {
|
|
selectServer(availableServer)
|
|
} label: {
|
|
Label(
|
|
availableServer.info.name,
|
|
systemSymbol: availableServer.identifier == server.identifier ? .checkmark : .serverRack
|
|
)
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: DesignSystem.Spaces.one) {
|
|
Image(systemSymbol: .serverRack)
|
|
.foregroundStyle(Color.haPrimary)
|
|
|
|
Text(server.info.name)
|
|
.font(.callout)
|
|
.lineLimit(1)
|
|
|
|
Image(systemSymbol: .chevronUpChevronDown)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.horizontal, DesignSystem.Spaces.two)
|
|
.padding(.vertical, DesignSystem.Spaces.one)
|
|
.background(Color(uiColor: .secondarySystemBackground))
|
|
.clipShape(.capsule)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func headerAccessory(_ accessory: WebViewEmptyStateStyle.HeaderAccessory) -> some View {
|
|
switch accessory {
|
|
case .none:
|
|
Color.clear
|
|
.frame(width: headerAccessorySize.width, height: headerAccessorySize.height)
|
|
case .settings:
|
|
ModalReusableButton(
|
|
icon: .sfSymbol(.gearshape),
|
|
action: {
|
|
settingsAction?()
|
|
}
|
|
)
|
|
.accessibilityLabel(L10n.WebView.EmptyState.openSettingsButton)
|
|
case .close:
|
|
ModalCloseButton {
|
|
dismissAction?()
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var iconView: some View {
|
|
switch style {
|
|
case .disconnected, .unauthenticated:
|
|
Image(.logo)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: 80, height: 80)
|
|
case .recoveredServerNeedingReauthentication:
|
|
Image(systemSymbol: .key)
|
|
.font(.system(size: 56))
|
|
.foregroundStyle(Color.haPrimary)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var bodyText: some View {
|
|
switch style {
|
|
case .disconnected, .unauthenticated:
|
|
Text(style.body)
|
|
.font(.callout)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, DesignSystem.Spaces.two)
|
|
case .recoveredServerNeedingReauthentication:
|
|
Text(L10n.Onboarding.ServerImport.Reauthenticate.message(server.info.name))
|
|
.font(.callout)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, DesignSystem.Spaces.two)
|
|
}
|
|
}
|
|
|
|
private var primaryButton: some View {
|
|
Button(action: {
|
|
switch style {
|
|
case .disconnected:
|
|
retryAction?()
|
|
case .unauthenticated:
|
|
reauthAction?(selectedReauthURLType)
|
|
case .recoveredServerNeedingReauthentication:
|
|
beginRecoveredServerReauthentication()
|
|
}
|
|
}) {
|
|
if style == .recoveredServerNeedingReauthentication, isPerformingPrimaryAction {
|
|
ProgressView()
|
|
.tint(.white)
|
|
.frame(maxWidth: .infinity)
|
|
} else {
|
|
Text(style.primaryButtonTitle)
|
|
}
|
|
}
|
|
.disabled(style == .recoveredServerNeedingReauthentication && isPerformingPrimaryAction)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var reauthURLHint: some View {
|
|
if style == .unauthenticated || style == .recoveredServerNeedingReauthentication,
|
|
availableReauthURLTypes.count > 1 {
|
|
Button {
|
|
showURLPicker = true
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text(selectedReauthURLType.description)
|
|
Image(systemSymbol: .chevronUpChevronDown)
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.confirmationDialog(
|
|
style.urlPickerTitle,
|
|
isPresented: $showURLPicker,
|
|
titleVisibility: .visible
|
|
) {
|
|
ForEach(availableReauthURLTypes, id: \.self) { urlType in
|
|
Button(urlType.description) {
|
|
selectedReauthURLType = urlType
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var secondaryButton: some View {
|
|
Button(action: {
|
|
switch style {
|
|
case .disconnected, .unauthenticated, .recoveredServerNeedingReauthentication:
|
|
settingsAction?()
|
|
}
|
|
}) {
|
|
Text(style.secondaryButtonTitle)
|
|
}
|
|
}
|
|
|
|
private var canShowErrorDetailsButton: Bool {
|
|
style == .disconnected && showsErrorDetailsButton && errorDetailsAction != nil
|
|
}
|
|
|
|
private func selectServer(_ server: Server) {
|
|
if let serverSelectionAction {
|
|
serverSelectionAction(server)
|
|
} else {
|
|
Current.sceneManager.appCoordinator.done { coordinator in
|
|
coordinator.open(server: server)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var resolvedLeadingHeaderAccessory: WebViewEmptyStateStyle.HeaderAccessory {
|
|
if style.showsSecondarySettingsButton, canShowErrorDetailsButton {
|
|
.settings
|
|
} else {
|
|
style.leadingHeaderAccessory
|
|
}
|
|
}
|
|
|
|
private var errorDetailsButton: some View {
|
|
Button(action: {
|
|
errorDetailsAction?()
|
|
}) {
|
|
Text(L10n.ConnectionError.MoreDetailsSection.title)
|
|
}
|
|
}
|
|
|
|
private func beginRecoveredServerReauthentication() {
|
|
guard !isPerformingPrimaryAction else { return }
|
|
guard let recoveredServerReauthAction else { return }
|
|
isPerformingPrimaryAction = true
|
|
errorMessage = nil
|
|
|
|
recoveredServerReauthAction(selectedReauthURLType) { result in
|
|
DispatchQueue.main.async {
|
|
switch result {
|
|
case .success:
|
|
break
|
|
case let .failure(error):
|
|
isPerformingPrimaryAction = false
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview("Disconnected") {
|
|
WebViewEmptyStatePreview.view(style: .disconnected)
|
|
}
|
|
|
|
#Preview("Disconnected With Error Details") {
|
|
WebViewEmptyStatePreview.view(
|
|
style: .disconnected,
|
|
showsErrorDetailsButton: true,
|
|
errorDetailsAction: {}
|
|
)
|
|
}
|
|
|
|
#Preview("Unauthenticated") {
|
|
WebViewEmptyStatePreview.view(
|
|
style: .unauthenticated,
|
|
availableReauthURLTypes: [.external],
|
|
reauthAction: { _ in }
|
|
)
|
|
}
|
|
|
|
#Preview("Unauthenticated Multiple URLs") {
|
|
WebViewEmptyStatePreview.view(
|
|
style: .unauthenticated,
|
|
availableReauthURLTypes: [.remoteUI, .external, .internal],
|
|
reauthAction: { _ in }
|
|
)
|
|
}
|
|
|
|
#Preview("Recovered Server Reauthentication") {
|
|
WebViewEmptyStatePreview.view(
|
|
style: .recoveredServerNeedingReauthentication,
|
|
availableReauthURLTypes: [.remoteUI, .external],
|
|
recoveredServerReauthAction: { _, completion in
|
|
completion(.success(()))
|
|
}
|
|
)
|
|
}
|
|
|
|
#Preview("Recovered Server Reauthentication Dark") {
|
|
WebViewEmptyStatePreview.view(
|
|
style: .recoveredServerNeedingReauthentication,
|
|
availableReauthURLTypes: [.remoteUI, .external],
|
|
recoveredServerReauthAction: { _, completion in
|
|
completion(.failure(NSError(
|
|
domain: "Preview",
|
|
code: 1,
|
|
userInfo: [NSLocalizedDescriptionKey: "Reauthentication failed."]
|
|
)))
|
|
}
|
|
)
|
|
.preferredColorScheme(.dark)
|
|
}
|
|
|
|
private enum WebViewEmptyStatePreview {
|
|
static func view(
|
|
style: WebViewEmptyStateStyle,
|
|
showsErrorDetailsButton: Bool = false,
|
|
availableReauthURLTypes: [ConnectionInfo.URLType] = [],
|
|
errorDetailsAction: (() -> Void)? = nil,
|
|
reauthAction: ((ConnectionInfo.URLType) -> Void)? = nil,
|
|
recoveredServerReauthAction: ((
|
|
ConnectionInfo.URLType,
|
|
@escaping (Swift.Result<Void, Error>) -> Void
|
|
) -> Void)? = nil
|
|
) -> some View {
|
|
WebViewEmptyStateView(
|
|
style: style,
|
|
server: ServerFixture.standard,
|
|
showsErrorDetailsButton: showsErrorDetailsButton,
|
|
availableReauthURLTypes: availableReauthURLTypes,
|
|
retryAction: {},
|
|
settingsAction: {},
|
|
errorDetailsAction: errorDetailsAction,
|
|
reauthAction: reauthAction,
|
|
recoveredServerReauthAction: recoveredServerReauthAction,
|
|
serverSelectionAction: { _ in },
|
|
dismissAction: {}
|
|
)
|
|
}
|
|
}
|