Skip to content

Commit

Permalink
Decompose Nearby Device Row (#18)
Browse files Browse the repository at this point in the history
# Decompose Nearby Device Row

## ♻️ Current situation & Problem
The `NearbyDeviceRow` was a monolith-style view that encompassed all
functionality without a lot of configuration possibilities to vitalizes
a nearby device in, e.g., a Bluetooth scanning view. This design is not
really flexible if you want to show your own information or want to
adjust the layout. Previously you were forced to write your own view
completely from scratch. This PR decomposes the view into multiple
subview allowing you to build your custom `NearbyDeviceRow`-like
experience without reinventing the wheel completely. The new
`PeripheralLabel`, `PeripheralSecondaryLabel`, and `InfoButton` views
(see StanfordSpezi/SpeziViews#50) allow you to
fully customize the appearance of your `NearbyDeviceRow`.


## ⚙️ Release Notes 
* Decompose the `NearbyDeviceRow` into `PeripheralLabel`,
`PeripheralSecondaryLabel`, and `InfoButton` views for greater
customizability.


## 📚 Documentation
Added new views to the DocC documentation target.


## ✅ Testing
The new tests are implicitly tested via the `NearbyDeviceRow`.


## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
Supereg authored Dec 23, 2024
1 parent 397c0e8 commit e842a01
Show file tree
Hide file tree
Showing 9 changed files with 180 additions and 90 deletions.
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))
}
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 {
Group {
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()
}
}
}
.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

0 comments on commit e842a01

Please sign in to comment.