282 lines
9.8 KiB
Swift
282 lines
9.8 KiB
Swift
import CoreBluetooth
|
|
import Improv_iOS
|
|
import NetworkExtension
|
|
import Shared
|
|
import SwiftUI
|
|
|
|
struct ImprovDiscoverView<Manager>: View where Manager: ImprovManagerProtocol {
|
|
enum ViewState: Equatable {
|
|
case empty
|
|
case list
|
|
case loading(_ message: String)
|
|
case success
|
|
case failure(_ message: String)
|
|
}
|
|
|
|
@StateObject private var improvManager: Manager
|
|
private let deviceName: String?
|
|
|
|
@State private var state: ViewState = .empty
|
|
|
|
@State private var ssid = ""
|
|
@State private var password = ""
|
|
@State private var showWifiAlert = false
|
|
|
|
/// Device which is currently selected for connection and wifi operations
|
|
@State private var selectedPeripheral: CBPeripheral?
|
|
@State private var readyToSendWifiCredentials = false
|
|
@State private var bottomSheetState: AppleLikeBottomSheetViewState?
|
|
|
|
/// Redirect user to integrations page based on urlPath received from Improv
|
|
private let redirectRequest: (_ urlPath: String) -> Void
|
|
|
|
// swiftlint: disable force_cast
|
|
init(
|
|
improvManager: any ImprovManagerProtocol,
|
|
deviceName: String?,
|
|
redirectRequest: @escaping (_ urlPath: String) -> Void
|
|
) {
|
|
self._improvManager = .init(wrappedValue: improvManager as! Manager)
|
|
self.deviceName = deviceName
|
|
self.redirectRequest = redirectRequest
|
|
}
|
|
|
|
var body: some View {
|
|
AppleLikeBottomSheet(
|
|
content: {
|
|
content
|
|
},
|
|
showCloseButton: true,
|
|
state: $bottomSheetState
|
|
)
|
|
.animation(.default, value: state)
|
|
.onAppear {
|
|
if improvManager.bluetoothState == .poweredOn {
|
|
improvManager.scan()
|
|
}
|
|
}
|
|
.onDisappear {
|
|
improvManager.stopScan()
|
|
}
|
|
.onChange(of: improvManager.bluetoothState) { newValue in
|
|
if newValue == .poweredOn {
|
|
improvManager.scan()
|
|
}
|
|
}
|
|
.onChange(of: state) { _ in
|
|
if state == .success {
|
|
notifyFrontend()
|
|
}
|
|
}
|
|
.onChange(of: improvManager.deviceState) { newValue in
|
|
switch newValue {
|
|
case .authorizationRequired:
|
|
state = .loading(CoreStrings.componentImprovBleConfigProgressAuthorize)
|
|
case .authorized:
|
|
guard readyToSendWifiCredentials else { return }
|
|
state = .loading(L10n.Improv.ConnectionState.authorized)
|
|
// Sending wifi credentials to device
|
|
authenticate()
|
|
case .provisioned:
|
|
state = .success
|
|
case .provisioning:
|
|
state = .loading(L10n.Improv.ConnectionState.provisioning)
|
|
case .none:
|
|
break
|
|
}
|
|
}
|
|
.onChange(of: improvManager.errorState) { newValue in
|
|
switch newValue {
|
|
case .noError:
|
|
break
|
|
case .invalidRPCPacket:
|
|
state = .failure(L10n.Improv.ErrorState.invalidRpcPacket)
|
|
case .unknownCommand:
|
|
state = .failure(L10n.Improv.ErrorState.unknownCommand)
|
|
case .unableToConnect:
|
|
state = .failure(L10n.Improv.ErrorState.unableToConnect)
|
|
readyToSendWifiCredentials = false
|
|
case .notAuthorized:
|
|
state = .failure(L10n.Improv.ErrorState.notAuthorized)
|
|
case .unknown:
|
|
state = .failure(L10n.Improv.ErrorState.unknown)
|
|
case .none:
|
|
break
|
|
}
|
|
}
|
|
.onChange(of: improvManager.connectedDevice) { newValue in
|
|
if newValue == nil || selectedPeripheral == nil {
|
|
state = .list
|
|
} else if newValue?.identifier == selectedPeripheral?.identifier {
|
|
state = .loading(L10n.Improv.State.connected)
|
|
}
|
|
}
|
|
.onChange(of: improvManager.foundDevices) { newValue in
|
|
guard let deviceName, selectedPeripheral == nil else { return }
|
|
if let device = newValue.first(where: { $0.value.name == deviceName })?.value {
|
|
selectPeripheral(device)
|
|
} else if state == .empty {
|
|
state = .list
|
|
}
|
|
}
|
|
.alert(L10n.Improv.Wifi.Alert.title, isPresented: $showWifiAlert) {
|
|
TextField(L10n.Improv.Wifi.Alert.ssidPlaceholder, text: $ssid)
|
|
.textInputAutocapitalization(.never)
|
|
SecureField(L10n.Improv.Wifi.Alert.passwordPlaceholder, text: $password)
|
|
Button(L10n.Improv.Wifi.Alert.connectButton, action: connectToDevice)
|
|
Button(L10n.Improv.Wifi.Alert.cancelButton, role: .cancel) {
|
|
cancelWifiInput()
|
|
}
|
|
} message: {
|
|
Text(L10n.Improv.Wifi.Alert.description)
|
|
}
|
|
}
|
|
|
|
private var content: some View {
|
|
VStack {
|
|
switch state {
|
|
case .empty:
|
|
// Nothing visible while auto connecting
|
|
loadingView("")
|
|
case .list:
|
|
devicesList
|
|
case let .loading(message):
|
|
loadingView(message)
|
|
case .success:
|
|
successView
|
|
case let .failure(message):
|
|
ImprovFailureView(message: message) {
|
|
if let selectedPeripheral {
|
|
improvManager.disconnectFromDevice(selectedPeripheral)
|
|
state = .list
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func notifyFrontend() {
|
|
Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise).done { controller in
|
|
controller.webViewExternalMessageHandler.sendExternalBus(message: .init(
|
|
command: WebViewExternalBusOutgoingMessage.improvDiscoveredDeviceSetupDone.rawValue
|
|
))
|
|
}
|
|
}
|
|
|
|
private func selectPeripheral(_ peripheral: CBPeripheral) {
|
|
selectedPeripheral = peripheral
|
|
|
|
// This only works if location permission is permitted
|
|
NEHotspotNetwork.fetchCurrent { hotspotNetwork in
|
|
if let ssid = hotspotNetwork?.ssid, self.ssid.isEmpty {
|
|
self.ssid = ssid
|
|
}
|
|
}
|
|
showWifiAlert = true
|
|
state = .loading(L10n.Improv.State.connecting)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var devicesList: some View {
|
|
List {
|
|
Section(content: {
|
|
if improvManager.foundDevices.isEmpty {
|
|
VStack {}
|
|
}
|
|
ForEach(improvManager.foundDevices.keys.sorted(), id: \.self) { peripheralKey in
|
|
if let peripheral = improvManager.foundDevices[peripheralKey] {
|
|
Button {
|
|
selectPeripheral(peripheral)
|
|
} label: {
|
|
Text(peripheral.name ?? peripheral.identifier.uuidString)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.foregroundStyle(Color(uiColor: .label))
|
|
}
|
|
}
|
|
}
|
|
}, header: {
|
|
HStack(spacing: Spaces.two) {
|
|
Text(L10n.Improv.List.title)
|
|
.textCase(.uppercase)
|
|
.font(.footnote)
|
|
.foregroundStyle(Color(uiColor: .secondaryLabel))
|
|
.multilineTextAlignment(.center)
|
|
ProgressView()
|
|
.progressViewStyle(.circular)
|
|
.listRowBackground(Color.clear)
|
|
Spacer()
|
|
}
|
|
})
|
|
}
|
|
.modify {
|
|
if #available(iOS 16, *) {
|
|
$0.scrollContentBackground(.hidden)
|
|
} else {
|
|
$0
|
|
}
|
|
}
|
|
}
|
|
|
|
private func connectToDevice() {
|
|
guard let selectedPeripheral else {
|
|
Current.Log.error("No peripheral selected")
|
|
return
|
|
}
|
|
readyToSendWifiCredentials = true
|
|
improvManager.connectToDevice(selectedPeripheral)
|
|
}
|
|
|
|
private func authenticate() {
|
|
state = .loading(L10n.Improv.State.connecting)
|
|
if let error = improvManager.sendWifi(ssid: ssid, password: password) {
|
|
Current.Log.error("Failed to send wifi credentials to Improv device, error: \(error)")
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func loadingView(_ message: String) -> some View {
|
|
VStack(spacing: Spaces.three) {
|
|
Spacer()
|
|
ProgressView()
|
|
.progressViewStyle(.circular)
|
|
.scaleEffect(.init(floatLiteral: 1.8))
|
|
Text(message)
|
|
.foregroundStyle(Color(uiColor: .secondaryLabel))
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var successView: some View {
|
|
ImprovSuccessView {
|
|
let improvResultDomain = URL(string: improvManager.lastResult?.first ?? "")?.queryItems?["domain"] ?? ""
|
|
let redirectPath = "/config/integrations/dashboard/add?domain=" + improvResultDomain
|
|
redirectRequest(redirectPath)
|
|
bottomSheetState = .dismiss
|
|
}
|
|
}
|
|
|
|
private func cancelWifiInput() {
|
|
if let selectedPeripheral {
|
|
improvManager.disconnectFromDevice(selectedPeripheral)
|
|
self.selectedPeripheral = nil
|
|
}
|
|
bottomSheetState = .dismiss
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ZStack {
|
|
VStack {}
|
|
.background(.blue)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
ImprovDiscoverView<ImprovManager>(
|
|
improvManager: ImprovManager.shared,
|
|
deviceName: "12345",
|
|
redirectRequest: { _ in }
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|