Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Decompose Nearby Device Row #18

Merged
merged 9 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ let package = Package(
.package(url: "https://github.com/apple/swift-collections.git", from: "1.1.1"),
.package(url: "https://github.com/StanfordSpezi/SpeziFoundation.git", from: "2.0.0-beta.1"),
.package(url: "https://github.com/StanfordSpezi/Spezi.git", from: "1.7.1"),
.package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.5.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziBluetooth.git", from: "3.0.1"),
.package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.8.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziBluetooth.git", from: "3.1.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziNetworking.git", from: "2.1.1"),
.package(url: "https://github.com/StanfordBDHG/XCTestExtensions.git", from: "1.0.0")
] + swiftLintPackage(),
Expand Down
134 changes: 53 additions & 81 deletions Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,126 +6,82 @@
// SPDX-License-Identifier: MIT
//


import SpeziBluetooth
@_spi(TestingSupport) import SpeziDevices
import SpeziViews
import SwiftUI


/// A row that displays information of a nearby Bluetooth peripheral in a List view.
public struct NearbyDeviceRow: View {
public struct NearbyDeviceRow<Label: View>: View {
private let peripheral: any GenericBluetoothPeripheral
private let label: Label
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 {
HStack {
Button(action: devicePrimaryAction) {
HStack {
ListRow(verbatim: peripheral.label) {
deviceSecondaryLabel
.foregroundStyle(.secondary)
}
label

Spacer()

if peripheral.state == .connecting || peripheral.state == .disconnecting {
ProgressView()
.accessibilityRemoveTraits(.updatesFrequently)
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(.primary)
.accessibilityAddTraits(.isButton)
.tint(.primary)

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 secondaryActionClosure != nil && peripheral.state == .connected {
InfoButton(Text("Device Details", bundle: .module), action: deviceDetailsAction)
}
}

#if TEST || targetEnvironment(simulator)
// accessibility actions cannot be unit tested
stack
#else
stack.accessibilityRepresentation {
accessibilityRepresentation
}
#endif
.accessibilityElement(children: .combine)
}

@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()
/// Create a new nearby device row.
/// - Parameters:
/// - peripheral: The nearby peripheral.
/// - primaryAction: The action that is executed when tapping the peripheral.
/// It is recommended to connect or disconnect devices when tapping on them.
/// - secondaryAction: The action that is executed when the device details button is pressed.
/// The device details button is displayed once the peripheral is connected.
public init(
peripheral: any GenericBluetoothPeripheral,
primaryAction: @escaping () -> Void,
secondaryAction: (() -> Void)? = nil
) where Label == LabeledContent<PeripheralLabel, PeripheralSecondaryLabel> {
self.init(peripheral: peripheral, primaryAction: primaryAction, secondaryAction: secondaryAction) {
LabeledContent {
PeripheralSecondaryLabel(peripheral)
} label: {
PeripheralLabel(peripheral)
}
}
}


/// Create a new nearby device row.

/// Creates a new nearby device row.
/// - Parameters:
/// - peripheral: The nearby peripheral.
/// - primaryAction: The action that is executed when tapping the peripheral.
/// It is recommended to connect or disconnect devices when tapping on them.
/// - secondaryAction: The action that is executed when the device details button is pressed.
/// The device details button is displayed once the peripheral is connected.
/// - label: The label that is displayed for the row.
public init(
peripheral: any GenericBluetoothPeripheral,
primaryAction: @escaping () -> Void,
secondaryAction: (() -> Void)? = nil
secondaryAction: (() -> Void)? = nil,
@ViewBuilder label: () -> Label
) {
self.peripheral = peripheral
self.label = label()
self.devicePrimaryActionClosure = primaryAction
self.secondaryActionClosure = secondaryAction
}
Expand All @@ -144,7 +100,7 @@ public struct NearbyDeviceRow: View {


#if DEBUG
#Preview {
#Preview { // swiftlint:disable:this closure_body_length
List {
NearbyDeviceRow(peripheral: MockBluetoothPeripheral(label: "MyDevice 1", state: .connecting)) {
print("Clicked")
Expand All @@ -153,10 +109,12 @@ public struct NearbyDeviceRow: View {
NearbyDeviceRow(peripheral: MockBluetoothPeripheral(label: "MyDevice 2", state: .connected)) {
print("Clicked")
} secondaryAction: {
print("Secondary Clicked!")
}
NearbyDeviceRow(peripheral: MockBluetoothPeripheral(label: "Long MyDevice 3", state: .connected, requiresUserAttention: true)) {
print("Clicked")
} secondaryAction: {
print("Secondary Clicked!")
}
NearbyDeviceRow(peripheral: MockBluetoothPeripheral(label: "MyDevice 4", state: .disconnecting)) {
print("Clicked")
Expand All @@ -166,6 +124,20 @@ public struct NearbyDeviceRow: View {
print("Clicked")
} secondaryAction: {
}

let peripheral = MockBluetoothPeripheral(label: "MyDevice 2", state: .connected)
NearbyDeviceRow(peripheral: MockBluetoothPeripheral(label: "MyDevice 2", state: .connected)) {
print("Clicked")
} secondaryAction: {
print("Secondary Clicked!")
} label: {
ListRow {
PeripheralLabel(peripheral)
Text("RSSI: -64")
} content: {
PeripheralSecondaryLabel(peripheral)
}
}
}
}
#endif
37 changes: 37 additions & 0 deletions Sources/SpeziDevicesUI/Scanning/PeripheralLabel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// 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
@_spi(TestingSupport) import SpeziDevices
import SwiftUI


/// The label of a bluetooth peripheral.
public struct PeripheralLabel: View {
private let peripheral: any GenericBluetoothPeripheral

public var body: some View {
Text(peripheral.label)
.accessibilityLabel(Text(peripheral.accessibilityLabel))
}

/// Create a new bluetooth peripheral label.
/// - Parameter peripheral: The peripheral to describe.
public init(_ peripheral: any GenericBluetoothPeripheral) {
self.peripheral = peripheral
}
}


#if DEBUG
#Preview {
List {
PeripheralLabel(MockBluetoothPeripheral(label: "MyDevice 1", state: .connected))
}
}
#endif
77 changes: 77 additions & 0 deletions Sources/SpeziDevicesUI/Scanning/PeripheralSecondaryLabel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// 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
@_spi(TestingSupport) import SpeziDevices
import SwiftUI


/// A secondary label of a Bluetooth peripheral.
///
/// The secondary label describes the state of the Bluetooth peripheral (e.g., `connecting` or `connected`).
/// It might be empty in a `disconnected` state.
public struct PeripheralSecondaryLabel: View {
private let peripheral: any GenericBluetoothPeripheral

private var localizationSecondaryLabel: LocalizedStringResource? {
if peripheral.requiresUserAttention {
return .init("Intervention Required", bundle: .atURL(from: .module))

Check warning on line 23 in Sources/SpeziDevicesUI/Scanning/PeripheralSecondaryLabel.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziDevicesUI/Scanning/PeripheralSecondaryLabel.swift#L23

Added line #L23 was not covered by tests
}
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))

Check warning on line 31 in Sources/SpeziDevicesUI/Scanning/PeripheralSecondaryLabel.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziDevicesUI/Scanning/PeripheralSecondaryLabel.swift#L31

Added line #L31 was not covered by tests
case .disconnected:
return nil
}
}

public var body: some View {
Group {
if peripheral.requiresUserAttention {
Text("Requires Attention", bundle: .module)

Check warning on line 40 in Sources/SpeziDevicesUI/Scanning/PeripheralSecondaryLabel.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziDevicesUI/Scanning/PeripheralSecondaryLabel.swift#L40

Added line #L40 was not covered by tests
} else {
switch peripheral.state {
case .connecting, .disconnecting:
EmptyView()
case .connected:
Text("Connected", bundle: .module)
case .disconnected:
EmptyView()
}
}
}
.accessibilityRepresentation {
if let localizationSecondaryLabel {
Text(localizationSecondaryLabel)
}
}
}

/// Create a new secondary peripheral label.
/// - Parameter peripheral: The Bluetooth peripheral.
public init(_ peripheral: any GenericBluetoothPeripheral) {
self.peripheral = peripheral
}
}


#if DEBUG
#Preview {
List {
PeripheralSecondaryLabel(MockBluetoothPeripheral(label: "MyDevice 1", state: .connecting))
PeripheralSecondaryLabel(MockBluetoothPeripheral(label: "MyDevice 1", state: .connected))
PeripheralSecondaryLabel(MockBluetoothPeripheral(label: "MyDevice 1", state: .connected, requiresUserAttention: true))
PeripheralSecondaryLabel(MockBluetoothPeripheral(label: "MyDevice 1", state: .disconnecting))
PeripheralSecondaryLabel(MockBluetoothPeripheral(label: "MyDevice 1", state: .disconnected))
}
}
#endif
2 changes: 2 additions & 0 deletions Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ Views that are helpful when building a nearby devices view.
- ``BluetoothUnavailableView``
- ``NearbyDeviceRow``
- ``LoadingSectionHeader``
- ``PeripheralLabel``
- ``PeripheralSecondaryLabel``

### Pairing Devices

Expand Down
1 change: 0 additions & 1 deletion Sources/SpeziOmron/Model/OmronLocalName.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ public struct OmronLocalName {

/// Initialize the local name from the raw value string.
/// - Parameter rawValue: The local name raw value.
/// - Returns: Returns nil if the string is ill-formatted.
public init?(rawValue: String) {
let pattern = Regex {
TryCapture {
Expand Down
6 changes: 3 additions & 3 deletions Sources/SpeziOmron/Model/OmronManufacturerData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,11 @@ public struct OmronManufacturerData {
/// - streamingMode: The streaming mode.
/// - servicesMode: The services mode.
/// - users: The list of users. At least one, maximum four.
public init( // swiftlint:disable:this function_default_parameter_at_end
timeSet: Bool = true,
public init(
timeSet: Bool = true, // swiftlint:disable:this function_default_parameter_at_end
pairingMode: PairingMode,
streamingMode: StreamingMode = .dataCommunication,
servicesMode: ServicesMode = .bluetoothStandard,
servicesMode: ServicesMode = .bluetoothStandard, // swiftlint:disable:this function_default_parameter_at_end
users: [UserSlot]
) {
// swiftlint:disable:next empty_count
Expand Down
4 changes: 2 additions & 2 deletions Tests/UITests/TestAppUITests/BluetoothViewsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ class BluetoothViewsTests: XCTestCase {

XCTAssert(app.staticTexts["DEVICES"].exists)

XCTAssert(app.buttons["Mock Device"].exists)
app.buttons["Mock Device"].tap()
XCTAssert(app.staticTexts["Mock Device"].exists)
app.staticTexts["Mock Device"].tap()

XCTAssert(app.buttons["Mock Device, Connected"].waitForExistence(timeout: 5.0))
XCTAssert(app.buttons["Device Details"].exists)
Expand Down
Loading
Loading