Files
iOS/Sources/App/Onboarding/API/Steps/OnboardingAuthStepDeviceNaming.swift
Bruno Pantaleão Gonçalves 9be944780e Onboarding UX fixes (#3914)
<!-- 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 -->
- Fix http/https section not showing on manual URL entry during
onboarding;
- Fix Safari view controller presentation during onboarding;
- Fix ha button style missing highlighted and pressed state missing;
- Fix registered list of devices not up to date when user names the
device;

## 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-10-28 12:08:33 +01:00

156 lines
6.0 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import HAKit
import PromiseKit
import Shared
import SwiftUI
private struct RegisteredDevice {
var name: String
var id: String
init?(data: HAData) throws {
self.name = try data.decode("name")
self.id = try {
let identifiers: [[String]] = try data.decode("identifiers")
for identifier in identifiers where identifier.count == 2 && identifier.starts(with: ["mobile_app"]) {
return identifier[1]
}
throw HADataError.couldntTransform(key: "identifiers")
}()
}
func matches(name other: String) -> Bool {
name.lowercased() == other.lowercased()
}
}
struct OnboardingAuthStepDeviceNaming: OnboardingAuthPostStep {
init(
api: HomeAssistantAPI,
sender: UIViewController
) {
self.api = api
self.sender = sender
}
var api: HomeAssistantAPI
var sender: UIViewController
static var supportedPoints: Set<OnboardingAuthStepPoint> {
Set([.beforeRegister])
}
var timeout: TimeInterval = 30.0
/// Whether the user has already been prompted for a device name.
static var firstUserDeviceNameInput = true
func perform(point: OnboardingAuthStepPoint) -> Promise<Void> {
let devices = fetchDeviceList()
let timeout: Promise<[RegisteredDevice]> = after(seconds: timeout).then { () -> Promise<[RegisteredDevice]> in
switch api.connection.state {
case let .disconnected(reason: .waitingToReconnect(lastError: .some(error), atLatest: _, retryCount: _)):
throw error
default:
throw OnboardingAuthError(kind: .invalidURL, data: nil)
}
}
// racing the request, not the whole flow, importantly.
// otherwise we'd fail out before the user finished typing.
return race(timeout, devices).then { [self] registeredDevices -> Promise<Void> in
guard !registeredDevices.contains(where: { $0.id == Current.settingsStore.integrationDeviceID }) else {
// if the integration is registered already, we will take over that one, so we don't need to look
return .value(())
}
// this can be removed once the mobile_app notify service stops being device name specific
return promptForDeviceName(
deviceName: Current.device.deviceName(),
registeredDevices: registeredDevices,
sender: sender
)
}
}
private func promptForDeviceName(
deviceName: String,
errorMessage: String? = nil,
registeredDevices: [RegisteredDevice],
sender: UIViewController
) -> Promise<Void> {
guard registeredDevices.contains(where: { $0.matches(name: deviceName) }) ||
OnboardingAuthStepDeviceNaming.firstUserDeviceNameInput else {
// if the device name is not already taken, we can safely use it and don't need to prompt
return .value(())
}
OnboardingAuthStepDeviceNaming.firstUserDeviceNameInput = false
return Promise<Void> { seal in
let view = UIHostingController(rootView: DeviceNameView(errorMessage: errorMessage, saveAction: { name in
guard name.isEmpty == false else {
promptForDeviceName(
deviceName: deviceName,
errorMessage: L10n.Onboarding.DeviceNameCheck.Error.title(deviceName),
registeredDevices: registeredDevices,
sender: sender
).pipe(to: seal.resolve)
return
}
// Fetch updated device list to ensure we have current data
fetchDeviceList().done { updatedDevices in
if updatedDevices.contains(where: { $0.matches(name: name) }) {
// Name conflicts with a registered device
promptForDeviceName(
deviceName: deviceName,
errorMessage: L10n.Onboarding.DeviceNameCheck.Error.title(deviceName),
registeredDevices: updatedDevices,
sender: sender
).pipe(to: seal.resolve)
} else {
// No conflict, proceed with the name
api.server.info.setSetting(value: name, for: .overrideDeviceName)
resetFirstUserDeviceNameInput()
seal.fulfill(())
}
}.catch { _ in
// If we can't fetch updated list, fall back to showing error with original list
promptForDeviceName(
deviceName: deviceName,
errorMessage: L10n.Onboarding.DeviceNameCheck.Error.title(deviceName),
registeredDevices: registeredDevices,
sender: sender
).pipe(to: seal.resolve)
}
}, cancelAction: {
resetFirstUserDeviceNameInput()
seal.reject(PMKError.cancelled)
}))
sender.present(view, animated: true, completion: nil)
}
}
// In case the flow is completed or cancelled, we reset the first user device name input flag.
private func resetFirstUserDeviceNameInput() {
OnboardingAuthStepDeviceNaming.firstUserDeviceNameInput = true
}
private func fetchDeviceList() -> Promise<[RegisteredDevice]> {
firstly { () -> Promise<[HAData]> in
api.connection.send(.init(type: "config/device_registry/list")).promise.compactMap {
if case let .array(value) = $0 {
return value
} else {
throw HomeAssistantAPI.APIError.invalidResponse
}
}
}.compactMapValues { value -> RegisteredDevice? in
try? RegisteredDevice(data: value)
}
}
}