From b338e02e2217cecf16c8f98f6a6298698fbf6af8 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sun, 17 Nov 2024 12:37:16 +0100 Subject: [PATCH] Decompose Nearby Device Row --- .../Scanning/ListInfoButton.swift | 43 +++++++ .../Scanning/NearbyDeviceRow.swift | 119 +++++------------- .../Scanning/PeripheralLabel.swift | 34 +++++ .../Scanning/PeripheralSecondaryLabel.swift | 72 +++++++++++ 4 files changed, 182 insertions(+), 86 deletions(-) create mode 100644 Sources/SpeziDevicesUI/Scanning/ListInfoButton.swift create mode 100644 Sources/SpeziDevicesUI/Scanning/PeripheralLabel.swift create mode 100644 Sources/SpeziDevicesUI/Scanning/PeripheralSecondaryLabel.swift diff --git a/Sources/SpeziDevicesUI/Scanning/ListInfoButton.swift b/Sources/SpeziDevicesUI/Scanning/ListInfoButton.swift new file mode 100644 index 0000000..6036502 --- /dev/null +++ b/Sources/SpeziDevicesUI/Scanning/ListInfoButton.swift @@ -0,0 +1,43 @@ +// +// 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 ListInfoButton: View { // TODO: move to SpeziViews! + private let label: Text + private let action: () -> Void + + public var body: some View { + Button(action: action) { + Label { + label + } icon: { + Image(systemName: "info.circle") // swiftlint:disable:this accessibility_label_for_image + } + } + .labelStyle(.iconOnly) + .font(.title3) + .foregroundColor(.accentColor) + .buttonStyle(.plain) // ensure button is clickable next to the other button + .accessibilityAction(named: label, action) +#if TEST || targetEnvironment(simulator) + .accessibilityHidden(true) // accessibility actions cannot be unit tested +#endif + } + + public init(_ label: Text, action: @escaping () -> Void) { + self.label = label + self.action = action + } + + public init(_ resource: LocalizedStringResource, action: @escaping () -> Void) { + self.label = Text(resource) + self.action = action + } +} diff --git a/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift b/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift index f5f0ba7..0b3c1b6 100644 --- a/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift +++ b/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // - import SpeziBluetooth @_spi(TestingSupport) import SpeziDevices import SpeziViews @@ -14,118 +13,64 @@ import SwiftUI /// A row that displays information of a nearby Bluetooth peripheral in a List view. -public struct NearbyDeviceRow: View { +public struct NearbyDeviceRow: 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) - } - if peripheral.state == .connecting || peripheral.state == .disconnecting { - ProgressView() - .accessibilityRemoveTraits(.updatesFrequently) - } - } + label } - .foregroundStyle(.primary) + .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 { + ListInfoButton(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 == ListRow { + self.init(peripheral: peripheral, primaryAction: primaryAction, secondaryAction: secondaryAction) { + ListRow { + PeripheralLabel(peripheral) + } content: { + PeripheralSecondaryLabel(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 } @@ -153,10 +98,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") diff --git a/Sources/SpeziDevicesUI/Scanning/PeripheralLabel.swift b/Sources/SpeziDevicesUI/Scanning/PeripheralLabel.swift new file mode 100644 index 0000000..5835bd1 --- /dev/null +++ b/Sources/SpeziDevicesUI/Scanning/PeripheralLabel.swift @@ -0,0 +1,34 @@ +// +// 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 + + +public struct PeripheralLabel: View { + private let peripheral: any GenericBluetoothPeripheral + + public var body: some View { + Text(peripheral.label) + .accessibilityLabel(Text(peripheral.accessibilityLabel)) + } + + init(_ peripheral: any GenericBluetoothPeripheral) { + self.peripheral = peripheral + } +} + + +#if DEBUG +#Preview { + List { + PeripheralLabel(MockBluetoothPeripheral(label: "MyDevice 1", state: .connected)) + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Scanning/PeripheralSecondaryLabel.swift b/Sources/SpeziDevicesUI/Scanning/PeripheralSecondaryLabel.swift new file mode 100644 index 0000000..4ad2860 --- /dev/null +++ b/Sources/SpeziDevicesUI/Scanning/PeripheralSecondaryLabel.swift @@ -0,0 +1,72 @@ +// +// 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 + + +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: + ProgressView() + .accessibilityRemoveTraits(.updatesFrequently) + case .connected: + Text("Connected", bundle: .module) + case .disconnected: + EmptyView() + } + } + } + .accessibilityRepresentation { + if let localizationSecondaryLabel { + Text(localizationSecondaryLabel) + } + } + } + + 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