From 7510c5d936218b567dc78f6eb71d675e73d2ed42 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 21 Jun 2024 23:24:20 +0200 Subject: [PATCH] Move BluetoothViews to SpeziDevices --- Package.swift | 8 +- Sources/SpeziDevices/DeviceManager.swift | 3 +- .../Devices/BatteryPoweredDevice.swift | 2 +- .../Devices/GenericBluetoothPeripheral.swift | 53 +++ .../SpeziDevices/Devices/GenericDevice.swift | 6 +- .../Health/HealthDevice+HKDevice.swift | 3 +- .../SpeziDevices/Model/ImageReference.swift | 2 +- .../SpeziDevices/Model/PairedDeviceInfo.swift | 54 +-- .../Model/PairingContinuation.swift | 3 +- .../SpeziDevices.docc/SpeziDevices.md | 1 + .../Devices/DeviceDetailsView.swift | 1 + .../SpeziDevicesUI/Devices/DevicesGrid.swift | 1 + .../Resources/Localizable.xcstrings | 417 ++++++++++++++++-- .../Resources/Localizable.xcstrings.license | 5 + .../Scanning/BluetoothUnavailableView.swift | 129 ++++++ .../Scanning/LoadingSectionHeader.swift | 60 +++ .../Scanning/NearbyDeviceRow.swift | 161 +++++++ .../SpeziDevicesUI.docc/SpeziDevicesUI.md | 8 +- .../Testing/MockBluetoothPeripheral.swift | 24 + .../SpeziDevicesUI/Testing/MockDevice.swift | 4 +- .../SpeziDevicesUI/Tips/ConfigureTipKit.swift | 36 ++ .../SpeziDevicesUI/Utils/PaneContent.swift | 4 +- ...acteristicAccessor+OmronRecordAccess.swift | 3 +- .../OmronRecordAccessOperand.swift | 2 +- .../RecordAccessControlPoint+Omron.swift | 2 +- .../RecordAccessOpCode+Omron.swift | 2 +- Sources/SpeziOmron/OmronOptionService.swift | 2 +- 27 files changed, 890 insertions(+), 106 deletions(-) create mode 100644 Sources/SpeziDevices/Devices/GenericBluetoothPeripheral.swift create mode 100644 Sources/SpeziDevicesUI/Resources/Localizable.xcstrings.license create mode 100644 Sources/SpeziDevicesUI/Scanning/BluetoothUnavailableView.swift create mode 100644 Sources/SpeziDevicesUI/Scanning/LoadingSectionHeader.swift create mode 100644 Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift create mode 100644 Sources/SpeziDevicesUI/Testing/MockBluetoothPeripheral.swift create mode 100644 Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift diff --git a/Package.swift b/Package.swift index a517a82..93eaff9 100644 --- a/Package.swift +++ b/Package.swift @@ -45,8 +45,7 @@ let package = Package( .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "SpeziFoundation", package: "SpeziFoundation"), .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), - .product(name: "BluetoothServices", package: "SpeziBluetooth"), - .product(name: "BluetoothViews", package: "SpeziBluetooth") // TODO: just because of the One protocol??? + .product(name: "SpeziBluetoothServices", package: "SpeziBluetooth") ], plugins: [swiftLintPlugin] ), @@ -54,10 +53,9 @@ let package = Package( name: "SpeziDevicesUI", dependencies: [ .target(name: "SpeziDevices"), - .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), - .product(name: "BluetoothViews", package: "SpeziBluetooth"), .product(name: "SpeziViews", package: "SpeziViews"), .product(name: "SpeziValidation", package: "SpeziViews"), + .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), .product(name: "ACarousel", package: "ACarousel") ], resources: [ @@ -70,7 +68,7 @@ let package = Package( dependencies: [ .target(name: "SpeziDevices"), .product(name: "SpeziBluetooth", package: "SpeziBluetooth"), - .product(name: "BluetoothServices", package: "SpeziBluetooth") + .product(name: "SpeziBluetoothServices", package: "SpeziBluetooth") ], plugins: [swiftLintPlugin] ), diff --git a/Sources/SpeziDevices/DeviceManager.swift b/Sources/SpeziDevices/DeviceManager.swift index 130abb8..efabf6d 100644 --- a/Sources/SpeziDevices/DeviceManager.swift +++ b/Sources/SpeziDevices/DeviceManager.swift @@ -6,10 +6,10 @@ // SPDX-License-Identifier: MIT // -import BluetoothServices import OrderedCollections import Spezi import SpeziBluetooth +import SpeziBluetoothServices import SwiftUI // TODO: Start SpeziDevices generalization @@ -46,6 +46,7 @@ public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitiali } @Application(\.logger) @ObservationIgnored private var logger + @Dependency @ObservationIgnored private var tipKit: ConfigureTipKit @Dependency @ObservationIgnored private var bluetooth: Bluetooth? required public init() {} // TODO: configure automatic search without devices paired! diff --git a/Sources/SpeziDevices/Devices/BatteryPoweredDevice.swift b/Sources/SpeziDevices/Devices/BatteryPoweredDevice.swift index d0ded50..977a4a1 100644 --- a/Sources/SpeziDevices/Devices/BatteryPoweredDevice.swift +++ b/Sources/SpeziDevices/Devices/BatteryPoweredDevice.swift @@ -6,8 +6,8 @@ // SPDX-License-Identifier: MIT // -import BluetoothServices import SpeziBluetooth +import SpeziBluetoothServices /// A battery powered Bluetooth device. diff --git a/Sources/SpeziDevices/Devices/GenericBluetoothPeripheral.swift b/Sources/SpeziDevices/Devices/GenericBluetoothPeripheral.swift new file mode 100644 index 0000000..5be7351 --- /dev/null +++ b/Sources/SpeziDevices/Devices/GenericBluetoothPeripheral.swift @@ -0,0 +1,53 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth + + +/// A generic bluetooth peripheral. +public protocol GenericBluetoothPeripheral { + /// The user-visible label. + /// + /// This label is used to communicate information about this device to the user. + var label: String { get } + + /// An optional accessibility label. + /// + /// This label is used as the accessibility label within views when + /// communicate information about this device to the user. + var accessibilityLabel: String { get } + + /// The current peripheral state. + var state: PeripheralState { get } + + /// Mark the device to require user attention. + /// + /// Marks the device to require user attention. The user should navigate to the details + /// view to get more information about the device. + var requiresUserAttention: Bool { get } +} + + +extension GenericBluetoothPeripheral { + /// Default implementation using the devices `label`. + public var accessibilityLabel: String { + label + } + + /// By default the peripheral doesn't require user attention. + public var requiresUserAttention: Bool { + false + } +} + + +extension BluetoothPeripheral: GenericBluetoothPeripheral { + public nonisolated var label: String { + name ?? "Generic Peripheral" + } +} diff --git a/Sources/SpeziDevices/Devices/GenericDevice.swift b/Sources/SpeziDevices/Devices/GenericDevice.swift index a612317..3ead72d 100644 --- a/Sources/SpeziDevices/Devices/GenericDevice.swift +++ b/Sources/SpeziDevices/Devices/GenericDevice.swift @@ -6,15 +6,11 @@ // SPDX-License-Identifier: MIT // -import BluetoothServices -import BluetoothViews import Foundation import SpeziBluetooth +import SpeziBluetoothServices -// TODO: move GenericBluetoothPeripheral to here? -// TODO: => merge BluetoothViews into SpeziDevicesUI? - /// A generic Bluetooth device. /// /// A generic Bluetooth device that provides access to basic device information. diff --git a/Sources/SpeziDevices/Health/HealthDevice+HKDevice.swift b/Sources/SpeziDevices/Health/HealthDevice+HKDevice.swift index 5a08116..bf0b7d8 100644 --- a/Sources/SpeziDevices/Health/HealthDevice+HKDevice.swift +++ b/Sources/SpeziDevices/Health/HealthDevice+HKDevice.swift @@ -10,7 +10,8 @@ import HealthKit extension HealthDevice { - public var hkDevice: HKDevice { // TODO: doesn't necessarily need to be public if we move MeasurementManager! + /// The HealthKit Device description. + public var hkDevice: HKDevice { HKDevice( name: name, manufacturer: deviceInformation.manufacturerName, diff --git a/Sources/SpeziDevices/Model/ImageReference.swift b/Sources/SpeziDevices/Model/ImageReference.swift index 5af98b4..715f577 100644 --- a/Sources/SpeziDevices/Model/ImageReference.swift +++ b/Sources/SpeziDevices/Model/ImageReference.swift @@ -10,7 +10,7 @@ import SwiftUI /// Reference an Image Resource. -public enum ImageReference { // TODO: SpeziViews candidate! +public enum ImageReference { /// Provides the system name for an image. case system(String) /// Reference an image from the asset catalog of a bundle. diff --git a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift index d6b995d..8be3768 100644 --- a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift +++ b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift @@ -10,32 +10,31 @@ import Foundation /// Persistent information stored of a paired device. -public struct PairedDeviceInfo { // TODO: observablen => resolves UI update issue! +public struct PairedDeviceInfo { + // TODO: observablen => resolves UI update issue! + // TODO: update properties (model, lastSeen, battery) with Observation framework and not via explicit calls in the device class + // => make some things have internal setters(?) + // TODO: additionalData: lastSequenceNumber: UInt16?, userDatabaseNumber: UInt32?, consentCode: UIntX + /// The CoreBluetooth device identifier. public let id: UUID /// The device type. /// /// Stores the associated ``PairableDevice/deviceTypeIdentifier-9wsed`` device type used to locate the device implementation. - public let deviceType: String // TODO: verify link + public let deviceType: String /// Visual representation of the device. public let icon: ImageReference? /// A model string of the device. - public let model: String? // TODO: this one as well! + public let model: String? - // TODO: make some things have internal setters? /// The user edit-able name of the device. public var name: String /// The date the device was last seen. - public var lastSeen: Date // TODO: don't set within the device class itself + public var lastSeen: Date /// The last reported battery percentage of the device. - public var lastBatteryPercentage: UInt8? // TODO: update those values based on the Observation framework? - + public var lastBatteryPercentage: UInt8? - public var lastSequenceNumber: UInt16? - public var userDatabaseNumber: UInt32? // TODO: default value? - // TODO: consent code? - // TODO: last transfer time? - // TODO: handle extensibility? + // TODO: how with codability? public var additionalData: [String: Any] /// Create new paired device information. /// - Parameters: @@ -46,8 +45,6 @@ public struct PairedDeviceInfo { // TODO: observablen => resolves UI update issu /// - icon: The device icon. /// - lastSeen: The date the device was last seen. /// - batteryPercentage: The last known battery percentage of the device. - /// - lastSequenceNumber: // TODO: docs - /// - userDatabaseNumber: // TODO: docs public init( id: UUID, deviceType: String, @@ -55,9 +52,7 @@ public struct PairedDeviceInfo { // TODO: observablen => resolves UI update issu model: String?, icon: ImageReference?, lastSeen: Date = .now, - batteryPercentage: UInt8? = nil, - lastSequenceNumber: UInt16? = nil, - userDatabaseNumber: UInt32? = nil + batteryPercentage: UInt8? = nil ) { self.id = id self.deviceType = deviceType @@ -66,8 +61,6 @@ public struct PairedDeviceInfo { // TODO: observablen => resolves UI update issu self.icon = icon self.lastSeen = lastSeen self.lastBatteryPercentage = batteryPercentage - self.lastSequenceNumber = lastSequenceNumber - self.userDatabaseNumber = userDatabaseNumber } } @@ -84,29 +77,6 @@ extension PairedDeviceInfo: Hashable { #if DEBUG extension PairedDeviceInfo { - /* - // TODO: bring back those??? - static var mockBP5250: PairedDeviceInfo { - PairedDeviceInfo( - id: UUID(), - deviceType: BloodPressureCuffDevice.deviceTypeIdentifier, - name: "BP5250", - model: OmronModel.bp5250, - icon: .asset("Omron-BP5250") - ) - } - - static var mockSC150: PairedDeviceInfo { - PairedDeviceInfo( - id: UUID(), - deviceType: WeightScaleDevice.deviceTypeIdentifier, - name: "SC-150", - model: OmronModel.sc150, - icon: .asset("Omron-SC-150") - ) - } - */ - /// Mock Health Device 1 Data. @_spi(TestingSupport) public static var mockHealthDevice1: PairedDeviceInfo { PairedDeviceInfo( diff --git a/Sources/SpeziDevices/Model/PairingContinuation.swift b/Sources/SpeziDevices/Model/PairingContinuation.swift index b5ca2cb..7851d5e 100644 --- a/Sources/SpeziDevices/Model/PairingContinuation.swift +++ b/Sources/SpeziDevices/Model/PairingContinuation.swift @@ -17,6 +17,7 @@ public final class PairingContinuation { private var isInSession = false private var pairingContinuation: CheckedContinuation? + /// Create a new pairing continuation management object. public init() {} func pairingSession(_ action: () async throws -> T) async throws -> T { @@ -30,7 +31,7 @@ public final class PairingContinuation { } defer { - lock.withLock{ + lock.withLock { isInSession = false } } diff --git a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md index 4d80a8b..6ac12af 100644 --- a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md +++ b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md @@ -20,6 +20,7 @@ SPDX-License-Identifier: MIT ### Devices +- ``GenericBluetoothPeripheral`` - ``GenericDevice`` - ``BatteryPoweredDevice`` - ``PairableDevice`` diff --git a/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift index 13c87d8..606b9bc 100644 --- a/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift +++ b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift @@ -64,6 +64,7 @@ public struct DeviceDetailsView: View { .navigationBarTitleDisplayMode(.inline) .confirmationDialog("Do you really want to forget this device?", isPresented: $presentForgetConfirmation, titleVisibility: .visible) { Button("Forget Device", role: .destructive) { + // TODO: message to check for ConfigureTipKit dependency! ForgetDeviceTip.hasRemovedPairedDevice = true deviceManager.forgetDevice(id: deviceInfo.id) dismiss() diff --git a/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift index ef4cc62..9b909b0 100644 --- a/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift +++ b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift @@ -32,6 +32,7 @@ public struct DevicesGrid: View { if devices.isEmpty { ZStack { VStack { + // TODO: message to check for ConfigureTipKit dependency! TipView(ForgetDeviceTip.instance) .padding([.leading, .trailing], 20) Spacer() diff --git a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings index b5eb15b..40b2842 100644 --- a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings +++ b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings @@ -11,32 +11,195 @@ } } }, + "%@, Searching" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@, Searching" + } + } + } + }, "Accessory Paired" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accessory Paired" + } + } + } }, "Add Device" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Device" + } + } + } }, "Battery" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Battery" + } + } + } + }, + "Bluetooth Failure" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Failure" + } + } + } + }, + "Bluetooth is required to make connections to nearby devices. ..." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth is required to make connections to a nearby device. Please allow Bluetooth connections in your Privacy settings." + } + } + } + }, + "Bluetooth is turned off. ..." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth is turned off. Please turn on Bluetooth in Control Center or Settings, in order to connect to a nearby device." + } + } + } + }, + "Bluetooth is unsupported on this device!" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth is unsupported on this device!" + } + } + } }, - "Button" : { - + "Bluetooth Off" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Off" + } + } + } + }, + "Bluetooth Prohibited" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Prohibited" + } + } + } + }, + "Bluetooth Unsupported" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Unsupported" + } + } + } }, "Cancel" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, + "Connected" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connected" + } + } + } + }, + "Connecting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecting" + } + } + } }, "Device Details" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Device Details" + } + } + } }, "Devices" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devices" + } + } + } + }, + "Disconnecting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disconnecting" + } + } + } }, "Discovering" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discovering" + } + } + } }, "Do you really want to forget this device?" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do you really want to forget this device?" + } + } + } }, "Do you want to pair %@ with the %@ app?" : { "localizations" : { @@ -49,46 +212,154 @@ } }, "Done" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + } + } }, "enter device name" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "enter device name" + } + } + } }, "Failed to pair accessory." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to pair accessory." + } + } + } }, "Forget Device" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forget Device" + } + } + } }, "Forget This Device" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forget This Device" + } + } + } }, "Fully Unpair Device" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fully Unpair Device" + } + } + } }, "Hold down the Bluetooth button for 3 seconds to put the device into pairing mode." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hold down the Bluetooth button for 3 seconds to put the device into pairing mode." + } + } + } + }, + "Intervention Required" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervention Required" + } + } + } }, "Make sure to to remove the device from the Bluetooth settings to fully unpair the device." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Make sure to to remove the device from the Bluetooth settings to fully unpair the device." + } + } + } }, "Model" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model" + } + } + } }, "Name" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" + } + } + } }, "No Devices" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Devices" + } + } + } }, "OK" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + } + } }, "Open Settings" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Settings" + } + } + } }, "Page" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Page" + } + } + } }, "Page %lld of %lld" : { "localizations" : { @@ -101,34 +372,94 @@ } }, "Pair" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pair" + } + } + } }, "Pair Accessory" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pair Accessory" + } + } + } }, "Pair New Device" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pair New Device" + } + } + } }, "Paired devices will appear here once set up." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paired devices will appear here once set up." + } + } + } }, "Pairing Failed" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pairing Failed" + } + } + } + }, + "Requires Attention" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requires Attention" + } + } + } }, "Synchronizing ..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronizing ..." + } + } + } }, "The device name cannot be longer than 50 characters." : { - - }, - "The Subtitle" : { - - }, - "The Title" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The device name cannot be longer then 50 characters." + } + } + } }, "This device was last seen at %@" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This device was last seen at %@" + } + } + } }, "This device was last seen on %@ at %@" : { "localizations" : { @@ -139,6 +470,16 @@ } } } + }, + "We have trouble with the Bluetooth communication. Please try again." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We have trouble with the Bluetooth communication. Please try again." + } + } + } } }, "version" : "1.0" diff --git a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings.license b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings.license new file mode 100644 index 0000000..28f53d0 --- /dev/null +++ b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT \ No newline at end of file diff --git a/Sources/SpeziDevicesUI/Scanning/BluetoothUnavailableView.swift b/Sources/SpeziDevicesUI/Scanning/BluetoothUnavailableView.swift new file mode 100644 index 0000000..67bbdb2 --- /dev/null +++ b/Sources/SpeziDevicesUI/Scanning/BluetoothUnavailableView.swift @@ -0,0 +1,129 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth +import SwiftUI + + +public struct BluetoothUnavailableView: View { // TODO: missing docs on views! + private let state: BluetoothState + + private var titleMessage: LocalizedStringResource? { + switch state { + case .poweredOn: + return nil + case .poweredOff: + return .init("Bluetooth Off", bundle: .atURL(from: .module)) + case .unauthorized: + return .init("Bluetooth Prohibited", bundle: .atURL(from: .module)) + case .unsupported: + return .init("Bluetooth Unsupported", bundle: .atURL(from: .module)) + case .unknown: + return .init("Bluetooth Failure", bundle: .atURL(from: .module)) + } + } + + private var subtitleMessage: LocalizedStringResource? { + switch state { + case .poweredOn: + return nil + case .poweredOff: + return .init("Bluetooth is turned off. ...", bundle: .atURL(from: .module)) + case .unauthorized: + return .init("Bluetooth is required to make connections to nearby devices. ...", bundle: .atURL(from: .module)) + case .unknown: + return .init("We have trouble with the Bluetooth communication. Please try again.", bundle: .atURL(from: .module)) + case .unsupported: + return .init("Bluetooth is unsupported on this device!", bundle: .atURL(from: .module)) + } + } + + + public var body: some View { + if titleMessage != nil || subtitleMessage != nil { + ContentUnavailableView { + if let titleMessage { + Label { + Text(titleMessage) + } icon: { + EmptyView() + } + } + } description: { + if let subtitleMessage { + Text(subtitleMessage) + } + } actions: { + switch state { + case .poweredOff, .unauthorized: + #if os(iOS) || os(visionOS) || os(tvOS) + Button(action: { + if let url = URL(string: UIApplication.openSettingsURLString) { // TODO: this is wrong? + UIApplication.shared.open(url) + } + }) { + Text("Open Settings", bundle: .module) + } + #else + EmptyView() + #endif + default: + EmptyView() + } + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: -15, leading: 0, bottom: 0, trailing: 0)) + } else { + EmptyView() + } + } + + + public init(_ state: BluetoothState) { + self.state = state + } +} + + +#if DEBUG +#Preview { + GeometryReader { proxy in + List { + BluetoothUnavailableView(.poweredOff) + .frame(height: proxy.size.height - 100) + } + } +} + +#Preview { + GeometryReader { proxy in + List { + BluetoothUnavailableView(.unauthorized) + .frame(height: proxy.size.height - 100) + } + } +} + +#Preview { + GeometryReader { proxy in + List { + BluetoothUnavailableView(.unsupported) + .frame(height: proxy.size.height - 100) + } + } +} + +#Preview { + GeometryReader { proxy in + List { + BluetoothUnavailableView(.unknown) + .frame(height: proxy.size.height - 100) + } + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Scanning/LoadingSectionHeader.swift b/Sources/SpeziDevicesUI/Scanning/LoadingSectionHeader.swift new file mode 100644 index 0000000..b38ba87 --- /dev/null +++ b/Sources/SpeziDevicesUI/Scanning/LoadingSectionHeader.swift @@ -0,0 +1,60 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +public struct LoadingSectionHeader: View { + private let text: Text + private let loading: Bool + + public var body: some View { + HStack { + text + if loading { + ProgressView() + .padding(.leading, 4) + .accessibilityRemoveTraits(.updatesFrequently) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text("\(text), Searching", bundle: .module)) + } + + @_disfavoredOverload + public init(verbatim: String, loading: Bool) { + self.init(Text(verbatim), loading: loading) + } + + public init(_ title: LocalizedStringResource, loading: Bool) { + self.init(Text(title), loading: loading) + } + + + public init(_ text: Text, loading: Bool) { + self.text = text + self.loading = loading + } +} + + +#if DEBUG +#Preview { + List { + Section { + Text(verbatim: "...") + } header: { + LoadingSectionHeader(verbatim: "Devices", loading: true) + } + } +} + +#Preview { + LoadingSectionHeader(verbatim: "Devices", loading: true) +} +#endif diff --git a/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift b/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift new file mode 100644 index 0000000..049161e --- /dev/null +++ b/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift @@ -0,0 +1,161 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +import SpeziBluetooth +import SpeziDevices +import SpeziViews +import SwiftUI + + +public struct NearbyDeviceRow: View { + private let peripheral: any GenericBluetoothPeripheral + private let devicePrimaryActionClosure: () -> Void + private let secondaryActionClosure: (() -> Void)? + + + var showDetailsButton: Bool { + secondaryActionClosure != nil && peripheral.state == .connected + } + + var localizationSecondaryLabel: LocalizedStringResource? { + if peripheral.requiresUserAttention { + return .init("Intervention Required", bundle: .atURL(from: .module)) + } + switch peripheral.state { + case .connecting: + return .init("Connecting", bundle: .atURL(from: .module)) + case .connected: + return .init("Connected", bundle: .atURL(from: .module)) + case .disconnecting: + return .init("Disconnecting", bundle: .atURL(from: .module)) + case .disconnected: + return nil + } + } + + public var body: some View { + let stack = HStack { + Button(action: devicePrimaryAction) { + HStack { + ListRow(verbatim: peripheral.label) { + deviceSecondaryLabel + } + if peripheral.state == .connecting || peripheral.state == .disconnecting { + ProgressView() + .accessibilityRemoveTraits(.updatesFrequently) + } + } + } + + if showDetailsButton { + Button(action: deviceDetailsAction) { + Label { + Text("Device Details", bundle: .module) + } icon: { + Image(systemName: "info.circle") // swiftlint:disable:this accessibility_label_for_image + } + } + .labelStyle(.iconOnly) + .font(.title3) + .buttonStyle(.plain) // ensure button is clickable next to the other button + .foregroundColor(.accentColor) + } + } + + #if TEST || targetEnvironment(simulator) + // accessibility actions cannot be unit tested + stack + #else + stack.accessibilityRepresentation { + accessibilityRepresentation + } + #endif + } + + @ViewBuilder var accessibilityRepresentation: some View { + let button = Button(action: devicePrimaryAction) { + Text(verbatim: peripheral.accessibilityLabel) + if let localizationSecondaryLabel { + Text(localizationSecondaryLabel) + } + } + + if showDetailsButton { + button + .accessibilityAction(named: Text("Device Details", bundle: .module), deviceDetailsAction) + } else { + button + } + } + + @ViewBuilder var deviceSecondaryLabel: some View { + if peripheral.requiresUserAttention { + Text("Requires Attention", bundle: .module) + } else { + switch peripheral.state { + case .connecting, .disconnecting: + EmptyView() + case .connected: + Text("Connected", bundle: .module) + case .disconnected: + EmptyView() + } + } + } + + + public init( + peripheral: any GenericBluetoothPeripheral, + primaryAction: @escaping () -> Void, + secondaryAction: (() -> Void)? = nil + ) { + self.peripheral = peripheral + self.devicePrimaryActionClosure = primaryAction + self.secondaryActionClosure = secondaryAction + } + + + private func devicePrimaryAction() { + devicePrimaryActionClosure() + } + + private func deviceDetailsAction() { + if let secondaryActionClosure { + secondaryActionClosure() + } + } +} + + +#if DEBUG +#Preview { + List { + NearbyDeviceRow(peripheral: MockBluetoothPeripheral(label: "MyDevice 1", state: .connecting)) { + print("Clicked") + } secondaryAction: { + } + NearbyDeviceRow(peripheral: MockBluetoothPeripheral(label: "MyDevice 2", state: .connected)) { + print("Clicked") + } secondaryAction: { + } + NearbyDeviceRow(peripheral: MockBluetoothPeripheral(label: "Long MyDevice 3", state: .connected, requiresUserAttention: true)) { + print("Clicked") + } secondaryAction: { + } + NearbyDeviceRow(peripheral: MockBluetoothPeripheral(label: "MyDevice 4", state: .disconnecting)) { + print("Clicked") + } secondaryAction: { + } + NearbyDeviceRow(peripheral: MockBluetoothPeripheral(label: "MyDevice 5", state: .disconnected)) { + print("Clicked") + } secondaryAction: { + } + } +} +#endif diff --git a/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md b/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md index c42235a..d370945 100644 --- a/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md +++ b/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md @@ -18,6 +18,10 @@ SPDX-License-Identifier: MIT ## Topics -### Group +### Presenting nearby devices -- ``Symbol`` +Views that are helpful when building a nearby devices view. + +- ``BluetoothUnavailableView`` +- ``NearbyDeviceRow`` +- ``LoadingSectionHeader`` diff --git a/Sources/SpeziDevicesUI/Testing/MockBluetoothPeripheral.swift b/Sources/SpeziDevicesUI/Testing/MockBluetoothPeripheral.swift new file mode 100644 index 0000000..3531dca --- /dev/null +++ b/Sources/SpeziDevicesUI/Testing/MockBluetoothPeripheral.swift @@ -0,0 +1,24 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth +import SpeziDevices + + +/// Mock peripheral used for internal previews. +struct MockBluetoothPeripheral: GenericBluetoothPeripheral { + var label: String + var state: PeripheralState + var requiresUserAttention: Bool + + init(label: String, state: PeripheralState, requiresUserAttention: Bool = false) { + self.label = label + self.state = state + self.requiresUserAttention = requiresUserAttention + } +} diff --git a/Sources/SpeziDevicesUI/Testing/MockDevice.swift b/Sources/SpeziDevicesUI/Testing/MockDevice.swift index 5bafa07..0b37f05 100644 --- a/Sources/SpeziDevicesUI/Testing/MockDevice.swift +++ b/Sources/SpeziDevicesUI/Testing/MockDevice.swift @@ -6,14 +6,14 @@ // SPDX-License-Identifier: MIT // -import BluetoothServices import Foundation @_spi(TestingSupport) import SpeziBluetooth +import SpeziBluetoothServices import SpeziDevices #if DEBUG -final class MockDevice: PairableDevice, Identifiable { +final class MockDevice: PairableDevice, Identifiable { @DeviceState(\.id) var id @DeviceState(\.name) var name @DeviceState(\.state) var state diff --git a/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift b/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift new file mode 100644 index 0000000..6b19eb6 --- /dev/null +++ b/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift @@ -0,0 +1,36 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Spezi +@_spi(TestingSupport) import SpeziFoundation +import TipKit + + +class ConfigureTipKit: Module, DefaultInitializable { // TODO: move to SpeziViews! + @Application(\.logger) private var logger + + + required init() {} + + func configure() { + if RuntimeConfig.testingTips || ProcessInfo.processInfo.isPreviewSimulator { + Tips.showAllTipsForTesting() + } + do { + try Tips.configure() + } catch { + Self.logger.error("Failed to configure TipKit: \(error)") + } + } +} + + +extension RuntimeConfig { + /// Enable testing tips + static let testingTips = CommandLine.arguments.contains("--testTips") +} diff --git a/Sources/SpeziDevicesUI/Utils/PaneContent.swift b/Sources/SpeziDevicesUI/Utils/PaneContent.swift index afe5236..4b9c3f1 100644 --- a/Sources/SpeziDevicesUI/Utils/PaneContent.swift +++ b/Sources/SpeziDevicesUI/Utils/PaneContent.swift @@ -93,7 +93,7 @@ struct PaneContent: View { #if DEBUG #Preview { SheetPreview { - PaneContent(title: "The Title", subtitle: "The Subtitle") { + PaneContent(title: Text(verbatim: "The Title"), subtitle: Text(verbatim: "The Subtitle")) { Image(systemName: "person.crop.square.badge.camera.fill") .symbolRenderingMode(.hierarchical) .resizable() @@ -104,7 +104,7 @@ struct PaneContent: View { } action: { Button { } label: { - Text("Button") + Text(verbatim: "Button") .frame(maxWidth: .infinity, maxHeight: 35) } .buttonStyle(.borderedProminent) diff --git a/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift b/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift index 1cfcc7e..9a31350 100644 --- a/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift +++ b/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift @@ -6,8 +6,9 @@ // SPDX-License-Identifier: MIT // -@_spi(APISupport) import BluetoothServices // swiftlint:disable:this attributes import SpeziBluetooth +@_spi(APISupport) +import SpeziBluetoothServices extension CharacteristicAccessor where Value == RecordAccessControlPoint { diff --git a/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift b/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift index 2336125..e2db4d9 100644 --- a/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift +++ b/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift @@ -6,8 +6,8 @@ // SPDX-License-Identifier: MIT // -import BluetoothServices import NIOCore +import SpeziBluetoothServices /// The Record Access Operand format for the Omron Record Access Control Point characteristic. diff --git a/Sources/SpeziOmron/Characteristic/RecordAccessControlPoint+Omron.swift b/Sources/SpeziOmron/Characteristic/RecordAccessControlPoint+Omron.swift index 81556d9..731d942 100644 --- a/Sources/SpeziOmron/Characteristic/RecordAccessControlPoint+Omron.swift +++ b/Sources/SpeziOmron/Characteristic/RecordAccessControlPoint+Omron.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -import BluetoothServices +import SpeziBluetoothServices extension RecordAccessControlPoint { diff --git a/Sources/SpeziOmron/Characteristic/RecordAccessOpCode+Omron.swift b/Sources/SpeziOmron/Characteristic/RecordAccessOpCode+Omron.swift index 2aa53d1..830f4b1 100644 --- a/Sources/SpeziOmron/Characteristic/RecordAccessOpCode+Omron.swift +++ b/Sources/SpeziOmron/Characteristic/RecordAccessOpCode+Omron.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -import BluetoothServices +import SpeziBluetoothServices extension RecordAccessOpCode { diff --git a/Sources/SpeziOmron/OmronOptionService.swift b/Sources/SpeziOmron/OmronOptionService.swift index 9278aac..d4d45e0 100644 --- a/Sources/SpeziOmron/OmronOptionService.swift +++ b/Sources/SpeziOmron/OmronOptionService.swift @@ -6,9 +6,9 @@ // SPDX-License-Identifier: MIT // -import BluetoothServices import class CoreBluetooth.CBUUID import SpeziBluetooth +import SpeziBluetoothServices /// The Omron Option Service.