diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..8eb59870 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "dart-code.flutter", + "dart-code.dart-code" + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..0ee9b5db --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "blood-pressure-monitor", + "type": "dart", + "request": "launch", + "cwd": "${workspaceFolder}/app", + "program": "lib/main.dart", + "args": [ + "--flavor", "github" + ] + } + ] +} \ No newline at end of file diff --git a/BLUETOOTH.md b/BLUETOOTH.md new file mode 100644 index 00000000..d67f8a24 --- /dev/null +++ b/BLUETOOTH.md @@ -0,0 +1,44 @@ +# Supported Bluetooth devices + +In general any device that supports [`Blood Pressure Service (0x1810)`](https://bitbucket.org/bluetooth-SIG/public/src/main/assigned_numbers/uuids/service_uuids.yaml#lines-77:79) could be used. The blood pressure measurement values are stored in the characteristic [`Blood Pressure Measurement (0x2A35)`](https://bitbucket.org/bluetooth-SIG/public/src/main/assigned_numbers/uuids/characteristic_uuids.yaml#lines-161:163) + +## Reading caveats + +There are some difference in how devices report their measurements. + +Most devices provide 2 ways to retrieve measurements over bluetooth, but there are also difference in how those operate: + +1. Immediately after taking a measurement + 1. and returns all measurements stored in memory + 2. and only returns the latest measurement +2. As a download mode + 1. and automatically remove all locally stored measurements after a succesful download + 2. and leave measurements untouched, i.e. the user needs to remove the stored measurements themselves + +> :warning: At the moment situation 2.i is not well supported. Do not use this unless you are ok with loosing previously stored measurements + +## Known working devices + +> If your device is not listed please edit this page and add it! :bow: + +|Device|Bluetooth name|Read after measurement|Download mode|Automatically disconnects after reading| +|---|---| :---: | :---: | :---: | +|HealthForYou by Silvercrest (Type SBM 69)|SBM69| :1234: | :white_check_mark: | :white_check_mark: | +|Omron X4 Smart|X4 Smart| :one: | :white_check_mark::wastebasket: | :white_check_mark: | + +#### Legenda + +|Icon|Description| +| :---: | --- | +| :no_entry_sign: |Not supported / No| +| :white_check_mark: |Supported / Yes| +| :one: | Returns latest measurement| +| :1234: | Returns all measurements| +| :white_check_mark::wastebasket: |Supported and removes all locally stored measurements| + +## Specifications + +- Blood Pressure Service: https://www.bluetooth.com/specifications/specs/blood-pressure-service-1-1-1/ +- Assigned Numbers (f.e. service & characteristic UUID's): https://www.bluetooth.com/specifications/assigned-numbers/ +- GATT Specification Supplement (f.e. data structures): https://www.bluetooth.com/specifications/gss/ +- Current Time Service: https://www.bluetooth.com/specifications/specs/current-time-service-1-1/ diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties index 1eb4e49d..04ad34cb 100644 --- a/app/android/gradle/wrapper/gradle-wrapper.properties +++ b/app/android/gradle/wrapper/gradle-wrapper.properties @@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip -distributionSha256Sum=f30b29580fe11719087d698da23f3b0f0d04031d8995f7dd8275a31f7674dc01 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +distributionSha256Sum=2ab88d6de2c23e6adae7363ae6e29cbdd2a709e992929b48b6530fd0c7133bd6 \ No newline at end of file diff --git a/app/android/settings.gradle b/app/android/settings.gradle index db7675cc..c3aa23a8 100644 --- a/app/android/settings.gradle +++ b/app/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.1.0" apply false + id "com.android.application" version "8.3.2" apply false id "org.jetbrains.kotlin.android" version "1.8.22" apply false } diff --git a/app/lib/config.dart b/app/lib/config.dart new file mode 100644 index 00000000..e99e68c9 --- /dev/null +++ b/app/lib/config.dart @@ -0,0 +1,10 @@ +import 'dart:io'; + +/// Whether bluetooth is supported on this platform by this app +final isPlatformSupportedBluetooth = Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux; + +/// Whether we are running in a test environment +final isTestingEnvironment = Platform.environment['FLUTTER_TEST'] == 'true'; + +/// Whether the value graph should be shown as home screen in landscape mode +final showValueGraphAsHomeScreenInLandscapeMode = isTestingEnvironment || !Platform.isLinux; diff --git a/app/lib/data_util/entry_context.dart b/app/lib/data_util/entry_context.dart index 373ea54d..3c8a8e2e 100644 --- a/app/lib/data_util/entry_context.dart +++ b/app/lib/data_util/entry_context.dart @@ -55,7 +55,7 @@ extension EntryUtils on BuildContext { } } } on ProviderNotFoundException { - Log.err('createEntry($initial) was called from a context without Provider.'); + log.severe('[extension.EntryUtils] createEntry($initial) was called from a context without Provider.'); } catch (e, stack) { await ErrorReporting.reportCriticalError('Error opening add measurement dialoge', '$e\n$stack',); } @@ -93,7 +93,7 @@ extension EntryUtils on BuildContext { ),); } } on ProviderNotFoundException { - Log.err('deleteEntry($entry) was called from a context without Provider.'); + log.severe('[extension.EntryUtils] deleteEntry($entry) was called from a context without Provider.'); } } } diff --git a/app/lib/features/bluetooth/backend/bluetooth_backend.dart b/app/lib/features/bluetooth/backend/bluetooth_backend.dart new file mode 100644 index 00000000..580cea59 --- /dev/null +++ b/app/lib/features/bluetooth/backend/bluetooth_backend.dart @@ -0,0 +1,16 @@ +/// Utility import that only exposes the bluetooth backend services that should be used. +library; + +export 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart' show BluetoothDevice; +export 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_manager.dart' show BluetoothManager; +export 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_state.dart' show BluetoothAdapterState; + +/// All available bluetooth backends +enum BluetoothBackend { + /// Bluetooth Low Energy backend + bluetoothLowEnergy, + /// Flutter Blue Plus backend + flutterBluePlus, + /// Mock backend + mock; +} diff --git a/app/lib/features/bluetooth/backend/bluetooth_connection.dart b/app/lib/features/bluetooth/backend/bluetooth_connection.dart new file mode 100644 index 00000000..dca97571 --- /dev/null +++ b/app/lib/features/bluetooth/backend/bluetooth_connection.dart @@ -0,0 +1,8 @@ +/// State of the bluetooth connection of a device +enum BluetoothConnectionState { + /// Device is connected + connected, + /// Device is disconnect + disconnected; +} + diff --git a/app/lib/features/bluetooth/backend/bluetooth_device.dart b/app/lib/features/bluetooth/backend/bluetooth_device.dart new file mode 100644 index 00000000..18a35369 --- /dev/null +++ b/app/lib/features/bluetooth/backend/bluetooth_device.dart @@ -0,0 +1,392 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_connection.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_manager.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_service.dart'; +import 'package:blood_pressure_app/logging.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; + +/// Current state of the bluetooth device +enum BluetoothDeviceState { + /// Started connecting to the device + connecting, + /// Started disconnecting the device + disconnecting, + /// Device is connected (f.e. it send a connected event) + connected, + /// Device is disconnected (f.e. it send a disconnected event) + disconnected; +} + +/// Wrapper class for bluetooth implementations to generically expose required functionality +abstract class BluetoothDevice< + BM extends BluetoothManager, + BS extends BluetoothService, + BC extends BluetoothCharacteristic, + BackendDevice +> with TypeLogger { + /// Create a new BluetoothDevice. + /// + /// * [manager] Manager the device belongs to + /// * [source] Device implementation of the current backend + BluetoothDevice(this.manager, this.source) { + logger.finer('init device: $this'); + } + + /// [BluetoothManager] this device belongs to + final BM manager; + + /// Original source device as returned by the backend + final BackendDevice source; + + BluetoothDeviceState _state = BluetoothDeviceState.disconnected; + + /// (Unique?) id of the device + String get deviceId; + + /// Name of the device + String get name; + + /// Memoized service list for the device + List? _services; + + StreamSubscription? _connectionListener; + + /// Whether the device is connected + bool get isConnected => _state == BluetoothDeviceState.connected; + + /// Stream to listen to for connection state changes after connecting to a device + Stream get connectionStream; + + /// Backend implementation to connect to the device + Future backendConnect(); + /// Backend implementation to disconnect to the device + Future backendDisconnect(); + + /// Require backends to implement a dispose method to cleanup any resources + Future dispose(); + + /// Array of disconnect callbacks + /// + /// Disconnect callbacks are processed in reverse order, i.e. the latest added callback is executed as first. Callbacks + /// can return true to indicate they have fully handled the disconnect. This will then also stop executing any remaining + /// callbacks. + final List disconnectCallbacks = []; + + /// Wait for the device state to change to a different value then disconnecting + /// + /// [timeout] - How long to wait before timeout occurs. A value of -1 disables waiting, a value of 0 waits indefinitely + Future _waitForDisconnectingStateChange({ int timeout = 300000 }) async { + if (timeout < 0) { + return; + } + + // Futures within an any still always resolve, it's just that the results + // are disregard for futures that do not finish first. Use this bool to + // keep track whether the futures are already completed or not + bool futuresCompleted = false; + + /// Waits and calls itself recursively as long as the current device [_state] equals [BluetoothDeviceState.disconnecting] + Future checkDeviceState() async { + while (!futuresCompleted && _state == BluetoothDeviceState.disconnecting) { + logger.finest('Waiting because device is still disconnecting'); + await Future.delayed(const Duration(milliseconds: 10)); + } + } + + final futures = [checkDeviceState()]; + if (timeout > 0) { + futures.add( + Future.delayed(Duration(milliseconds: min(timeout, 300000))).then((_) { + if (!futuresCompleted) { + logger.finest('connect: Wait for state change timed out after $timeout ms'); + } + }) + ); + } + + await Future.any(futures); + futuresCompleted = true; + } + + /// Connect to the device + /// + /// Always call [disconnect] when ready after calling connect + /// [onConnect] Called after device is connected + /// [onDisconnect] Called after device is disconnected, see [disconnectCallbacks] + /// [onError] Called when an error occurs + /// [waitForDisconnectingStateChangeTimeout] If connect is called while the device is still disconnecting, wait + /// for the device to change it's state. A value of -1 means don't ever wait, a value of 0 means wait indefinitely + /// Setting this timeout ensures correct state management of the device so users only have to call disconnect()/connect() + Future connect({ + VoidCallback? onConnect, + bool Function()? onDisconnect, + ValueSetter? onError, + int waitForDisconnectingStateChangeTimeout = 3000 + }) async { + if (_state == BluetoothDeviceState.disconnecting) { + await _waitForDisconnectingStateChange(timeout: waitForDisconnectingStateChangeTimeout); + } + + if (_state != BluetoothDeviceState.disconnected) { + return false; + } + + _state = BluetoothDeviceState.connecting; + + final completer = Completer(); + logger.finer('connect: start connecting'); + + if (onDisconnect != null) { + disconnectCallbacks.add(onDisconnect); + } + + await _connectionListener?.cancel(); + _connectionListener = connectionStream.listen((BluetoothConnectionState state) { + logger.finest('connectionStream.listen[_state: $_state]: $state'); + + // Note: in this abstraction we want the device state to be singular. Unfortunately + // not all libraries on all platforms send only a single connection state event. F.e. + // flutter_blue_plus can send 3 disconnect events the very first time you try to connect + // with a device. These multiple similar events for the same device will break our logic + // so we need to filter the states. + switch (state) { + case BluetoothConnectionState.connected: + if (_state != BluetoothDeviceState.connecting) { + // Ignore status update if the current device state was not connecting. Cause then + // the library probably send multiple state update events. + logger.finest('Ignoring state update because device was not connecting: $_state'); + return; + } + + onConnect?.call(); + if (!completer.isCompleted) completer.complete(true); + _state = BluetoothDeviceState.connected; + return; + case BluetoothConnectionState.disconnected: + if ([BluetoothDeviceState.connecting, BluetoothDeviceState.disconnected].any((s) => s == _state)) { + // Ignore status update if the state was connecting or already disconnected + logger.finest('Ignoring state update because device was already disconnected: $_state'); + return; + } + + for (final fn in disconnectCallbacks.reversed) { + if (fn()) { + // ignore other disconnect callbacks + break; + } + } + + disconnectCallbacks.clear(); + if (!completer.isCompleted) completer.complete(false); + _state = BluetoothDeviceState.disconnected; + } + }, onError: onError); + + try { + await backendConnect(); + } catch (e) { + logger.severe('Failed to connect to device', e); + if (!completer.isCompleted) completer.complete(false); + _state = BluetoothDeviceState.disconnected; + } + + return completer.future.then((res) { + logger.finer('connect: completer.resolved($res)'); + return res; + }); + } + + /// Disconnect & dispose the device + /// + /// Always call [disconnect] after calling [connect] to ensure all resources are disposed + /// Optionally specify [waitForStateChangeTimeout] in milliseconds to indicate how long we + /// should wait for the device to send a disconnect event. Specifying a value of -1 disables + /// waiting for the state change, a value of 0 means wait indefinitely. + Future disconnect({ int waitForStateChangeTimeout = 3000 }) async { + _state = BluetoothDeviceState.disconnecting; + await backendDisconnect(); + + if (waitForStateChangeTimeout > -1) { + await _waitForDisconnectingStateChange(timeout: waitForStateChangeTimeout); + + assert( + _state == BluetoothDeviceState.disconnecting || _state == BluetoothDeviceState.disconnected, + 'Expected state either to be disconnecting (due to timeout) or disconnected. Got $_state instead' + ); + } + + await _connectionListener?.cancel(); + await dispose(); + return true; + } + + /// Discover all available services on the device + /// + /// It's recommended to use [getServices] instead + Future?> discoverServices(); + + /// Return all available services for this device + /// + /// Difference with [discoverServices] is that [getServices] memoizes the results + Future?> getServices() async { + final logServices = _services == null; // only log services on the first call + _services ??= await discoverServices(); + if (_services == null) { + logger.finer('Failed to discoverServices on: $this'); + } + + if (logServices) { + logger.finest(_services + ?.map((s) => 'Found services\n$s:\n - ${s.characteristics.join('\n - ')}]') + .join('\n')); + } + + return _services; + } + + /// Returns the service with requested [uuid] or null if requested service is not available + Future getServiceByUuid(BluetoothUuid uuid) async { + final services = await getServices(); + return services?.firstWhereOrNull((service) => service.uuid == uuid); + } + + /// Retrieves the value of [characteristic] from the device and calls [onValue] for all received values + /// + /// This method provides a generic implementation for async reading of data, regardless whether the + /// characteristic can be read directly or through a notification or indication. In case the value + /// is being read using an indication, then the [onValue] callback receives a second argument [complete] with + /// which you can stop reading the data. + /// + /// Note that a [characteristic] could have multiple values, so [onValue] can be called more then once. + /// TODO: implement reading values for characteristics with [canNotify] + Future getCharacteristicValue(BC characteristic, void Function(Uint8List value, [void Function(bool success)? complete]) onValue); + + @override + String toString() => 'BluetoothDevice{name: $name, deviceId: $deviceId}'; + + @override + /// Compare devices, only checking hashCode is not sufficient during tests as hashCode + /// of mocked classes seems to be always 0 hence why also comparing by deviceId + /// TODO: Understand why the mocked devices in the device_scan_cubit_test have the same hashCode=0 and are therefore not all added to the set + bool operator == (Object other) => other is BluetoothDevice && hashCode == other.hashCode && deviceId == other.deviceId; + + @override + int get hashCode => deviceId.hashCode ^ name.hashCode; +} + +/// Generic logic to implement an indication stream to read characteristic values +mixin CharacteristicValueListener< + BM extends BluetoothManager, + BS extends BluetoothService, + BC extends BluetoothCharacteristic, + BackendDevice +> on BluetoothDevice { + /// List of read characteristic completers, used for cleanup on device disconnect + final List> _readCharacteristicCompleters = []; + /// List of read characteristic subscriptions, used for cleanup on device disconnect + final List> _readCharacteristicListeners = []; + + /// Dispose of all resources used to read characteristics + /// + /// Internal method, should not be used by users + @protected + Future disposeCharacteristics() async { + for (final completer in _readCharacteristicCompleters) { + // completing the completer also cancels the listener + completer.complete(false); + } + + _readCharacteristicCompleters.clear(); + _readCharacteristicListeners.clear(); + } + + /// Trigger notifications or indications for the [characteristic] + @protected + Future triggerCharacteristicValue(BC characteristic, [bool state = true]); + + /// Read characteristic values from a stream + /// + /// It's not recommended to use this method directly, use [BluetoothDevice.getCharacteristicValue] instead + @protected + Future listenCharacteristicValue( + BC characteristic, + Stream characteristicValueStream, + void Function(Uint8List value, [void Function(bool success)? complete]) onValue + ) async { + if (!characteristic.canIndicate) { + return false; + } + + final completer = Completer(); + bool receivedSomeData = false; + + bool disconnectCallback() { + logger.finer('listenCharacteristicValue(receivedSomeData: $receivedSomeData): onDisconnect called'); + if (!receivedSomeData) { + return false; + } + + completer.complete(true); + return true; + } + + disconnectCallbacks.add(disconnectCallback); + + final listener = characteristicValueStream.listen( + (value) { + if (value == null) { + // ignore null values + return; + } + + logger.finer('listenCharacteristicValue[${value.length}] $value'); + + receivedSomeData = true; + onValue(value, completer.complete); + }, + cancelOnError: true, + onDone: () { + logger.finer('listenCharacteristicValue: onDone called'); + completer.complete(receivedSomeData); + }, + onError: (Object err) { + logger.shout('listenCharacteristicValue: Error while reading characteristic', err); + completer.complete(false); + } + ); + + // track completer & listener so we can clean them up + // when the device unexpectedly disconnects (ie before + // any data has been received yet) + _readCharacteristicCompleters.add(completer); + _readCharacteristicListeners.add(listener); + + try { + logger.finest('listenCharacteristicValue: triggering characteristic value'); + final bool triggerSuccess = await triggerCharacteristicValue(characteristic); + if (!triggerSuccess) { + logger.warning('listenCharacteristicValue: triggerCharacteristicValue returned $triggerSuccess'); + } + } catch (e) { + logger.severe('Error occured while triggering characteristic', e); + } + + return completer.future.then((res) { + // Ensure listener is always cancelled when completer resolves + listener.cancel().then((_) => _readCharacteristicListeners.remove(listener)); + + // Remove stored completer reference + _readCharacteristicCompleters.remove(completer); + + // Remove disconnect callback in case the connection was not automatically disconnected + if (disconnectCallbacks.remove(disconnectCallback)) { + logger.finer('listenCharacteristicValue: device was not automatically disconnected after completer finished, removing disconnect callback'); + } + + return res; + }); + } +} diff --git a/app/lib/features/bluetooth/backend/bluetooth_discovery.dart b/app/lib/features/bluetooth/backend/bluetooth_discovery.dart new file mode 100644 index 00000000..34df1115 --- /dev/null +++ b/app/lib/features/bluetooth/backend/bluetooth_discovery.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_manager.dart'; +import 'package:blood_pressure_app/logging.dart'; +import 'package:flutter/foundation.dart'; + +/// Base class for backend device discovery implementations +abstract class BluetoothDeviceDiscovery with TypeLogger { + /// Initialize base class for device discovery implementations. + BluetoothDeviceDiscovery(this.manager) { + logger.finer('init device discovery: $this'); + } + + /// Corresponding BluetoothManager + final BM manager; + + /// List of discovered devices + final Set _devices = {}; + + /// A stream that returns the discovered devices when discovering + Stream> get discoverStream; + + StreamSubscription>? _discoverSubscription; + + /// Backend implementation to start discovering + @protected + Future backendStart(String serviceUuid); + + /// Backend implementation to stop discovering + @protected + Future backendStop(); + + /// Whether device discovery is running or not + bool _discovering = false; + + /// True when already discovering devices + bool get isDiscovering => _discovering; + + /// Start discovering for new devices + /// + /// - [serviceUuid] The service uuid to filter on when discovering devices + /// - [onDevices] Callback for when devices have been discovered. The + /// [onDevices] callback can be called multiple times, it is also always + /// called with the list of all discovered devices from the start of discovering + Future start(String serviceUuid, ValueSetter> onDevices) async { + if (_discovering) { + logger.warning('Already discovering, not starting discovery again'); + return; + } + + // Do not remove this if, otherwise the device_scan_cubit_test will 'hang' + // Not sure why, it seems during testing this would close the mocked stream + // immediately so when adding devices through mock.sink.add they never reach + // the device_scan_cubit component + // TODO: figure out why test fails without this if + if (_discoverSubscription != null) { + await _discoverSubscription?.cancel(); + } + + _discovering = true; + _devices.clear(); + + _discoverSubscription = discoverStream.listen((newDevices) { + logger.finest('New devices discovered: $newDevices'); + assert(_discovering); + + // Note that there are major differences in how backends return discovered devices, + // f.e. FlutterBluePlus batches results itself while BluetoothLowEnergy will always + // return one device per listen callback. + // The _devices type [Set] makes sure sure we are not adding duplicate devices. + _devices.addAll(newDevices); + + onDevices(_devices.toList()); + }, onError: onDiscoveryError); + + logger.fine('Starting discovery for devices with service: $serviceUuid'); + await backendStart(serviceUuid); + } + + /// Called when an error occurs during discovery + void onDiscoveryError(Object error) { + logger.severe('Starting device scan failed', error); + _discovering = false; + } + + /// Stop discovering for new devices + Future stop() async { + if (!_discovering) { + return; + } + + logger.finer('Stopping discovery'); + await _discoverSubscription?.cancel(); + await backendStop(); + _devices.clear(); + _discovering = false; + } +} diff --git a/app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_device.dart b/app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_device.dart new file mode 100644 index 00000000..69ce1d78 --- /dev/null +++ b/app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_device.dart @@ -0,0 +1,124 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_connection.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_manager.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_service.dart'; +import 'package:bluetooth_low_energy/bluetooth_low_energy.dart' show CentralManager, ConnectionState, DiscoveredEventArgs, PeripheralConnectionStateChangedEventArgs; + +/// BluetoothDevice implementation for the 'bluetooth_low_energy' package +final class BluetoothLowEnergyDevice + extends BluetoothDevice< + BluetoothLowEnergyManager, + BluetoothLowEnergyService, + BluetoothLowEnergyCharacteristic, + DiscoveredEventArgs + > with CharacteristicValueListener< + BluetoothLowEnergyManager, + BluetoothLowEnergyService, + BluetoothLowEnergyCharacteristic, + DiscoveredEventArgs + > +{ + /// Init BluetoothDevice implementation for the 'bluetooth_low_energy' package + BluetoothLowEnergyDevice(super.manager, super.source); + + @override + String get deviceId => source.peripheral.uuid.toString(); + + @override + String get name => source.advertisement.name ?? deviceId; + + CentralManager get _cm => manager.backend; + + @override + Stream get connectionStream => _cm.connectionStateChanged + .map((PeripheralConnectionStateChangedEventArgs rawState) => switch (rawState.state) { + ConnectionState.connected => BluetoothConnectionState.connected, + ConnectionState.disconnected => BluetoothConnectionState.disconnected, + }); + + @override + Future backendConnect() => _cm.connect(source.peripheral); + + @override + Future backendDisconnect() => _cm.disconnect(source.peripheral); + + @override + Future dispose() => disposeCharacteristics(); + + @override + Future?> discoverServices() async { + if (!isConnected) { + logger.finer('discoverServices: device not connect. Call device.connect() first'); + return null; + } + + // Query actual services supported by the device. While they must be + // rediscovered when a disconnect happens, this object is also recreated. + try { + final rawServices = await _cm.discoverGATT(source.peripheral); + logger.finer('discoverServices: $rawServices'); + return rawServices.map(BluetoothLowEnergyService.fromSource).toList(); + } catch (e) { + logger.shout('discoverServices: error:', [source.peripheral, e]); + return null; + } + } + + @override + Future triggerCharacteristicValue(BluetoothLowEnergyCharacteristic characteristic, [bool state = true]) async { + await _cm.setCharacteristicNotifyState(source.peripheral, characteristic.source, state: state); + return true; + } + + @override + Future getCharacteristicValue( + BluetoothLowEnergyCharacteristic characteristic, + void Function(Uint8List value, [void Function(bool success)? complete]) onValue, + ) async { + if (!isConnected) { + assert(false, 'getCharacteristicValue: device not connected. Call device.connect() first'); + logger.finer('getCharacteristicValue: device not connected.'); + return false; + } + + if (characteristic.canRead) { // Read characteristic value if supported + try { + final data = await _cm.readCharacteristic( + source.peripheral, + characteristic.source, + ); + + onValue(data); + return true; + } catch (err) { + logger.warning('getCharacteristicValue: ble readCharacteristic failed', err); + return false; + } + } + + else if (characteristic.canIndicate) { // Listen for characteristic value and trigger the device to send it + return listenCharacteristicValue( + characteristic, + _cm.characteristicNotified.map((eventArgs) { + if (characteristic.source != eventArgs.characteristic) { + /// characteristicNotified is a generic stream which ble does not + /// pre-filter for just the current requested characteristic + logger.finer(' data is for a different characteristic'); + logger.finest(' ${eventArgs.characteristic.uuid} == ${characteristic.source.uuid} => ${eventArgs.characteristic == characteristic.source}'); + logger.finest(' ${eventArgs.characteristic} == ${characteristic.source} => ${eventArgs.characteristic == characteristic.source}'); + return null; + } + + return eventArgs.value; + }), + onValue + ); + } + + logger.severe("Can't read or indicate characteristic: $characteristic"); + return false; + } +} diff --git a/app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_discovery.dart b/app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_discovery.dart new file mode 100644 index 00000000..b06f365a --- /dev/null +++ b/app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_discovery.dart @@ -0,0 +1,31 @@ +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_discovery.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_manager.dart'; +import 'package:bluetooth_low_energy/bluetooth_low_energy.dart' show UUID; + +/// BluetoothDeviceDiscovery implementation for the 'bluetooth_low_energy' package +final class BluetoothLowEnergyDiscovery extends BluetoothDeviceDiscovery { + /// Construct BluetoothDeviceDiscovery implementation for the 'bluetooth_low_energy' package + BluetoothLowEnergyDiscovery(super.manager); + + @override + Stream> get discoverStream => manager.backend.discovered.map( + (device) => [manager.createDevice(device)] + ); + + @override + Future backendStart(String serviceUuid) async { + try { + await manager.backend.startDiscovery( + // no timeout, the user knows best how long scanning is needed + serviceUUIDs: [UUID.fromString(serviceUuid)], + // Not all devices might be found using this configuration + ); + } catch (e) { + onDiscoveryError(e); + } + } + + @override + Future backendStop() => manager.backend.stopDiscovery(); +} diff --git a/app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_manager.dart b/app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_manager.dart new file mode 100644 index 00000000..3018b2bd --- /dev/null +++ b/app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_manager.dart @@ -0,0 +1,64 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_device.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_discovery.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_service.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_state.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_manager.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_state.dart'; +import 'package:bluetooth_low_energy/bluetooth_low_energy.dart'; + +/// Bluetooth manager for the 'bluetooth_low_energy' package +final class BluetoothLowEnergyManager extends BluetoothManager { + /// constructor + BluetoothLowEnergyManager() { + logger.fine('init'); + + // Sync current adapter state + _adapterStateParser.parseAndCache(BluetoothLowEnergyStateChangedEventArgs(backend.state)); + } + + @override + Future enable() async { + if (!Platform.isAndroid) { + return null; + } + + return backend.authorize(); + } + + /// The actual backend implementation + final CentralManager backend = CentralManager(); + + final BluetoothLowEnergyStateParser _adapterStateParser = BluetoothLowEnergyStateParser(); + + @override + BluetoothAdapterState get lastKnownAdapterState => _adapterStateParser.lastKnownState; + + @override + Stream get stateStream => backend.stateChanged.map(_adapterStateParser.parse); + + BluetoothLowEnergyDiscovery? _discovery; + + @override + BluetoothLowEnergyDiscovery get discovery { + _discovery ??= BluetoothLowEnergyDiscovery(this); + return _discovery!; + } + + @override + BluetoothLowEnergyDevice createDevice(DiscoveredEventArgs device) => BluetoothLowEnergyDevice(this, device); + + @override + BluetoothLowEnergyUUID createUuid(UUID uuid) => BluetoothLowEnergyUUID(uuid); + + @override + BluetoothLowEnergyUUID createUuidFromString(String uuid) => BluetoothLowEnergyUUID.fromString(uuid); + + @override + BluetoothLowEnergyService createService(GATTService service) => BluetoothLowEnergyService.fromSource(service); + + @override + BluetoothLowEnergyCharacteristic createCharacteristic(GATTCharacteristic characteristic) => BluetoothLowEnergyCharacteristic.fromSource(characteristic); +} diff --git a/app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_service.dart b/app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_service.dart new file mode 100644 index 00000000..833dd88f --- /dev/null +++ b/app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_service.dart @@ -0,0 +1,45 @@ +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_service.dart'; +import 'package:bluetooth_low_energy/bluetooth_low_energy.dart'; + +/// UUID wrapper for BluetoothLowEnergy +final class BluetoothLowEnergyUUID extends BluetoothUuid { + /// Create a [BluetoothLowEnergyUUID] from a [UUID] + BluetoothLowEnergyUUID(UUID uuid): super(uuid: uuid.toString(), source: uuid); + + /// Create a [BluetoothLowEnergyUUID] from a [String] + factory BluetoothLowEnergyUUID.fromString(String uuid) => BluetoothLowEnergyUUID(UUID.fromString(uuid)); +} + +/// Wrapper class with generic interface for a [GATTService] +final class BluetoothLowEnergyService + extends BluetoothService { + + /// Create a [BluetoothLowEnergyService] from a [GATTService] + BluetoothLowEnergyService.fromSource(GATTService service): + super(uuid: BluetoothLowEnergyUUID(service.uuid), source: service); + + @override + List get characteristics => source.characteristics.map(BluetoothLowEnergyCharacteristic.fromSource).toList(); +} + +/// Wrapper class with generic interface for a [GATTCharacteristic] +final class BluetoothLowEnergyCharacteristic extends BluetoothCharacteristic { + /// Create a [BluetoothLowEnergyCharacteristic] from the backend specific source + BluetoothLowEnergyCharacteristic.fromSource(GATTCharacteristic source): + super(uuid: BluetoothLowEnergyUUID(source.uuid), source: source); + + @override + bool get canRead => source.properties.contains(GATTCharacteristicProperty.read); + + @override + bool get canWrite => source.properties.contains(GATTCharacteristicProperty.write); + + @override + bool get canWriteWithoutResponse => source.properties.contains(GATTCharacteristicProperty.writeWithoutResponse); + + @override + bool get canNotify => source.properties.contains(GATTCharacteristicProperty.notify); + + @override + bool get canIndicate => source.properties.contains(GATTCharacteristicProperty.indicate); +} diff --git a/app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_state.dart b/app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_state.dart new file mode 100644 index 00000000..e3fe6259 --- /dev/null +++ b/app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_state.dart @@ -0,0 +1,17 @@ +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_state.dart'; +import 'package:bluetooth_low_energy/bluetooth_low_energy.dart'; + +/// Bluetooth adapter state parser for the 'bluetooth_low_energy' package +final class BluetoothLowEnergyStateParser extends BluetoothAdapterStateParser { + @override + BluetoothAdapterState parse(BluetoothLowEnergyStateChangedEventArgs rawState) => switch (rawState.state) { + BluetoothLowEnergyState.unsupported => BluetoothAdapterState.unfeasible, + // Bluetooth permissions should always be granted on normal android + // devices. Users on non-standard android devices will know how to + // enable them. If this is not the case there will be bug reports. + BluetoothLowEnergyState.unauthorized => BluetoothAdapterState.unauthorized, + BluetoothLowEnergyState.poweredOn => BluetoothAdapterState.ready, + BluetoothLowEnergyState.poweredOff => BluetoothAdapterState.disabled, + BluetoothLowEnergyState.unknown => BluetoothAdapterState.initial, + }; +} diff --git a/app/lib/features/bluetooth/backend/bluetooth_manager.dart b/app/lib/features/bluetooth/backend/bluetooth_manager.dart new file mode 100644 index 00000000..d43fc450 --- /dev/null +++ b/app/lib/features/bluetooth/backend/bluetooth_manager.dart @@ -0,0 +1,57 @@ +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_discovery.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_manager.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_service.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_state.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_manager.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/mock/mock_manager.dart'; +import 'package:blood_pressure_app/logging.dart'; + +/// Base class for a bluetooth manager +abstract class BluetoothManager with TypeLogger { + /// Instantiate the correct [BluetoothManager] implementation. + static BluetoothManager create([BluetoothBackend? backend]) { + switch (backend) { + case BluetoothBackend.mock: + return MockBluetoothManager(); + case BluetoothBackend.flutterBluePlus: + return FlutterBluePlusManager(); + case BluetoothBackend.bluetoothLowEnergy: + default: + return BluetoothLowEnergyManager(); + } + } + + /// Trigger the device to request the user for bluetooth ermissions + /// + /// Returns null if no permissions were requested (ie because its not needed on a platform) + /// or true/false to indicate whether requesting permissions succeeded (not if it was granted) + Future enable(); // TODO: use task specific plugin/native code + + /// Last known adapter state + /// + /// For convenience [BluetoothAdapterStateParser] instances already track the last known state, + /// so that state only needs to be returned in a backend's manager implementation + BluetoothAdapterState get lastKnownAdapterState; + + /// Getter for the state stream + Stream get stateStream; + + /// Device discovery implementation + BluetoothDeviceDiscovery get discovery; + + /// Convert a BackendDevice into a BluetoothDevice + BluetoothDevice createDevice(BackendDevice device); + + /// Convert a BackendUuid into a BluetoothUuid + BluetoothUuid createUuid(BackendUuid uuid); + + /// Create a BluetoothUuid from a String + BluetoothUuid createUuidFromString(String uuid); + + /// Convert a BackendService into a BluetoothService + BluetoothService createService(BackendService service); + + /// Convert a BackendCharacteristic into a BluetoothCharacteristic + BluetoothCharacteristic createCharacteristic(BackendCharacteristic characteristic); +} diff --git a/app/lib/features/bluetooth/backend/bluetooth_service.dart b/app/lib/features/bluetooth/backend/bluetooth_service.dart new file mode 100644 index 00000000..a8f3d13f --- /dev/null +++ b/app/lib/features/bluetooth/backend/bluetooth_service.dart @@ -0,0 +1,136 @@ +import 'package:collection/collection.dart'; + +/// Bluetooth Base UUID from Bluetooth Core Spec +/// +/// The full 128-bit value of a 16-bit or 32-bit UUID may be computed by a simple arithmetic +/// operation. +/// 128_bit_value = 16_bit_value × 296 + Bluetooth_Base_UUID +/// 128_bit_value = 32_bit_value × 296 + Bluetooth_Base_UUID +const bluetoothBaseUuid = '00000000-0000-1000-8000-00805F9B34FB'; + +/// Generic BluetoothUuid representation +abstract class BluetoothUuid { + /// constructor + BluetoothUuid({ required this.uuid, required this.source }): assert(uuid.length == 36, 'Expected uuid to have a length of 36, got uuid=$uuid'); + + /// Create a BluetoothUuid from a string + BluetoothUuid.fromString(this.uuid): + assert(uuid.isNotEmpty, 'This static method is abstract'), + source = bluetoothBaseUuid as BackendUuid // satisfy linter + { + throw AssertionError('This static method is abstract'); + } + + /// 128-bit string representation of uuid + final String uuid; + + /// The backend specific uuid + final BackendUuid source; + + /// Whether this uuid is an official bluetooth core spec uuid + bool get isBluetoothUuid => uuid.toUpperCase().endsWith(bluetoothBaseUuid.substring(8)); + + @override + String toString() => uuid; + + /// Returns the 16 bit value of the UUID if uuid is from bluetooth core spec, otherwise full id + /// + /// The 16-bit Attribute UUID replaces the x’s in the following: + /// 0000xxxx-0000-1000-8000-00805F9B34FB + String get shortId { + final uuid = toString(); + assert(uuid.length == 36); + + if (isBluetoothUuid) { + return '0x${uuid.substring(4, 8)}'; + } + + return uuid; + } + + @override + bool operator == (Object other) { + if (other is BluetoothUuid) { + return toString() == other.toString(); + } + + if (other is BluetoothService) { + return toString() == other.uuid.toString(); + } + + if (other is BluetoothCharacteristic) { + return toString() == other.uuid.toString(); + } + + return false; + } + + @override + int get hashCode => super.hashCode * 17; +} + +/// Generic BluetoothService representation +abstract class BluetoothService { + /// Initialize bluetooth service wrapper class + BluetoothService({ required this.uuid, required this.source }); + + /// UUID of the service + final BluetoothUuid uuid; + /// Backend source for the service + final BackendService source; + + /// Get all characteristics for this service + List get characteristics; + + /// Returns the characteristic with requested [uuid], returns null if + /// requested [uuid] was not found + Future getCharacteristicByUuid(BluetoothUuid uuid) async => characteristics.firstWhereOrNull((service) => service.uuid == uuid); + + @override + String toString() => 'BluetoothService{uuid: ${uuid.shortId}, source: ${source.runtimeType}}'; + + @override + bool operator ==(Object other) => (other is BluetoothService) + && toString() == other.toString(); + + @override + int get hashCode => super.hashCode * 17; +} + +/// Characteristic representation +abstract class BluetoothCharacteristic { + /// Initialize bluetooth characteristic wrapper class + BluetoothCharacteristic({ required this.uuid, required this.source }); + + /// UUID of the characteristic + final BluetoothUuid uuid; + /// Backend source for the characteristic + final BackendCharacteristic source; + + /// Whether the characteristic can be read + bool get canRead; + + /// Whether the characteristic can be written to + bool get canWrite; + + /// Whether the characteristic can be written to without a response + bool get canWriteWithoutResponse; + + /// Whether the characteristic permits notifications for it's value, without a response to indicate receipt of the notification. + bool get canNotify; + + /// Whether the characteristic permits notifications for it's value, with a response to indicate receipt of the notification + bool get canIndicate; + + @override + String toString() => 'BluetoothCharacteristic{uuid: ${uuid.shortId}, source: ${source.runtimeType}, ' + 'canRead: $canRead, canWrite: $canWrite, canWriteWithoutResponse: $canWriteWithoutResponse, ' + 'canNotify: $canNotify, canIndicate: $canIndicate}'; + + @override + bool operator ==(Object other) => (other is BluetoothCharacteristic) + && toString() == other.toString(); + + @override + int get hashCode => super.hashCode * 17; +} diff --git a/app/lib/features/bluetooth/backend/bluetooth_state.dart b/app/lib/features/bluetooth/backend/bluetooth_state.dart new file mode 100644 index 00000000..9517e3b8 --- /dev/null +++ b/app/lib/features/bluetooth/backend/bluetooth_state.dart @@ -0,0 +1,21 @@ +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_utils.dart'; + +/// Current state of bluetooth adapter/sensor +enum BluetoothAdapterState { + /// Use of bluetooth adapter not authorized + unauthorized, + /// Use of bluetooth adapter not possible, f.e. because there is none + unfeasible, + /// Use of bluetooth adapter disabled + disabled, + /// Use of bluetooth adapter unknown + initial, + /// Bluetooth adapter ready to be used + ready; +} + +/// Util to parse backend adapter states to [BluetoothAdapterState] +abstract class BluetoothAdapterStateParser extends StreamDataParserCached { + @override + BluetoothAdapterState get initialState => BluetoothAdapterState.initial; +} diff --git a/app/lib/features/bluetooth/backend/bluetooth_utils.dart b/app/lib/features/bluetooth/backend/bluetooth_utils.dart new file mode 100644 index 00000000..74849c0a --- /dev/null +++ b/app/lib/features/bluetooth/backend/bluetooth_utils.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_state.dart'; +import 'package:blood_pressure_app/logging.dart'; + +/// Generic stream data parser base class +abstract class StreamDataParser { + /// Method to be implemented by backend that converts the raw bluetooth adapter state to our BluetoothState + ParsedData parse(StreamData rawState); +} + +/// Generic stream data parser base class that caches the last known state returned by the stream +abstract class StreamDataParserCached extends StreamDataParser { + /// Initial state when it's unknown, f.e. when stream didn't return any data yet + ParsedData get initialState; + ParsedData? _lastKnownState; + + /// The last known adapter state + ParsedData get lastKnownState => _lastKnownState ?? initialState; + + /// Internal method to cache the last adapter state value, backends should only implement parse not this method + ParsedData parseAndCache(StreamData rawState) { + _lastKnownState = parse(rawState); + return lastKnownState; + } +} + +/// Transforms the backend's bluetooth adapter state stream to emit [BluetoothAdapterState]'s +/// +/// Can normally be used directly, backends should only inject a customized BluetoothStateParser +class StreamDataParserTransformer> + extends StreamDataTransformer { + /// Create a BluetoothAdapterStateStreamTransformer + /// + /// [stateParser] The BluetoothStateParser that provides the backend logic to convert BackendState to BluetoothAdapterState + StreamDataParserTransformer({ required SD stateParser, super.sync, super.cancelOnError }) { + _stateParser = stateParser; + } + + late SD _stateParser; + + @override + void onData(StreamData streamData) { + late ParsedData data; + if (_stateParser is StreamDataParserCached) { + data = (_stateParser as StreamDataParserCached).parseAndCache(streamData); + } else { + data = _stateParser.parse(streamData); + } + + sendData(data); + } +} + + +/// Generic stream transformer util that should support cancelling & pausing etc +/// +/// Implementations should only need to worry about transforming the data by overriding +/// the onData method and sending the transformed data using sendData +/// +/// TODO: move outside bluetooth logic +abstract class StreamDataTransformer with TypeLogger implements StreamTransformer { + /// Create a BluetoothStreamTransformer + /// + /// - [sync] Passed to [StreamController] + /// - [cancelOnError] Passed to [Stream] + StreamDataTransformer({ bool sync = false, bool cancelOnError = false }) { + _cancelOnError = cancelOnError; + + _controller = StreamController( + onListen: _onListen, + onCancel: _onCancel, + onPause: () => _subscription?.pause(), + onResume: () => _subscription?.resume(), + sync: sync, + ); + } + + late StreamController _controller; + StreamSubscription? _subscription; + Stream? _stream; + bool _cancelOnError = false; + + void _onListen() { + logger.finest('_onListen'); + _subscription = _stream?.listen( + onData, + onError: _controller.addError, + onDone: _controller.close, + cancelOnError: _cancelOnError); + } + + void _onCancel() { + logger.finest('_onCancel'); + _subscription?.cancel(); + _subscription = null; + } + + /// Method that actually transforms the data being passed through this stream + void onData(S streamData); + + /// Send data to the listening stream, should f.e. be called from within the + /// onData call to forward the transformed data + void sendData(T data) { + _controller.add(data); + } + + @override + Stream bind(Stream stream) { + logger.finest('bind'); + _stream = stream; + return _controller.stream; + } + + @override + StreamTransformer cast() => StreamTransformer.castFrom(this); +} diff --git a/app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_device.dart b/app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_device.dart new file mode 100644 index 00000000..495e8510 --- /dev/null +++ b/app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_device.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_connection.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_manager.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp; + +/// BluetoothDevice implementation for the 'flutter_blue_plus' package +final class FlutterBluePlusDevice + extends BluetoothDevice< + FlutterBluePlusManager, + FlutterBluePlusService, + FlutterBluePlusCharacteristic, + fbp.ScanResult + > + with CharacteristicValueListener< + FlutterBluePlusManager, + FlutterBluePlusService, + FlutterBluePlusCharacteristic, + fbp.ScanResult + > +{ + /// Initialize BluetoothDevice implementation for the 'flutter_blue_plus' package + FlutterBluePlusDevice(super.manager, super.source); + + @override + String get deviceId => source.device.remoteId.str; + + @override + String get name => source.device.platformName; + + @override + Stream get connectionStream => source.device.connectionState + .map((fbp.BluetoothConnectionState rawState) => switch (rawState) { + fbp.BluetoothConnectionState.connected => BluetoothConnectionState.connected, + fbp.BluetoothConnectionState.disconnected => BluetoothConnectionState.disconnected, + // code should never reach here + fbp.BluetoothConnectionState.connecting || fbp.BluetoothConnectionState.disconnecting + => throw ErrorDescription('Unsupported connection state: $rawState'), + }); + + @override + Future backendConnect() => source.device.connect(); + + @override + Future backendDisconnect() => source.device.disconnect(); + + @override + Future dispose() => disposeCharacteristics(); + + @override + Future?> discoverServices() async { + if (!isConnected) { + logger.finer('Device not connected, cannot discover services'); + return null; + } + + // Query actual services supported by the device. While they must be + // rediscovered when a disconnect happens, this object is also recreated. + try { + final allServices = await source.device.discoverServices(); + logger.finer('fbp.discoverServices: $allServices'); + + return allServices.map(FlutterBluePlusService.fromSource).toList(); + } catch (e) { + logger.shout('Error on service discovery', [source.device, e]); + return null; + } + } + + @override + Future triggerCharacteristicValue(FlutterBluePlusCharacteristic characteristic, [bool state = true]) => characteristic.source.setNotifyValue(state); + + @override + Future getCharacteristicValue( + FlutterBluePlusCharacteristic characteristic, + void Function(Uint8List value, [void Function(bool success)? complete]) onValue + ) async { + if (!isConnected) { + assert(false, 'getCharacteristicValue: device not connected. Call device.connect() first'); + logger.finer('getCharacteristicValue: device not connected.'); + return false; + } + + if (characteristic.canRead) { // Read characteristic value if supported + try { + final data = await characteristic.source.read(); + onValue(Uint8List.fromList(data)); + return true; + } catch (err) { + logger.severe('getCharacteristicValue(read error)', err); + return false; + } + } + + if (characteristic.canIndicate) { // Listen for characteristic value and trigger the device to send it + return listenCharacteristicValue( + characteristic, + characteristic.source.onValueReceived.map(Uint8List.fromList), + onValue + ); + } + + logger.severe("Can't read or indicate characteristic: $characteristic"); + return false; + } +} diff --git a/app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_discovery.dart b/app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_discovery.dart new file mode 100644 index 00000000..99a8da2c --- /dev/null +++ b/app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_discovery.dart @@ -0,0 +1,31 @@ +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_discovery.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_manager.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' show Guid; + +/// BluetoothDeviceDiscovery implementation for the 'flutter_blue_plus' package +final class FlutterBluePlusDiscovery extends BluetoothDeviceDiscovery { + /// constructor + FlutterBluePlusDiscovery(super.manager); + + @override + Stream> get discoverStream => manager.backend.scanResults.map( + (devices) => devices.map(manager.createDevice).toList() + ); + + @override + Future backendStart(String serviceUuid) async { + try { + await manager.backend.startScan( + // no timeout, the user knows best how long scanning is needed + withServices: [ Guid(serviceUuid) ], + // Not all devices are found using this configuration (https://pub.dev/packages/flutter_blue_plus#scanning-does-not-find-my-device). + ); + } catch (e) { + onDiscoveryError(e); + } + } + + @override + Future backendStop() => manager.backend.stopScan(); +} diff --git a/app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_manager.dart b/app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_manager.dart new file mode 100644 index 00000000..cedc6f14 --- /dev/null +++ b/app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_manager.dart @@ -0,0 +1,68 @@ + + +import 'dart:io'; + +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_manager.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_state.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_device.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_discovery.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_service.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_state.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/flutter_blue_plus_mockable.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' show Guid, ScanResult; + +/// Bluetooth manager for the 'flutter_blue_plus' package +class FlutterBluePlusManager extends BluetoothManager { + /// constructor + FlutterBluePlusManager([FlutterBluePlusMockable? backend]): backend = (backend ?? FlutterBluePlusMockable()) { + logger.finer('init'); + } + + /// backend implementation + final FlutterBluePlusMockable backend; + + @override + Future enable() async { + if (Platform.isAndroid) { + await backend.turnOn(); + return true; + } + return null; + } + + @override + BluetoothAdapterState get lastKnownAdapterState { + // Check whether our lastKnownState is the same as FlutterBluePlus's + assert(_adapterStateParser.parse(backend.adapterStateNow) == _adapterStateParser.lastKnownState); + return _adapterStateParser.lastKnownState; + } + + final FlutterBluePlusStateParser _adapterStateParser = FlutterBluePlusStateParser(); + + @override + Stream get stateStream => backend.adapterState.map(_adapterStateParser.parse); + + FlutterBluePlusDiscovery? _discovery; + + @override + FlutterBluePlusDiscovery get discovery { + _discovery ??= FlutterBluePlusDiscovery(this); + return _discovery!; + } + + @override + FlutterBluePlusDevice createDevice(ScanResult device) => FlutterBluePlusDevice(this, device); + + @override + FlutterBluePlusUUID createUuid(Guid uuid) => FlutterBluePlusUUID(uuid); + + @override + FlutterBluePlusUUID createUuidFromString(String uuid) => FlutterBluePlusUUID.fromString(uuid); + + @override + FlutterBluePlusService createService(fbp.BluetoothService service) => FlutterBluePlusService.fromSource(service); + + @override + FlutterBluePlusCharacteristic createCharacteristic(fbp.BluetoothCharacteristic characteristic) => FlutterBluePlusCharacteristic.fromSource(characteristic); +} diff --git a/app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_service.dart b/app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_service.dart new file mode 100644 index 00000000..08cd181c --- /dev/null +++ b/app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_service.dart @@ -0,0 +1,50 @@ +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_service.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' show Guid; + +/// UUID wrapper for FlutterBluePlus +final class FlutterBluePlusUUID extends BluetoothUuid { + /// Create a [FlutterBluePlusUUID] from a [Guid] + FlutterBluePlusUUID(Guid uuid): super(uuid: uuid.str128, source: uuid); + + /// Create a [FlutterBluePlusUUID] from a [String] + factory FlutterBluePlusUUID.fromString(String uuid) => FlutterBluePlusUUID(Guid(uuid)); +} + +/// [BluetoothService] implementation wrapping [fbp.BluetoothService] +final class FlutterBluePlusService extends BluetoothService { + /// Create [BluetoothService] implementation wrapping [fbp.BluetoothService] + FlutterBluePlusService({ required super.uuid, required super.source }); + + /// Create a [FlutterBluePlusService] from a [fbp.BluetoothService] + factory FlutterBluePlusService.fromSource(fbp.BluetoothService service) { + final uuid = FlutterBluePlusUUID(service.serviceUuid); + return FlutterBluePlusService(uuid: uuid, source: service); + } + + @override + List get characteristics => source.characteristics + .map(FlutterBluePlusCharacteristic.fromSource).toList(); +} + +/// Wrapper class with generic interface for a [fbp.BluetoothCharacteristic] +final class FlutterBluePlusCharacteristic extends BluetoothCharacteristic { + /// Initialize a [FlutterBluePlusCharacteristic] from the backend specific source + FlutterBluePlusCharacteristic.fromSource(fbp.BluetoothCharacteristic source): + super(uuid: FlutterBluePlusUUID(source.characteristicUuid), source: source); + + @override + bool get canRead => source.properties.read; + + @override + bool get canWrite => source.properties.write; + + @override + bool get canWriteWithoutResponse => source.properties.writeWithoutResponse; + + @override + bool get canNotify => source.properties.notify; + + @override + bool get canIndicate => source.properties.indicate; +} diff --git a/app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_state.dart b/app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_state.dart new file mode 100644 index 00000000..9a348a4d --- /dev/null +++ b/app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_state.dart @@ -0,0 +1,19 @@ +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_state.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp; + +/// Bluetooth adapter state parser for the 'flutter_blue_plus' package +final class FlutterBluePlusStateParser extends BluetoothAdapterStateParser { + @override + BluetoothAdapterState parse(fbp.BluetoothAdapterState rawState) => switch (rawState) { + fbp.BluetoothAdapterState.unavailable => BluetoothAdapterState.unfeasible, + // Bluetooth permissions should always be granted on normal android + // devices. Users on non-standard android devices will know how to + // enable them. If this is not the case there will be bug reports. + fbp.BluetoothAdapterState.unauthorized => BluetoothAdapterState.unauthorized, + fbp.BluetoothAdapterState.on => BluetoothAdapterState.ready, + fbp.BluetoothAdapterState.off + || fbp.BluetoothAdapterState.turningOn + || fbp.BluetoothAdapterState.turningOff => BluetoothAdapterState.disabled, + fbp.BluetoothAdapterState.unknown => BluetoothAdapterState.initial, + }; +} diff --git a/app/lib/features/bluetooth/logic/flutter_blue_plus_mockable.dart b/app/lib/features/bluetooth/backend/flutter_blue_plus/flutter_blue_plus_mockable.dart similarity index 98% rename from app/lib/features/bluetooth/logic/flutter_blue_plus_mockable.dart rename to app/lib/features/bluetooth/backend/flutter_blue_plus/flutter_blue_plus_mockable.dart index 8647f64b..0117b4ee 100644 --- a/app/lib/features/bluetooth/logic/flutter_blue_plus_mockable.dart +++ b/app/lib/features/bluetooth/backend/flutter_blue_plus/flutter_blue_plus_mockable.dart @@ -2,8 +2,7 @@ import 'dart:async'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -/// Wrapper for FlutterBluePlus in order to easily mock it -/// Wraps all calls for testing purposes +/// Wrapper for FlutterBluePlus in allowing easy mocking during testing class FlutterBluePlusMockable { LogLevel get logLevel => FlutterBluePlus.logLevel; @@ -136,4 +135,4 @@ class FlutterBluePlusMockable { /// Request Bluetooth PHY support Future getPhySupport() => FlutterBluePlus.getPhySupport(); -} \ No newline at end of file +} diff --git a/app/lib/features/bluetooth/backend/mock/mock_device.dart b/app/lib/features/bluetooth/backend/mock/mock_device.dart new file mode 100644 index 00000000..4a673c0f --- /dev/null +++ b/app/lib/features/bluetooth/backend/mock/mock_device.dart @@ -0,0 +1,38 @@ +import 'dart:typed_data'; + +import 'package:blood_pressure_app/config.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_connection.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_manager.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_service.dart'; + +/// Placeholder [BluetoothDevice] implementation that can f.e. be used for testing +final class MockBluetoothDevice extends BluetoothDevice { + /// Initialize Placeholder [BluetoothDevice] implementation that can f.e. be used for testing + MockBluetoothDevice(super.manager, super.source): assert(isTestingEnvironment, 'consider whether a blanket implementation is appropriate'); + + @override + String get deviceId => super.source; + + @override + String get name => super.source; + + @override + Stream get connectionStream => const Stream.empty(); + + @override + Future backendConnect() async {} + + @override + Future backendDisconnect() async {} + + @override + Future dispose() async {} + + @override + Future>?> discoverServices() => + Future.value(>[]); + + @override + Future getCharacteristicValue(BluetoothCharacteristic characteristic, void Function(Uint8List value) onValue) async => true; +} diff --git a/app/lib/features/bluetooth/backend/mock/mock_discovery.dart b/app/lib/features/bluetooth/backend/mock/mock_discovery.dart new file mode 100644 index 00000000..fae9d225 --- /dev/null +++ b/app/lib/features/bluetooth/backend/mock/mock_discovery.dart @@ -0,0 +1,23 @@ +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_discovery.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/mock/mock_manager.dart'; + +/// Placeholder [BluetoothDeviceDiscovery] implementation that can f.e. be used for testing +final class MockBluetoothDiscovery extends BluetoothDeviceDiscovery { + /// constructor + MockBluetoothDiscovery(super.manager); + + @override + Future backendStart(String serviceUuid) { + throw UnimplementedError(); + } + + @override + Future backendStop() { + throw UnimplementedError(); + } + + @override + Stream> get discoverStream => throw UnimplementedError(); + +} diff --git a/app/lib/features/bluetooth/backend/mock/mock_manager.dart b/app/lib/features/bluetooth/backend/mock/mock_manager.dart new file mode 100644 index 00000000..92e0cc35 --- /dev/null +++ b/app/lib/features/bluetooth/backend/mock/mock_manager.dart @@ -0,0 +1,46 @@ +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_discovery.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_manager.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_service.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_state.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/mock/mock_service.dart'; + +/// Placeholder [BluetoothManager] implementation that can f.e. be used for testing +final class MockBluetoothManager extends BluetoothManager { + @override + BluetoothDeviceDiscovery get discovery => throw UnimplementedError(); + + @override + Future enable() async => null; + + @override + BluetoothAdapterState get lastKnownAdapterState => BluetoothAdapterState.initial; + + @override + Stream get stateStream => const Stream.empty(); + + @override + BluetoothUuid createUuid(String uuid) { + throw UnimplementedError(); + } + + @override + BluetoothUuid createUuidFromString(String uuid) { + throw UnimplementedError(); + } + + @override + BluetoothDevice, BluetoothCharacteristic, dynamic> createDevice(String device) { + throw UnimplementedError(); + } + + @override + BluetoothService createService(MockedService service) { + throw UnimplementedError(); + } + + @override + BluetoothCharacteristic createCharacteristic(MockedCharacteristic characteristic) { + throw UnimplementedError(); + } +} diff --git a/app/lib/features/bluetooth/backend/mock/mock_service.dart b/app/lib/features/bluetooth/backend/mock/mock_service.dart new file mode 100644 index 00000000..7bafc45a --- /dev/null +++ b/app/lib/features/bluetooth/backend/mock/mock_service.dart @@ -0,0 +1,68 @@ +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_service.dart'; + +/// Backend implementation for MockBluetoothService +final class MockedService { + /// constructor + MockedService({ required this.uuid, required this.characteristics }); + + String uuid; + List characteristics; +} + +/// Backend implementation for MockBluetoothCharacteristic +final class MockedCharacteristic { + /// constructor + MockedCharacteristic({ + required this.uuid, + this.canRead = false, + this.canWrite = false, + this.canWriteWithoutResponse = false, + this.canNotify = false, + this.canIndicate = false, + }); + + String uuid; + bool canRead; + bool canWrite; + bool canWriteWithoutResponse; + bool canNotify; + bool canIndicate; +} + +/// String wrapper for Bluetooth +final class MockBluetoothString extends BluetoothUuid { + /// Create a BluetoothString from a String + MockBluetoothString(String uuid): super(uuid: uuid, source: uuid); + /// Create a BluetoothString from a string + MockBluetoothString.fromString(String uuid): super(uuid: uuid, source: uuid); +} + +/// Wrapper class with generic interface for a [MockedService] +final class MockBluetoothService extends BluetoothService { + /// Create a FlutterBlueService from a [MockedService] + MockBluetoothService.fromSource(MockedService service): super(uuid: MockBluetoothString(service.uuid), source: service); + + @override + List get characteristics => source.characteristics.map(MockBluetoothCharacteristic.fromSource).toList(); +} + +/// Wrapper class with generic interface for a [MockedCharacteristic] +final class MockBluetoothCharacteristic extends BluetoothCharacteristic { + /// Create a BluetoothCharacteristic from the backend specific source + MockBluetoothCharacteristic.fromSource(MockedCharacteristic source): super(uuid: MockBluetoothString(source.uuid), source: source); + + @override + bool get canRead => source.canRead; + + @override + bool get canWrite => source.canWrite; + + @override + bool get canWriteWithoutResponse => source.canWriteWithoutResponse; + + @override + bool get canNotify => source.canNotify; + + @override + bool get canIndicate => source.canIndicate; +} diff --git a/app/lib/features/bluetooth/bluetooth_input.dart b/app/lib/features/bluetooth/bluetooth_input.dart index ac8ec4a0..4bdf1073 100644 --- a/app/lib/features/bluetooth/bluetooth_input.dart +++ b/app/lib/features/bluetooth/bluetooth_input.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart'; import 'package:blood_pressure_app/features/bluetooth/logic/ble_read_cubit.dart'; import 'package:blood_pressure_app/features/bluetooth/logic/bluetooth_cubit.dart'; import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_data.dart'; @@ -8,12 +9,12 @@ import 'package:blood_pressure_app/features/bluetooth/ui/closed_bluetooth_input. import 'package:blood_pressure_app/features/bluetooth/ui/device_selection.dart'; import 'package:blood_pressure_app/features/bluetooth/ui/input_card.dart'; import 'package:blood_pressure_app/features/bluetooth/ui/measurement_failure.dart'; +import 'package:blood_pressure_app/features/bluetooth/ui/measurement_multiple.dart'; import 'package:blood_pressure_app/features/bluetooth/ui/measurement_success.dart'; import 'package:blood_pressure_app/logging.dart'; import 'package:blood_pressure_app/model/storage/storage.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart' show BluetoothDevice, Guid; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:health_data_store/health_data_store.dart'; @@ -22,11 +23,15 @@ class BluetoothInput extends StatefulWidget { /// Create a measurement input through bluetooth. const BluetoothInput({super.key, required this.onMeasurement, + required this.manager, this.bluetoothCubit, this.deviceScanCubit, this.bleReadCubit, }); + /// Bluetooth Backend manager + final BluetoothManager manager; + /// Called when a measurement was received through bluetooth. final void Function(BloodPressureRecord data) onMeasurement; @@ -43,8 +48,14 @@ class BluetoothInput extends StatefulWidget { State createState() => _BluetoothInputState(); } -class _BluetoothInputState extends State { - /// Whether the user expanded bluetooth input +/// Read bluetooth input happy workflow: +/// - build is called and renders ClosedBluetoothInput with read bluetooth input button +/// - User clicks button, toggles _isActive +/// - _buildActive is called, waits for device_scan_state.DeviceSelected +/// - _buildReadDevice is called, waits for ble_read_state.BleReadSuccess +/// - onMeasurement callback triggered +class _BluetoothInputState extends State with TypeLogger { + /// Whether the user initiated reading bluetooth input bool _isActive = false; late final BluetoothCubit _bluetoothCubit; @@ -61,7 +72,7 @@ class _BluetoothInputState extends State { @override void initState() { super.initState(); - _bluetoothCubit = widget.bluetoothCubit?.call() ?? BluetoothCubit(); + _bluetoothCubit = widget.bluetoothCubit?.call() ?? BluetoothCubit(manager: widget.manager); } @override @@ -83,31 +94,83 @@ class _BluetoothInputState extends State { } await _deviceReadCubit?.close(); - _deviceReadCubit = null; await _deviceScanCubit?.close(); - _deviceScanCubit = null; await _bluetoothSubscription?.cancel(); + _deviceReadCubit = null; + _deviceScanCubit = null; _bluetoothSubscription = null; } + // TODO(derdilla): extract logic from UI + @override + Widget build(BuildContext context) { + const SizeChangedLayoutNotification().dispatch(context); + logger.finer('build[_isActive: $_isActive, _finishedData: $_finishedData]'); + + if (_finishedData != null) { + return MeasurementSuccess( + onTap: _returnToIdle, + data: _finishedData!, + ); + } + + if (_isActive) { + return _buildActive(context); + } + + return ClosedBluetoothInput( + bluetoothCubit: _bluetoothCubit, + onStarted: () async { + setState(() => _isActive = true); + }, + inputInfo: () async { + logger.finer('build.inputInfo[mounted: ${context.mounted}]'); + if (context.mounted) { + await showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.bluetoothInput), + content: Text(AppLocalizations.of(context)!.aboutBleInput), + actions: [ + ElevatedButton( + child: Text((AppLocalizations.of(context)!.btnConfirm)), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + } + }, + ); + } + + /// Build widget for 'adapter ready & discovering devices from bluetooth' state Widget _buildActive(BuildContext context) { - final Guid serviceUUID = Guid('1810'); - final Guid characteristicUUID = Guid('2A35'); + /// blood pressure service, see https://developer.nordicsemi.com/nRF51_SDK/nRF51_SDK_v4.x.x/doc/html/group___u_u_i_d___s_e_r_v_i_c_e_s.html + const String serviceUUID = '1810'; + /// blood pressure characterisic, see https://developer.nordicsemi.com/nRF51_SDK/nRF51_SDK_v4.x.x/doc/html/group___u_u_i_d___c_h_a_r_a_c_t_e_r_i_s_t_i_c_s.html#ga95fc99c7a99cf9d991c81027e4866936 + const String characteristicUUID = '2A35'; + _bluetoothSubscription = _bluetoothCubit.stream.listen((state) { - if (state is! BluetoothReady) { - Log.trace('_BluetoothInputState: _bluetoothSubscription state=$state, calling _returnToIdle'); + if (state is BluetoothStateReady) { + logger.finest('_bluetoothSubscription.listen: state=$state'); + } else { + logger.finer('_bluetoothSubscription.listen: state=$state, calling _returnToIdle'); _returnToIdle(); } }); + final settings = context.watch(); _deviceScanCubit ??= widget.deviceScanCubit?.call() ?? DeviceScanCubit( + manager: widget.manager, service: serviceUUID, settings: settings, ); + return BlocBuilder( bloc: _deviceScanCubit, builder: (context, DeviceScanState state) { - Log.trace('BluetoothInput _BluetoothInputState _deviceScanCubit: $state'); + logger.finer('DeviceScanCubit.builder deviceScanState: $state'); const SizeChangedLayoutNotification().dispatch(context); return switch(state) { DeviceListLoading() => _buildMainCard(context, @@ -122,88 +185,54 @@ class _BluetoothInputState extends State { scanResults: [ state.device ], onAccepted: (dev) => _deviceScanCubit!.acceptDevice(dev), ), - // distinction - DeviceSelected() => BlocConsumer( - bloc: () { - _deviceReadCubit = widget.bleReadCubit?.call(state.device) ?? BleReadCubit( - state.device, - characteristicUUID: characteristicUUID, - serviceUUID: serviceUUID, - ); - return _deviceReadCubit; - }(), - listener: (BuildContext context, BleReadState state) { - if (state is BleReadSuccess) { - final BloodPressureRecord record = BloodPressureRecord( - time: state.data.timestamp ?? DateTime.now(), - sys: state.data.isMMHG - ? Pressure.mmHg(state.data.systolic.toInt()) - : Pressure.kPa(state.data.systolic), - dia: state.data.isMMHG - ? Pressure.mmHg(state.data.diastolic.toInt()) - : Pressure.kPa(state.data.diastolic), - pul: state.data.pulse?.toInt(), - ); - widget.onMeasurement(record); - setState(() { - _finishedData = state.data; - }); - } - }, - builder: (BuildContext context, BleReadState state) { - Log.trace('_BluetoothInputState BleReadCubit: $state'); - const SizeChangedLayoutNotification().dispatch(context); - return switch (state) { - BleReadInProgress() => _buildMainCard(context, - child: const CircularProgressIndicator(), - ), - BleReadFailure() => MeasurementFailure( - onTap: _returnToIdle, - ), - BleReadSuccess() => MeasurementSuccess( - onTap: _returnToIdle, - data: state.data, - ), - }; - }, - ), + DeviceSelected() => _buildReadDevice(state, serviceUUID: serviceUUID, characteristicUUID: characteristicUUID) }; }, ); } - @override - Widget build(BuildContext context) { - const SizeChangedLayoutNotification().dispatch(context); - if (_finishedData != null) { - return MeasurementSuccess( - onTap: _returnToIdle, - data: _finishedData!, - ); - } - if (_isActive) return _buildActive(context); - return ClosedBluetoothInput( - bluetoothCubit: _bluetoothCubit, - onStarted: () async { - setState(() =>_isActive = true); - }, - inputInfo: () async { - if (context.mounted) { - await showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: Text(AppLocalizations.of(context)!.bluetoothInput), - content: Text(AppLocalizations.of(context)!.aboutBleInput), - actions: [ - ElevatedButton( - child: Text((AppLocalizations.of(context)!.btnConfirm)), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ); + /// Build widget for 'reading characteristic value from bluetooth' state + Widget _buildReadDevice(DeviceSelected state, { required String serviceUUID, required String characteristicUUID }) { + logger.finer('_buildReadDevice: state: $state'); + return BlocConsumer( + bloc: () { + _deviceReadCubit = widget.bleReadCubit?.call(state.device) ?? BleReadCubit( + state.device, + characteristicUUID: characteristicUUID, + serviceUUID: serviceUUID, + ); + return _deviceReadCubit; + }(), + listener: (BuildContext context, BleReadState state) { + if (state is BleReadSuccess) { + final BloodPressureRecord record = state.data.asBloodPressureRecord(); + widget.onMeasurement(record); + setState(() => _finishedData = state.data); } }, + builder: (BuildContext context, BleReadState state) { + logger.finer('BleReadCubit.builder: $state'); + const SizeChangedLayoutNotification().dispatch(context); + + return switch (state) { + BleReadInProgress() => _buildMainCard(context, + child: const CircularProgressIndicator(), + ), + BleReadFailure() => MeasurementFailure( + onTap: _returnToIdle, + reason: state.reason, + ), + BleReadMultiple() => MeasurementMultiple( + onClosed: _returnToIdle, + onSelect: (data) => _deviceReadCubit!.useMeasurement(data), + measurements: state.data, + ), + BleReadSuccess() => MeasurementSuccess( + onTap: _returnToIdle, + data: state.data, + ), + }; + }, ); } diff --git a/app/lib/features/bluetooth/logic/ble_read_cubit.dart b/app/lib/features/bluetooth/logic/ble_read_cubit.dart index 1bce8813..9964a98d 100644 --- a/app/lib/features/bluetooth/logic/ble_read_cubit.dart +++ b/app/lib/features/bluetooth/logic/ble_read_cubit.dart @@ -1,11 +1,10 @@ import 'dart:async'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart'; import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_data.dart'; import 'package:blood_pressure_app/logging.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; part 'ble_read_state.dart'; @@ -25,162 +24,127 @@ part 'ble_read_state.dart'; /// 3. If the searched characteristic is found read its value /// 4. If binary data is read decode it to object /// 5. Emit decoded object -class BleReadCubit extends Cubit { +class BleReadCubit extends Cubit with TypeLogger { /// Start reading a characteristic from a device. BleReadCubit(this._device, { required this.serviceUUID, required this.characteristicUUID, }) : super(BleReadInProgress()) { - _subscription = _device.connectionState - .listen(_onConnectionStateChanged); - // timeout + takeMeasurement(); + + // start read timeout _timeoutTimer = Timer(const Duration(minutes: 2), () { if (state is BleReadInProgress) { - Log.trace('BleReadCubit timeout reached and still running'); - emit(BleReadFailure()); + logger.finer('BleReadCubit timeout reached and still running'); + emit(BleReadFailure('Timed out after 2 minutes')); } else { - Log.trace('BleReadCubit timeout reached with state: $state, ${state is BleReadInProgress}'); + logger.finer('BleReadCubit timeout reached with state: $state, ${state is BleReadInProgress}'); } }); } - /// UUID of the service to read. - final Guid serviceUUID; - - /// UUID of the characteristic to read. - final Guid characteristicUUID; - /// Bluetooth device to connect to. /// - /// Must have an active established connection and support the measurement - /// characteristic. + /// Must have an active established connection and support the measurement characteristic. final BluetoothDevice _device; + + /// UUID of the service to read. + final String serviceUUID; + + /// UUID of the characteristic to read. + final String characteristicUUID; - late final StreamSubscription _subscription; late final Timer _timeoutTimer; - StreamSubscription>? _indicationListener; - @override - Future close() async { - Log.trace('BleReadCubit close'); - await _subscription.cancel(); - _timeoutTimer.cancel(); + int _retryCount = 0; + final int _maxRetries = 3; - if (_device.isConnected) { - try { - Log.trace('BleReadCubit close: Attempting disconnect from ${_device.advName}'); - await _device.disconnect(); - assert(_device.isDisconnected); - } catch (e) { - Log.err('unable to disconnect', [e, _device]); + /// Take a 'measurement', i.e. read the blood pressure values from the given characteristicUUID + /// TODO: make this generic by accepting a data decoder argument? + Future takeMeasurement() async { + final success = await _device.connect( + onDisconnect: () { + if (_retryCount < _maxRetries) { + _retryCount++; + takeMeasurement(); + + logger.finer('BleReadCubit: retrying after device.onDisconnect called'); + return true; + } + + logger.finer('BleReadCubit: device.onDisconnect called'); + emit(BleReadFailure('Device unexpectedly disconnected')); + return true; + }, + onError: (Object err) => emit(BleReadFailure(err.toString())) + ); + if (success) { + final uuidService = _device.manager.createUuidFromString(serviceUUID); + final service = await _device.getServiceByUuid(uuidService); + logger.finer('BleReadCubit: Found service: $service'); + if (service == null) { + // TODO: add a BleReadUnsupported state + emit(BleReadFailure('Device does not provide the expected service with uuid $serviceUUID')); + return; } - } - await super.close(); - } + final uuidCharacteristic = _device.manager.createUuidFromString(characteristicUUID); + final characteristic = await service.getCharacteristicByUuid(uuidCharacteristic); + logger.finer('BleReadCubit: Found characteristic: $characteristic'); + if (characteristic == null) { + emit(BleReadFailure('Device does not provide the expected characteristic with uuid $characteristicUUID')); + return; + } - bool _ensureConnectionInProgress = false; - Future _ensureConnection([int attemptCount = 0]) async { - Log.trace('BleReadCubit _ensureConnection'); - if (_ensureConnectionInProgress) return; - _ensureConnectionInProgress = true; - - if (_device.isAutoConnectEnabled) { - Log.trace('BleReadCubit Waiting for auto connect...'); - _ensureConnectionInProgress = false; - return; - } - - if (_device.isDisconnected) { - Log.trace('BleReadCubit _ensureConnection: Attempting to connect with ${_device.advName}'); - try { - await _device.connect(); - } on FlutterBluePlusException catch (e) { - Log.err('BleReadCubit _device.connect failed:', [_device, e]); + final List data = []; + final success = await _device.getCharacteristicValue(characteristic, (Uint8List value, [_]) => data.add(value)); + + logger.finer('BleReadCubit(success: $success): Got data: $data'); + if (!success) { + emit(BleReadFailure('Could not retrieve characteristic value')); + return; } - - if (_device.isDisconnected) { - Log.trace('BleReadCubit _ensureConnection: Device not connected'); - _ensureConnectionInProgress = false; - if (attemptCount >= 5) { - emit(BleReadFailure()); + + final List measurements = []; + + for (final item in data) { + final decodedData = BleMeasurementData.decode(item, 0); + if (decodedData == null) { + logger.severe('BleReadCubit decoding failed', item); + emit(BleReadFailure('Could not decode data')); return; - } else { - return _ensureConnection(attemptCount + 1); } + + measurements.add(decodedData); + } + + if (measurements.length > 1) { + logger.finer('BleReadMultiple decoded ${measurements.length} measurements'); + emit(BleReadMultiple(measurements)); } else { - Log.trace('BleReadCubit Connection successful'); + logger.finer('BleReadCubit decoded: ${measurements.first}'); + emit(BleReadSuccess(measurements.first)); } } - assert(_device.isConnected); - _ensureConnectionInProgress = false; } - Future _onConnectionStateChanged(BluetoothConnectionState state) async { - Log.trace('BleReadCubit _onConnectionStateChanged: $state'); - if (super.state is BleReadSuccess) return; - if (state == BluetoothConnectionState.disconnected) { - Log.trace('BleReadCubit _onConnectionStateChanged disconnected: ' - '${_device.disconnectReason} Attempting reconnect'); - await _ensureConnection(); - return; - } - assert(state == BluetoothConnectionState.connected, 'state should be ' - 'connected as connecting and disconnecting are not streamed by android'); - assert(_device.isConnected); - - // Query actual services supported by the device. While they must be - // rediscovered when a disconnect happens, this object is also recreated. - late final List allServices; - try { - allServices = await _device.discoverServices(); - Log.trace('BleReadCubit allServices: $allServices'); - } catch (e) { - Log.err('service discovery', [_device, e]); - emit(BleReadFailure()); - return; - } - - // [Guid.str] trims standard parts from the uuid. 0x1810 is the blood - // pressure uuid. https://developer.nordicsemi.com/nRF51_SDK/nRF51_SDK_v4.x.x/doc/html/group___u_u_i_d___s_e_r_v_i_c_e_s.html - final BluetoothService? service = allServices - .firstWhereOrNull((BluetoothService s) => s.uuid == serviceUUID); - if (service == null) { - Log.err('unsupported service', [_device, allServices]); - emit(BleReadFailure()); - return; - } + @override + Future close() async { + logger.finer('BleReadCubit close'); + _timeoutTimer.cancel(); - // https://developer.nordicsemi.com/nRF51_SDK/nRF51_SDK_v4.x.x/doc/html/group___u_u_i_d___c_h_a_r_a_c_t_e_r_i_s_t_i_c_s.html#ga95fc99c7a99cf9d991c81027e4866936 - final List allCharacteristics = service.characteristics; - Log.trace('BleReadCubit allCharacteristics: $allCharacteristics'); - final BluetoothCharacteristic? characteristic = allCharacteristics - .firstWhereOrNull((c) => c.uuid == characteristicUUID,); - if (characteristic == null) { - Log.err('no characteristic', [_device, allServices, allCharacteristics]); - emit(BleReadFailure()); - return; + if (_device.isConnected) { + await _device.disconnect(); } - // This characteristic only supports indication so we need to listen to values. - await _indicationListener?.cancel(); - _indicationListener = characteristic - .onValueReceived.listen((rawData) { - Log.trace('BleReadCubit data received: $rawData'); - final decodedData = BleMeasurementData.decode(rawData, 0); - if (decodedData == null) { - Log.err('BleReadCubit decoding failed', [ rawData ]); - emit(BleReadFailure()); - } else { - Log.trace('BleReadCubit decoded: $decodedData'); - emit(BleReadSuccess(decodedData)); - } - _indicationListener?.cancel(); - _indicationListener = null; - }); + await super.close(); + } - final bool indicationsSet = await characteristic.setNotifyValue(true); - Log.trace('BleReadCubit indicationsSet: $indicationsSet'); + /// Called after reading from a device returned multiple measurements and the + /// user chose which measurement they wanted to add. + Future useMeasurement(BleMeasurementData data) async { + assert(state is! BleReadSuccess); + emit(BleReadSuccess(data)); } } diff --git a/app/lib/features/bluetooth/logic/ble_read_state.dart b/app/lib/features/bluetooth/logic/ble_read_state.dart index d1cb4af8..14f73528 100644 --- a/app/lib/features/bluetooth/logic/ble_read_state.dart +++ b/app/lib/features/bluetooth/logic/ble_read_state.dart @@ -8,7 +8,22 @@ sealed class BleReadState {} class BleReadInProgress extends BleReadState {} /// The reading failed unrecoverable for some reason. -class BleReadFailure extends BleReadState {} +class BleReadFailure extends BleReadState { + /// The reading failed unrecoverable for some reason. + BleReadFailure(this.reason); + + /// The reason why the read failed + final String reason; +} + +/// Data has been successfully read and returned multiple measurements +class BleReadMultiple extends BleReadState { + /// Indicate a successful reading of a ble characteristic with multiple measurements. + BleReadMultiple(this.data); + + /// List of measurements decoded from the device. + final List data; +} /// Data has been successfully read. class BleReadSuccess extends BleReadState { diff --git a/app/lib/features/bluetooth/logic/bluetooth_cubit.dart b/app/lib/features/bluetooth/logic/bluetooth_cubit.dart index 23bbc2fd..6f242a62 100644 --- a/app/lib/features/bluetooth/logic/bluetooth_cubit.dart +++ b/app/lib/features/bluetooth/logic/bluetooth_cubit.dart @@ -1,69 +1,58 @@ import 'dart:async'; -import 'dart:io'; -import 'package:blood_pressure_app/features/bluetooth/logic/flutter_blue_plus_mockable.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart'; +import 'package:blood_pressure_app/logging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; part 'bluetooth_state.dart'; /// Availability of the devices bluetooth adapter. /// -/// The only state that allows using the adapter is [BluetoothReady]. -class BluetoothCubit extends Cubit { +/// The only state that allows using the adapter is [BluetoothStateReady]. +class BluetoothCubit extends Cubit with TypeLogger { /// Create a cubit connecting to the bluetooth module for availability. /// - /// [flutterBluePlus] may be provided for testing purposes. - BluetoothCubit({ - FlutterBluePlusMockable? flutterBluePlus - }): _flutterBluePlus = flutterBluePlus ?? FlutterBluePlusMockable(), - super(BluetoothInitial()) { - _adapterStateStateSubscription = _flutterBluePlus.adapterState.listen(_onAdapterStateChanged); - } - - final FlutterBluePlusMockable _flutterBluePlus; + /// [manager] manager to check availabilty of. + BluetoothCubit({ required this.manager }): + super(BluetoothState.fromAdapterState(manager.lastKnownAdapterState)) { + _adapterStateSubscription = manager.stateStream.listen(_onAdapterStateChanged); - BluetoothAdapterState _adapterState = BluetoothAdapterState.unknown; + _lastKnownState = manager.lastKnownAdapterState; + logger.finer('lastKnownState: $_lastKnownState'); + } - late StreamSubscription _adapterStateStateSubscription; + /// Bluetooth manager + late final BluetoothManager manager; + late BluetoothAdapterState _lastKnownState; + late StreamSubscription _adapterStateSubscription; @override Future close() async { - await _adapterStateStateSubscription.cancel(); + await _adapterStateSubscription.cancel(); await super.close(); } void _onAdapterStateChanged(BluetoothAdapterState state) async { - _adapterState = state; - switch (_adapterState) { - case BluetoothAdapterState.unavailable: - emit(BluetoothUnfeasible()); - case BluetoothAdapterState.unauthorized: - // Bluetooth permissions should always be granted on normal android - // devices. Users on non-standard android devices will know how to - // enable them. If this is not the case there will be bug reports. - emit(BluetoothUnauthorized()); - case BluetoothAdapterState.on: - emit(BluetoothReady()); - case BluetoothAdapterState.off: - case BluetoothAdapterState.turningOff: - case BluetoothAdapterState.turningOn: - emit(BluetoothDisabled()); - case BluetoothAdapterState.unknown: - emit(BluetoothInitial()); + if (state == BluetoothAdapterState.unauthorized) { + final success = await manager.enable(); + if (success != true) { + logger.warning('Enabling bluetooth failed or not needed on this platform'); + } } + + _lastKnownState = state; + logger.finer('_onAdapterStateChanged(state: $state)'); + emit(BluetoothState.fromAdapterState(state)); } /// Request to enable bluetooth on the device - Future enableBluetooth() async { - assert(state is BluetoothDisabled, 'No need to enable bluetooth when ' + Future enableBluetooth() async { + assert(state is BluetoothStateDisabled, 'No need to enable bluetooth when ' 'already enabled or not known to be disabled.'); try { - if (!Platform.isAndroid) return false; - await _flutterBluePlus.turnOn(); - return true; - } on FlutterBluePlusException { + return manager.enable(); + } on Exception { return false; } } @@ -74,7 +63,5 @@ class BluetoothCubit extends Cubit { /// the app won't get notified about permission changes and such. In those /// instances the user should have the option to manually recheck the state to /// avoid getting stuck on a unauthorized state. - Future forceRefresh() async { - _onAdapterStateChanged(_flutterBluePlus.adapterStateNow); - } + void forceRefresh() => _onAdapterStateChanged(_lastKnownState); } diff --git a/app/lib/features/bluetooth/logic/bluetooth_state.dart b/app/lib/features/bluetooth/logic/bluetooth_state.dart index 7a8d52f7..73e7b9d0 100644 --- a/app/lib/features/bluetooth/logic/bluetooth_state.dart +++ b/app/lib/features/bluetooth/logic/bluetooth_state.dart @@ -2,25 +2,40 @@ part of 'bluetooth_cubit.dart'; /// State of the devices bluetooth module. @immutable -sealed class BluetoothState {} +sealed class BluetoothState { + /// Initialize state of the devices bluetooth module. + const BluetoothState(); + + /// Returns the [BluetoothState] instance for given [BluetoothAdapterState] enum state + factory BluetoothState.fromAdapterState(BluetoothAdapterState state) => switch(state) { + // Bluetooth permissions should always be granted on normal android + // devices. Users on non-standard android devices will know how to + // enable them. If this is not the case there will be bug reports. + BluetoothAdapterState.unauthorized => BluetoothStateUnauthorized(), + BluetoothAdapterState.unfeasible => BluetoothStateUnfeasible(), + BluetoothAdapterState.disabled => BluetoothStateDisabled(), + BluetoothAdapterState.initial => BluetoothStateInitial(), + BluetoothAdapterState.ready => BluetoothStateReady(), + }; +} /// No information on whether bluetooth is available. /// /// Users may show a loading indication but can not assume bluetooth is /// available. -class BluetoothInitial extends BluetoothState {} +class BluetoothStateInitial extends BluetoothState {} /// There is no way bluetooth will work (e.g. no sensor). /// /// Options relating to bluetooth should not be shown. -class BluetoothUnfeasible extends BluetoothState {} +class BluetoothStateUnfeasible extends BluetoothState {} /// There is a bluetooth sensor but the app has no permission. -class BluetoothUnauthorized extends BluetoothState {} +class BluetoothStateUnauthorized extends BluetoothState {} /// The device has Bluetooth and the app has permissions, but it is disabled in /// the device settings. -class BluetoothDisabled extends BluetoothState {} +class BluetoothStateDisabled extends BluetoothState {} /// Bluetooth is ready for use by the app. -class BluetoothReady extends BluetoothState {} +class BluetoothStateReady extends BluetoothState {} diff --git a/app/lib/features/bluetooth/logic/characteristics/ble_measurement_data.dart b/app/lib/features/bluetooth/logic/characteristics/ble_measurement_data.dart index 3da3f4e9..7927473b 100644 --- a/app/lib/features/bluetooth/logic/characteristics/ble_measurement_data.dart +++ b/app/lib/features/bluetooth/logic/characteristics/ble_measurement_data.dart @@ -1,30 +1,49 @@ -import 'package:blood_pressure_app/logging.dart'; +import 'dart:typed_data'; -import 'ble_date_time.dart'; -import 'ble_measurement_status.dart'; -import 'decoding_util.dart'; +import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_date_time.dart'; +import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_status.dart'; +import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/decoding_util.dart'; +import 'package:blood_pressure_app/logging.dart'; +import 'package:health_data_store/health_data_store.dart'; +/// Result of a single bp measurement as by ble spec. +/// /// https://developer.nordicsemi.com/nRF51_SDK/nRF51_SDK_v4.x.x/doc/html/structble__bps__meas__s.html /// https://github.com/NordicSemiconductor/Kotlin-BLE-Library/blob/6b565e59de21dfa53ef80ff8351ac4a4550e8d58/profile/src/main/java/no/nordicsemi/android/kotlin/ble/profile/bps/BloodPressureMeasurementParser.kt class BleMeasurementData { + /// Initialize result of a single bp measurement. BleMeasurementData({ required this.systolic, required this.diastolic, required this.meanArterialPressure, required this.isMMHG, - required this.pulse, - required this.userID, - required this.status, - required this.timestamp, + this.pulse, + this.userID, + this.status, + this.timestamp, }); - static BleMeasurementData? decode(List data, int offset) { + /// Return BleMeasurementData as a BloodPressureRecord + BloodPressureRecord asBloodPressureRecord() => + BloodPressureRecord( + time: timestamp ?? DateTime.now(), + sys: isMMHG + ? Pressure.mmHg(systolic.toInt()) + : Pressure.kPa(systolic), + dia: isMMHG + ? Pressure.mmHg(diastolic.toInt()) + : Pressure.kPa(diastolic), + pul: pulse?.toInt(), + ); + + /// Decode bytes read from the characteristic into a [BleMeasurementData] + static BleMeasurementData? decode(Uint8List data, int offset) { // https://github.com/NordicSemiconductor/Kotlin-BLE-Library/blob/6b565e59de21dfa53ef80ff8351ac4a4550e8d58/profile/src/main/java/no/nordicsemi/android/kotlin/ble/profile/bps/BloodPressureMeasurementParser.kt // Reading specific bits: `(byte & (1 << bitIdx))` if (data.length < 7) { - Log.trace('BleMeasurementData decodeMeasurement: Not enough data, $data has less than 7 bytes.'); + log.warning('BleMeasurementData decodeMeasurement: Not enough data, $data has less than 7 bytes.'); return null; } @@ -45,7 +64,7 @@ class BleMeasurementData { + (userIdPresent ? 1 : 0) + (measurementStatusPresent ? 2 : 0) )) { - Log.trace("BleMeasurementData decodeMeasurement: Flags don't match, $data has less bytes than expected."); + log.warning("BleMeasurementData decodeMeasurement: Flags don't match, $data has less bytes than expected."); return null; } @@ -57,7 +76,7 @@ class BleMeasurementData { offset += 2; if (systolic == null || diastolic == null || meanArterialPressure == null) { - Log.trace('BleMeasurementData decodeMeasurement: Unable to decode required values sys, dia, and meanArterialPressure, $data.'); + log.warning('BleMeasurementData decodeMeasurement: Unable to decode required values sys, dia, and meanArterialPressure, $data.'); return null; } @@ -96,13 +115,21 @@ class BleMeasurementData { ); } + /// Systolic pressure final double systolic; + /// Diatolic pressure final double diastolic; + /// Mean arterial pressure final double meanArterialPressure; + /// True if pressure values are in mmHg, False if in kPa final bool isMMHG; // mmhg or kpa + /// Pulse rate (of heart) final double? pulse; + /// User id final int? userID; + /// [BleMeasurementStatus] status final BleMeasurementStatus? status; + /// Timestamp of measurement final DateTime? timestamp; @override diff --git a/app/lib/features/bluetooth/logic/device_scan_cubit.dart b/app/lib/features/bluetooth/logic/device_scan_cubit.dart index 38f068e5..e92d8096 100644 --- a/app/lib/features/bluetooth/logic/device_scan_cubit.dart +++ b/app/lib/features/bluetooth/logic/device_scan_cubit.dart @@ -1,13 +1,12 @@ import 'dart:async'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart'; import 'package:blood_pressure_app/features/bluetooth/logic/bluetooth_cubit.dart'; -import 'package:blood_pressure_app/features/bluetooth/logic/flutter_blue_plus_mockable.dart'; import 'package:blood_pressure_app/logging.dart'; import 'package:blood_pressure_app/model/storage/settings_store.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; part 'device_scan_state.dart'; @@ -18,69 +17,69 @@ part 'device_scan_state.dart'; /// /// A device counts as recognized, when the user connected with it at least /// once. Recognized devices connect automatically. -class DeviceScanCubit extends Cubit { +class DeviceScanCubit extends Cubit with TypeLogger { /// Search for bluetooth devices that match the criteria or are known /// ([Settings.knownBleDev]). DeviceScanCubit({ - FlutterBluePlusMockable? flutterBluePlus, + required BluetoothManager manager, required this.service, required this.settings, - }) : _flutterBluePlus = flutterBluePlus ?? FlutterBluePlusMockable(), - super(DeviceListLoading()) { - assert(!_flutterBluePlus.isScanningNow); + }): super(DeviceListLoading()) { + _manager = manager; _startScanning(); } /// Storage for known devices. - final Settings settings; + late final Settings settings; /// Service required from bluetooth devices. - final Guid service; + late final String service; - final FlutterBluePlusMockable _flutterBluePlus; - - late StreamSubscription> _scanResultsSubscription; + late final BluetoothManager _manager; @override Future close() async { - await _scanResultsSubscription.cancel(); - try { - await _flutterBluePlus.stopScan(); - } catch (e) { - Log.err('Failed to stop scanning', [e]); - return; + final stopped = await _stopScanning(); + if (stopped) { + await super.close(); } - await super.close(); } Future _startScanning() async { - _scanResultsSubscription = _flutterBluePlus.scanResults - .listen(_onScanResult, - onError: _onScanError, - ); try { - await _flutterBluePlus.startScan( - // no timeout, the user knows best how long scanning is needed - withServices: [ service ], - // Not all devices are found using this configuration (https://pub.dev/packages/flutter_blue_plus#scanning-does-not-find-my-device). - ); + // no timeout, the user knows best how long scanning is needed + // Not all devices are found using this configuration (https://pub.dev/packages/flutter_blue_plus#scanning-does-not-find-my-device). + await _manager.discovery.start(service, _onScanResult); } catch (e) { _onScanError(e); } } - void _onScanResult(List devices) { - Log.trace('_onScanResult devices: $devices'); + Future _stopScanning() async { + try { + await _manager.discovery.stop(); + } catch (err) { + logger.severe('Failed to stop scanning', err); + return false; + } + return true; + } + + void _onScanResult(List devices) { + logger.finer('_onScanResult devices: $devices'); - assert(devices.isEmpty || _flutterBluePlus.isScanningNow); // No need to check whether the devices really support the searched // characteristic as users have to select their device anyways. - if(state is DeviceSelected) return; + if(state is DeviceSelected) { + return; + } + final preferred = devices.firstWhereOrNull((dev) => - settings.knownBleDev.contains(dev.device.platformName)); + settings.knownBleDev.contains(dev.name)); + if (preferred != null) { - _flutterBluePlus.stopScan() - .then((_) => emit(DeviceSelected(preferred.device))); + _stopScanning() + .then((_) => emit(DeviceSelected(preferred))); } else if (devices.isEmpty) { emit(DeviceListLoading()); } else if (devices.length == 1) { @@ -91,22 +90,23 @@ class DeviceScanCubit extends Cubit { } void _onScanError(Object error) { - Log.err('Starting device scan failed', [ error ]); + logger.severe('Error during device discovery', error); } /// Mark a new device as known and switch to selected device state asap. Future acceptDevice(BluetoothDevice device) async { assert(state is! DeviceSelected); try { - await _flutterBluePlus.stopScan(); + await _stopScanning(); } catch (e) { _onScanError(e); return; } - assert(!_flutterBluePlus.isScanningNow); + + assert(!_manager.discovery.isDiscovering); emit(DeviceSelected(device)); final List list = settings.knownBleDev.toList(); - list.add(device.platformName); + list.add(device.name); settings.knownBleDev = list; } } diff --git a/app/lib/features/bluetooth/logic/device_scan_state.dart b/app/lib/features/bluetooth/logic/device_scan_state.dart index 8fcab5d4..601302f4 100644 --- a/app/lib/features/bluetooth/logic/device_scan_state.dart +++ b/app/lib/features/bluetooth/logic/device_scan_state.dart @@ -22,7 +22,7 @@ class DeviceListAvailable extends DeviceScanState { DeviceListAvailable(this.devices); /// All found devices. - final List devices; + final List devices; } /// One unrecognized device has been found. @@ -34,5 +34,5 @@ class SingleDeviceAvailable extends DeviceScanState { SingleDeviceAvailable(this.device); /// The only found device. - final ScanResult device; + final BluetoothDevice device; } diff --git a/app/lib/features/bluetooth/ui/closed_bluetooth_input.dart b/app/lib/features/bluetooth/ui/closed_bluetooth_input.dart index ad588bd7..f7e841cf 100644 --- a/app/lib/features/bluetooth/ui/closed_bluetooth_input.dart +++ b/app/lib/features/bluetooth/ui/closed_bluetooth_input.dart @@ -1,11 +1,12 @@ import 'package:app_settings/app_settings.dart'; import 'package:blood_pressure_app/features/bluetooth/logic/bluetooth_cubit.dart'; +import 'package:blood_pressure_app/logging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; /// A closed ble input that shows the adapter state and allows to start the input. -class ClosedBluetoothInput extends StatelessWidget { +class ClosedBluetoothInput extends StatelessWidget with TypeLogger { /// Show adapter state and allow starting inputs const ClosedBluetoothInput({super.key, required this.bluetoothCubit, @@ -43,31 +44,34 @@ class ClosedBluetoothInput extends StatelessWidget { final localizations = AppLocalizations.of(context)!; return BlocBuilder( bloc: bluetoothCubit, - builder: (context, BluetoothState state) => switch(state) { - BluetoothInitial() => const SizedBox.shrink(), - BluetoothUnfeasible() => const SizedBox.shrink(), - BluetoothUnauthorized() => _buildTile( - text: localizations.errBleNoPerms, - icon: Icons.bluetooth_disabled, - onTap: () async { - await AppSettings.openAppSettings(); - await bluetoothCubit.forceRefresh(); - }, - ), - BluetoothDisabled() => _buildTile( - text: localizations.bluetoothDisabled, - icon: Icons.bluetooth_disabled, - onTap: () async { - final bluetoothOn = await bluetoothCubit.enableBluetooth(); - if (!bluetoothOn) await AppSettings.openAppSettings(type: AppSettingsType.bluetooth); - await bluetoothCubit.forceRefresh(); - }, - ), - BluetoothReady() => _buildTile( - text: localizations.bluetoothInput, - icon: Icons.bluetooth, - onTap: onStarted, - ), + builder: (context, BluetoothState state) { + logger.finer('Called with state: $state'); + return switch(state) { + BluetoothStateInitial() => const SizedBox.shrink(), + BluetoothStateUnfeasible() => const SizedBox.shrink(), + BluetoothStateUnauthorized() => _buildTile( + text: localizations.errBleNoPerms, + icon: Icons.bluetooth_disabled, + onTap: () async { + await AppSettings.openAppSettings(); + bluetoothCubit.forceRefresh(); + }, + ), + BluetoothStateDisabled() => _buildTile( + text: localizations.bluetoothDisabled, + icon: Icons.bluetooth_disabled, + onTap: () async { + final bluetoothOn = await bluetoothCubit.enableBluetooth(); + if (bluetoothOn == false) await AppSettings.openAppSettings(type: AppSettingsType.bluetooth); + bluetoothCubit.forceRefresh(); + }, + ), + BluetoothStateReady() => _buildTile( + text: localizations.bluetoothInput, + icon: Icons.bluetooth, + onTap: onStarted, + ) + }; }, ); } diff --git a/app/lib/features/bluetooth/ui/device_selection.dart b/app/lib/features/bluetooth/ui/device_selection.dart index c7e35fdf..8701f985 100644 --- a/app/lib/features/bluetooth/ui/device_selection.dart +++ b/app/lib/features/bluetooth/ui/device_selection.dart @@ -1,6 +1,6 @@ +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart'; import 'package:blood_pressure_app/features/bluetooth/ui/input_card.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; /// A pairing dialoge with a single bluetooth device. @@ -12,18 +12,18 @@ class DeviceSelection extends StatelessWidget { }); /// The name of the device trying to connect. - final List scanResults; + final List scanResults; /// Called when the user accepts the device. final void Function(BluetoothDevice) onAccepted; - Widget _buildDeviceTile(BuildContext context, ScanResult dev) => ListTile( - title: Text(dev.device.platformName), + Widget _buildDeviceTile(BuildContext context, BluetoothDevice dev) => ListTile( + title: Text(dev.name), trailing: FilledButton( - onPressed: () => onAccepted(dev.device), + onPressed: () => onAccepted(dev), child: Text(AppLocalizations.of(context)!.connect), ), - onTap: () => onAccepted(dev.device), + onTap: () => onAccepted(dev), ); @override diff --git a/app/lib/features/bluetooth/ui/measurement_failure.dart b/app/lib/features/bluetooth/ui/measurement_failure.dart index f0385146..8650137d 100644 --- a/app/lib/features/bluetooth/ui/measurement_failure.dart +++ b/app/lib/features/bluetooth/ui/measurement_failure.dart @@ -1,34 +1,39 @@ import 'package:blood_pressure_app/features/bluetooth/ui/input_card.dart'; +import 'package:blood_pressure_app/logging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; /// Indication of a failure while taking a bluetooth measurement. -class MeasurementFailure extends StatelessWidget { +class MeasurementFailure extends StatelessWidget with TypeLogger { /// Indicate a failure while taking a bluetooth measurement. - const MeasurementFailure({super.key, required this.onTap}); + const MeasurementFailure({super.key, required this.onTap, required this.reason}); /// Called when the user requests closing. final void Function() onTap; - + /// Likely reason why the measurement failed + final String reason; + @override - Widget build(BuildContext context) => GestureDetector( - onTap: onTap, - child: InputCard( - onClosed: onTap, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, color: Colors.red), - const SizedBox(height: 8,), - Text(AppLocalizations.of(context)!.errMeasurementRead), - const SizedBox(height: 4,), - Text(AppLocalizations.of(context)!.tapToClose), - const SizedBox(height: 8,), - ], + Widget build(BuildContext context) { + logger.warning('MeasurementFailure reason: $reason'); + return GestureDetector( + onTap: onTap, + child: InputCard( + onClosed: onTap, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Colors.red), + const SizedBox(height: 8,), + Text(AppLocalizations.of(context)!.errMeasurementRead), + const SizedBox(height: 4,), + Text(AppLocalizations.of(context)!.tapToClose), + const SizedBox(height: 8,), + ], + ), ), ), - ), - ); - + ); + } } diff --git a/app/lib/features/bluetooth/ui/measurement_multiple.dart b/app/lib/features/bluetooth/ui/measurement_multiple.dart new file mode 100644 index 00000000..ffdf5b67 --- /dev/null +++ b/app/lib/features/bluetooth/ui/measurement_multiple.dart @@ -0,0 +1,84 @@ +import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_data.dart'; +import 'package:blood_pressure_app/features/bluetooth/ui/input_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +/// Indication of a successful bluetooth read that returned multiple measurements. +/// +/// TODO: Some devices can store up to 100 measurements which could cause a very long ListView. Maybe optimize UI for that? +class MeasurementMultiple extends StatelessWidget { + /// Indicate a successful read while taking a bluetooth measurement. + const MeasurementMultiple({super.key, + required this.onClosed, + required this.onSelect, + required this.measurements, + }); + + /// All measurements decoded from bluetooth. + final List measurements; + + /// Called when the user requests closing. + final void Function() onClosed; + + /// Called when user selects a measurement + final void Function(BleMeasurementData data) onSelect; + + Widget _buildMeasurementTile(BuildContext context, int index, BleMeasurementData data) { + final localizations = AppLocalizations.of(context)!; + return ListTile( + title: Text(data.timestamp?.toIso8601String() ?? localizations.measurementIndex(index + 1)), + subtitle: Text(() { + String str = ''; + if (data.userID != null) { + str += '${localizations.userID}: ${data.userID}, '; + } + str += '${localizations.bloodPressure}: ${data.systolic.round()}/${data.diastolic.round()}'; + if (data.pulse != null) { + str += ', ${localizations.pulLong}: ${data.pulse?.round()}'; + } + return str; + }()), + trailing: FilledButton( + onPressed: () => onSelect(data), + child: Text(localizations.select), + ), + onTap: () => onSelect(data), + ); + } + + @override + Widget build(BuildContext context) { + // Sort measurements so latest measurement is on top of the list + measurements.sort((a, b) { + final aTimestamp = a.timestamp?.microsecondsSinceEpoch; + final bTimestamp = b.timestamp?.microsecondsSinceEpoch; + + if (aTimestamp == bTimestamp) { + // don't sort when a & b are equal (either both null or equal value) + return 0; + } + + if (aTimestamp == null) { + return 1; + } + + if (bTimestamp == null) { + return -1; + } + + return aTimestamp > bTimestamp ? -1 : 1; + }); + + return InputCard( + onClosed: onClosed, + title: Text(AppLocalizations.of(context)!.selectMeasurementTitle), + child: ListView( + shrinkWrap: true, + children: [ + for (final (index, data) in measurements.indexed) + _buildMeasurementTile(context, index, data), + ] + ), + ); + } +} diff --git a/app/lib/features/bluetooth/ui/measurement_success.dart b/app/lib/features/bluetooth/ui/measurement_success.dart index a5a6905d..cca3cbca 100644 --- a/app/lib/features/bluetooth/ui/measurement_success.dart +++ b/app/lib/features/bluetooth/ui/measurement_success.dart @@ -24,7 +24,7 @@ class MeasurementSuccess extends StatelessWidget { onClosed: onTap, child: Center( child: ListTileTheme( - data: ListTileThemeData( + data: const ListTileThemeData( iconColor: Colors.orange, ), child: Column( @@ -47,32 +47,32 @@ class MeasurementSuccess extends StatelessWidget { if (data.status?.bodyMovementDetected ?? false) ListTile( title: Text(AppLocalizations.of(context)!.bodyMovementDetected), - leading: Icon(Icons.directions_walk), + leading: const Icon(Icons.directions_walk), ), if (data.status?.cuffTooLose ?? false) ListTile( title: Text(AppLocalizations.of(context)!.cuffTooLoose), - leading: Icon(Icons.space_bar), + leading: const Icon(Icons.space_bar), ), if (data.status?.improperMeasurementPosition ?? false) ListTile( title: Text(AppLocalizations.of(context)!.improperMeasurementPosition), - leading: Icon(Icons.emoji_people), + leading: const Icon(Icons.emoji_people), ), if (data.status?.irregularPulseDetected ?? false) ListTile( title: Text(AppLocalizations.of(context)!.irregularPulseDetected), - leading: Icon(Icons.heart_broken), + leading: const Icon(Icons.heart_broken), ), if (data.status?.pulseRateExceedsUpperLimit ?? false) ListTile( title: Text(AppLocalizations.of(context)!.pulseRateExceedsUpperLimit), - leading: Icon(Icons.monitor_heart), + leading: const Icon(Icons.monitor_heart), ), if (data.status?.pulseRateIsLessThenLowerLimit ?? false) ListTile( title: Text(AppLocalizations.of(context)!.pulseRateLessThanLowerLimit), - leading: Icon(Icons.monitor_heart), + leading: const Icon(Icons.monitor_heart), ), ], ), diff --git a/app/lib/features/input/add_measurement_dialoge.dart b/app/lib/features/input/add_measurement_dialoge.dart index d67c9700..2841e21e 100644 --- a/app/lib/features/input/add_measurement_dialoge.dart +++ b/app/lib/features/input/add_measurement_dialoge.dart @@ -1,6 +1,9 @@ import 'dart:async'; +import 'dart:io'; import 'package:blood_pressure_app/components/fullscreen_dialoge.dart'; +import 'package:blood_pressure_app/config.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart'; import 'package:blood_pressure_app/features/bluetooth/bluetooth_input.dart'; import 'package:blood_pressure_app/features/input/add_bodyweight_dialoge.dart'; import 'package:blood_pressure_app/features/input/forms/date_time_form.dart'; @@ -252,6 +255,13 @@ class _AddEntryDialogeState extends State { children: [ if (settings.bleInput) BluetoothInput( + manager: BluetoothManager.create( + isTestingEnvironment + ? BluetoothBackend.mock + : Platform.isAndroid + ? BluetoothBackend.flutterBluePlus + : BluetoothBackend.bluetoothLowEnergy + ), onMeasurement: (record) => setState( () => _loadFields((record, Note(time: record.time, note: noteController.text, color: color?.value), [])), ), @@ -322,7 +332,7 @@ class _AddEntryDialogeState extends State { ), ), InputDecorator( - decoration: InputDecoration( + decoration: const InputDecoration( contentPadding: EdgeInsets.zero, ), child: ColorSelectionListTile( diff --git a/app/lib/features/statistics/clock_bp_graph.dart b/app/lib/features/statistics/clock_bp_graph.dart index 3e664fec..e8f06e4d 100644 --- a/app/lib/features/statistics/clock_bp_graph.dart +++ b/app/lib/features/statistics/clock_bp_graph.dart @@ -24,7 +24,7 @@ class ClockBpGraph extends StatelessWidget { return SizedBox.square( dimension: MediaQuery.of(context).size.width, child: Padding( - padding: EdgeInsets.all(24.0), + padding: const EdgeInsets.all(24.0), child: CustomPaint( painter: _RadarChartPainter( brightness: Theme.of(context).brightness, diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 166decf7..2643163e 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -523,6 +523,22 @@ "@weight": {}, "enterWeight": "Enter weight", "@enterWeight": {}, + "selectMeasurementTitle": "Select the measurement to use", + "@selectMeasurementTitle": {}, + "measurementIndex": "Measurement #{number}", + "@measurementIndex": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "select": "Select", + "@select": { + "description": "Used when f.e. selecting a single measurement when the bluetooth device returned multiple" + }, + "bloodPressure": "Blood pressure", + "@bloodPressure": {}, "preferredWeightUnit": "Preferred weight unit", "@preferredWeightUnit": { "description": "Setting for the unit the app will use for displaying weight" diff --git a/app/lib/logging.dart b/app/lib/logging.dart index e4dbaaee..12f02ed7 100644 --- a/app/lib/logging.dart +++ b/app/lib/logging.dart @@ -1,25 +1,40 @@ -import 'dart:io'; - +import 'package:blood_pressure_app/config.dart'; import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; + +/// Logger instance +final log = Logger('BloodPressureMonitor'); + +/// Mixin to provide logging instances within classes +/// +/// Usage: extend your class with this mixin by adding 'with TypeLogger' +/// to be able to call the logger property anywhere in your class. +mixin TypeLogger { + /// log interface, returns a [Logger] instance from https://pub.dev/packages/logging + Logger get logger => Logger('BPM[${Log.withoutTypes('$runtimeType')}]'); +} /// Simple class for manually logging in debug builds. +/// +/// Also contains some logging configuration logic class Log { - /// Log an error with stack trace in debug builds. - static void err(String message, [List? dumps]) { - if (kDebugMode && !(Platform.environment['FLUTTER_TEST'] == 'true')) { - debugPrint('-----------------------------'); - debugPrint('ERROR $message:'); - debugPrintStack(); - for (final e in dumps ?? []) { - debugPrint(e.toString()); - } - } + /// Whether logging is enabled + static final enabled = kDebugMode && !isTestingEnvironment; + + /// Format a log record + static String format(LogRecord record) { + final loggerName = record.loggerName == 'BloodPressureMonitor' ? null : record.loggerName; + return '${record.level.name}: ${record.time}: ${loggerName != null ? '$loggerName: ' : ''}${record.message}'; } - /// Log a message in debug more - static void trace(String message) { - if (kDebugMode && !(Platform.environment['FLUTTER_TEST'] == 'true')) { - debugPrint('TRACE: $message'); + /// Strip types from definition, i.e. MyClass -> MyClass + static String withoutTypes(String type) => type.replaceAll(RegExp(r'<[^>]+>'), ''); + + /// Register the apps logging config with [Logger]. + static void setup() { + if (Log.enabled) { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) => debugPrint(Log.format(record))); } } } diff --git a/app/lib/main.dart b/app/lib/main.dart index d3ca92e3..e36d6ca8 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,5 +1,9 @@ import 'package:blood_pressure_app/app.dart'; +import 'package:blood_pressure_app/logging.dart'; import 'package:flutter/material.dart'; /// Run the [App]. -void main() => runApp(const App()); +void main() { + Log.setup(); + runApp(const App()); +} diff --git a/app/lib/model/blood_pressure/medicine/intake_history.dart b/app/lib/model/blood_pressure/medicine/intake_history.dart index d58a9de9..0962505b 100644 --- a/app/lib/model/blood_pressure/medicine/intake_history.dart +++ b/app/lib/model/blood_pressure/medicine/intake_history.dart @@ -28,7 +28,7 @@ class IntakeHistory extends ChangeNotifier { try { return OldMedicineIntake.deserialize(e, availableMedicines); } on FormatException { - Log.err('OldMedicineIntake deserialization problem: "$e"'); + log.severe('OldMedicineIntake deserialization problem: "$e"'); return null; } }) diff --git a/app/lib/model/iso_lang_names.dart b/app/lib/model/iso_lang_names.dart index 6568f254..04d0d742 100644 --- a/app/lib/model/iso_lang_names.dart +++ b/app/lib/model/iso_lang_names.dart @@ -9,6 +9,7 @@ String getDisplayLanguage(Locale l) => switch(l.toLanguageTag()) { 'fr' => 'Française', 'it' => 'Italiano', 'nb' => 'Norsk bokmål', + 'nl' => 'Nederlands', 'pt' => 'Português', 'pt-BR' => 'Português (Brasil)', 'ru' => 'Русский', @@ -18,7 +19,6 @@ String getDisplayLanguage(Locale l) => switch(l.toLanguageTag()) { 'zh-Hant' => '中文(繁體)', 'hu' => 'Magyar (Magyarország)', 'et' => 'Eesti (Eesti)', - 'nl' => 'Nederlands', // Websites with names for expanding when new languages get added: // - https://chronoplexsoftware.com/localisation/help/languagecodes.htm // - https://localizely.com/locale-code/zh-Hans/ diff --git a/app/lib/model/storage/settings_store.dart b/app/lib/model/storage/settings_store.dart index b92ed5ff..23381018 100644 --- a/app/lib/model/storage/settings_store.dart +++ b/app/lib/model/storage/settings_store.dart @@ -1,13 +1,12 @@ import 'dart:collection'; import 'dart:convert'; -import 'dart:io'; +import 'package:blood_pressure_app/config.dart'; import 'package:blood_pressure_app/model/blood_pressure/medicine/medicine.dart'; import 'package:blood_pressure_app/model/blood_pressure/pressure_unit.dart'; import 'package:blood_pressure_app/model/horizontal_graph_line.dart'; import 'package:blood_pressure_app/model/storage/convert_util.dart'; import 'package:blood_pressure_app/model/weight_unit.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// Stores settings that are directly controllable by the user through the @@ -412,9 +411,7 @@ class Settings extends ChangeNotifier { bool _bleInput = true; /// Whether to show bluetooth input on add measurement page. - bool get bleInput => (Platform.isAndroid || Platform.isIOS || Platform.isMacOS - || (kDebugMode && Platform.environment['FLUTTER_TEST'] == 'true')) - && _bleInput; + bool get bleInput => isPlatformSupportedBluetooth && _bleInput; set bleInput(bool value) { _bleInput = value; notifyListeners(); diff --git a/app/lib/screens/home_screen.dart b/app/lib/screens/home_screen.dart index c264d2cf..3a88b968 100644 --- a/app/lib/screens/home_screen.dart +++ b/app/lib/screens/home_screen.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:blood_pressure_app/config.dart'; import 'package:blood_pressure_app/data_util/entry_context.dart'; import 'package:blood_pressure_app/data_util/full_entry_builder.dart'; import 'package:blood_pressure_app/data_util/interval_picker.dart'; @@ -66,7 +69,10 @@ class AppHome extends StatelessWidget { _appStart++; } - if (orientation == Orientation.landscape) return _buildValueGraph(context); + if (showValueGraphAsHomeScreenInLandscapeMode && orientation == Orientation.landscape) { + return _buildValueGraph(context); + } + return DefaultTabController( length: 2, child: Scaffold( diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index a348d92f..b72c8a04 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:archive/archive_io.dart'; import 'package:blood_pressure_app/components/input_dialoge.dart'; +import 'package:blood_pressure_app/config.dart'; import 'package:blood_pressure_app/data_util/consistent_future_builder.dart'; import 'package:blood_pressure_app/features/settings/delete_data_screen.dart'; import 'package:blood_pressure_app/features/settings/enter_timeformat_dialoge.dart'; @@ -161,14 +162,10 @@ class SettingsPage extends StatelessWidget { ), SwitchListTile( value: settings.bleInput, - onChanged: (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) - ? (value) { settings.bleInput = value; } - : null, + onChanged: isPlatformSupportedBluetooth ? (value) { settings.bleInput = value; } : null, secondary: const Icon(Icons.bluetooth), title: Text(localizations.bluetoothInput), - subtitle: (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) - ? null - : Text(localizations.errFeatureNotSupported), + subtitle: isPlatformSupportedBluetooth ? null : Text(localizations.errFeatureNotSupported), ), SwitchListTile( value: settings.allowManualTimeInput, @@ -381,7 +378,7 @@ class SettingsPage extends StatelessWidget { loader = await FileSettingsLoader.load(dir); } on FormatException catch (e, stack) { messenger.showSnackBar(SnackBar(content: Text(localizations.invalidZip))); - Log.err('invalid zip', [e, stack]); + log.severe('invalid zip', e, stack); return; } } else { diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift index 77dda8d5..e2408564 100644 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,13 +5,15 @@ import FlutterMacOS import Foundation +import bluetooth_low_energy_darwin import flutter_blue_plus import package_info_plus import shared_preferences_foundation -import sqflite +import sqflite_darwin import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + BluetoothLowEnergyDarwinPlugin.register(with: registry.registrar(forPlugin: "BluetoothLowEnergyDarwinPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/app/pubspec.lock b/app/pubspec.lock index 11a67142..39681e66 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -42,10 +42,10 @@ packages: dependency: transitive description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" async: dependency: transitive description: @@ -86,6 +86,62 @@ packages: url: "https://pub.dev" source: hosted version: "9.1.7" + bluetooth_low_energy: + dependency: "direct main" + description: + name: bluetooth_low_energy + sha256: "057c146c3d5378f6a048b6a792cee7029bb185c7b3392dfb1605228325a5e336" + url: "https://pub.dev" + source: hosted + version: "6.0.2" + bluetooth_low_energy_android: + dependency: transitive + description: + name: bluetooth_low_energy_android + sha256: f8cbef16b980f96c09df5d1d46b61be9f05683866151440e9987796607a4e7d8 + url: "https://pub.dev" + source: hosted + version: "6.0.3" + bluetooth_low_energy_darwin: + dependency: transitive + description: + name: bluetooth_low_energy_darwin + sha256: "849ba53f7d34845ad7491cd9cdb3784301aa54fe682c91cab804ed55cfd259d5" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + bluetooth_low_energy_linux: + dependency: transitive + description: + name: bluetooth_low_energy_linux + sha256: "4d1aaaede517f95320dcf9ad271091ab42c4ad8ba5bfa0e822744d850dcf0048" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + bluetooth_low_energy_platform_interface: + dependency: transitive + description: + name: bluetooth_low_energy_platform_interface + sha256: bc2e8d97c141653e5747bcb3cdc9fe956541b6ecc6e5f158b99a2f3abc2d946a + url: "https://pub.dev" + source: hosted + version: "6.0.0" + bluetooth_low_energy_windows: + dependency: transitive + description: + name: bluetooth_low_energy_windows + sha256: "4904530cb3e7e1dd7a66919b4c926f8a03ed9924c3c2ce068aef7e0e10ced555" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + bluez: + dependency: transitive + description: + name: bluez + sha256: "203a1924e818a9dd74af2b2c7a8f375ab8e5edf0e486bba8f90a0d8a17ed9fce" + url: "https://pub.dev" + source: hosted + version: "0.8.2" boolean_selector: dependency: transitive description: @@ -194,18 +250,18 @@ packages: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" convert: dependency: transitive description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" coverage: dependency: transitive description: @@ -226,10 +282,10 @@ packages: dependency: transitive description: name: crypto - sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.6" csv: dependency: "direct main" description: @@ -246,6 +302,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + url: "https://pub.dev" + source: hosted + version: "0.7.10" diff_match_patch: dependency: transitive description: @@ -290,10 +354,10 @@ packages: dependency: transitive description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -311,10 +375,10 @@ packages: dependency: "direct main" description: name: flutter_blue_plus - sha256: ebff7e60e2f75b7f02cc1e2524c6663dcd04a7e2ddfb7e0cddf561810cb732d2 + sha256: ddbed8d86199ab4342152b2f5ce9a7ea8b348219f6880da3e7899f0a73d2dae3 url: "https://pub.dev" source: hosted - version: "1.33.2" + version: "1.33.4" flutter_driver: dependency: transitive description: flutter @@ -337,10 +401,10 @@ packages: dependency: "direct main" description: name: flutter_markdown - sha256: e17575ca576a34b46c58c91f9948891117a1bd97815d2e661813c7f90c647a78 + sha256: bd9c475d9aae256369edacafc29d1e74c81f78a10cdcdacbbbc9e3c43d009e4a url: "https://pub.dev" source: hosted - version: "0.7.3+2" + version: "0.7.4" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -443,14 +507,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + hybrid_logging: + dependency: transitive + description: + name: hybrid_logging + sha256: "54248d52ce68c14702a42fbc4083bac5c6be30f6afad8a41be4bbadd197b8af5" + url: "https://pub.dev" + source: hosted + version: "1.0.0" image: dependency: transitive description: name: image - sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.3.0" integration_test: dependency: "direct dev" description: flutter @@ -492,18 +564,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.7" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.8" leak_tracker_testing: dependency: transitive description: @@ -521,7 +593,7 @@ packages: source: hosted version: "5.0.0" logging: - dependency: transitive + dependency: "direct main" description: name: logging sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" @@ -620,10 +692,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: a75164ade98cb7d24cfd0a13c6408927c6b217fa60dee5a7ff5c116a58f28918 + sha256: "894f37107424311bdae3e476552229476777b8752c5a2a2369c0cb9a2d5442ef" url: "https://pub.dev" source: hosted - version: "8.0.2" + version: "8.0.3" package_info_plus_platform_interface: dependency: transitive description: @@ -852,7 +924,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_gen: dependency: transitive description: @@ -889,18 +961,26 @@ packages: dependency: "direct main" description: name: sqflite - sha256: ff5a2436ef8ebdfda748fbfe957f9981524cb5ff11e7bafa8c42771840e8a788 + sha256: "79a297dc3cc137e758c6a4baf83342b039e5a6d2436fcdf3f96a00adaaf2ad62" url: "https://pub.dev" source: hosted - version: "2.3.3+2" + version: "2.4.0" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + url: "https://pub.dev" + source: hosted + version: "2.4.0" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "2d8e607db72e9cb7748c9c6e739e2c9618320a5517de693d5a24609c4671b1a4" + sha256: "4468b24876d673418a7b7147e5a08a715b4998a7ae69227acafaab762e0e5490" url: "https://pub.dev" source: hosted - version: "2.5.4+4" + version: "2.5.4+5" sqflite_common_ffi: dependency: "direct main" description: @@ -909,6 +989,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.3+1" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "769733dddf94622d5541c73e4ddc6aa7b252d865285914b6fcd54a63c4b4f027" + url: "https://pub.dev" + source: hosted + version: "2.4.1-1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" sqlite3: dependency: transitive description: @@ -945,10 +1041,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" sync_http: dependency: transitive description: @@ -977,26 +1073,26 @@ packages: dependency: transitive description: name: test - sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e" + sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f" url: "https://pub.dev" source: hosted - version: "1.25.7" + version: "1.25.8" test_api: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.3" test_core: dependency: transitive description: name: test_core - sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696" + sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d" url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "0.6.5" timing: dependency: transitive description: @@ -1025,10 +1121,10 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.3.1" url_launcher_android: dependency: transitive description: @@ -1161,10 +1257,10 @@ packages: dependency: transitive description: name: win32 - sha256: "4d45dc9069dba4619dc0ebd93c7cec5e66d8482cb625a370ac806dcc8165f2ec" + sha256: e5c39a90447e7c81cfec14b041cdbd0d0916bd9ebbc7fe02ab69568be703b9bd url: "https://pub.dev" source: hosted - version: "5.5.5" + version: "5.6.0" xdg_directories: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index fc57e4f5..455aa923 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -33,10 +33,12 @@ dependencies: path: ../health_data_store/ flutter_bloc: ^8.1.6 flutter_blue_plus: ^1.33.2 + bluetooth_low_energy: ^6.0.2 archive: ^3.6.1 file_picker: ^8.1.2 fluttertoast: ^8.2.8 app_settings: ^5.1.1 + logging: ^1.2.0 persistent_user_dir_access_android: ^0.0.1 # desktop only diff --git a/app/test/features/bluetooth/bluetooth_input_test.dart b/app/test/features/bluetooth/bluetooth_input_test.dart index 2ce356ec..079ac130 100644 --- a/app/test/features/bluetooth/bluetooth_input_test.dart +++ b/app/test/features/bluetooth/bluetooth_input_test.dart @@ -1,4 +1,6 @@ import 'package:bloc_test/bloc_test.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/mock/mock_device.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/mock/mock_manager.dart'; import 'package:blood_pressure_app/features/bluetooth/bluetooth_input.dart'; import 'package:blood_pressure_app/features/bluetooth/logic/ble_read_cubit.dart'; import 'package:blood_pressure_app/features/bluetooth/logic/bluetooth_cubit.dart'; @@ -7,7 +9,6 @@ import 'package:blood_pressure_app/features/bluetooth/logic/device_scan_cubit.da import 'package:blood_pressure_app/features/bluetooth/ui/closed_bluetooth_input.dart'; import 'package:blood_pressure_app/features/bluetooth/ui/measurement_success.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart' hide BluetoothState; import 'package:flutter_test/flutter_test.dart'; import 'package:health_data_store/health_data_store.dart'; @@ -30,10 +31,10 @@ class _MockBluetoothCubitFailingEnable extends MockCubit void main() { testWidgets('propagates successful read', (WidgetTester tester) async { final bluetoothCubit = _MockBluetoothCubit(); - whenListen(bluetoothCubit, Stream.fromIterable([BluetoothReady()]), - initialState: BluetoothReady()); + whenListen(bluetoothCubit, Stream.fromIterable([BluetoothStateReady()]), + initialState: BluetoothStateReady()); final deviceScanCubit = _MockDeviceScanCubit(); - final devScanOk = DeviceSelected(BluetoothDevice(remoteId: DeviceIdentifier('tstDev'))); + final devScanOk = DeviceSelected(MockBluetoothDevice(MockBluetoothManager(), 'tstDev')); whenListen(deviceScanCubit, Stream.fromIterable([devScanOk]), initialState: devScanOk); final bleReadCubit = _MockBleReadCubit(); @@ -42,10 +43,6 @@ void main() { diastolic: 45, meanArterialPressure: 67, isMMHG: true, - pulse: null, - userID: null, - status: null, - timestamp: null, )); whenListen(bleReadCubit, Stream.fromIterable([bleReadOk]), initialState: bleReadOk, @@ -53,6 +50,7 @@ void main() { final List reads = []; await tester.pumpWidget(materialApp(BluetoothInput( + manager: MockBluetoothManager(), onMeasurement: reads.add, bluetoothCubit: () => bluetoothCubit, deviceScanCubit: () => deviceScanCubit, @@ -70,10 +68,10 @@ void main() { }); testWidgets('allows closing after successful read', (WidgetTester tester) async { final bluetoothCubit = _MockBluetoothCubit(); - whenListen(bluetoothCubit, Stream.fromIterable([BluetoothReady()]), - initialState: BluetoothReady()); + whenListen(bluetoothCubit, Stream.fromIterable([BluetoothStateReady()]), + initialState: BluetoothStateReady()); final deviceScanCubit = _MockDeviceScanCubit(); - final devScanOk = DeviceSelected(BluetoothDevice(remoteId: DeviceIdentifier('tstDev'))); + final devScanOk = DeviceSelected(MockBluetoothDevice(MockBluetoothManager(), 'tstDev')); whenListen(deviceScanCubit, Stream.fromIterable([devScanOk]), initialState: devScanOk); final bleReadCubit = _MockBleReadCubit(); @@ -82,10 +80,6 @@ void main() { diastolic: 45, meanArterialPressure: 67, isMMHG: true, - pulse: null, - userID: null, - status: null, - timestamp: null, )); whenListen(bleReadCubit, Stream.fromIterable([bleReadOk]), initialState: bleReadOk, @@ -93,6 +87,7 @@ void main() { final List reads = []; await tester.pumpWidget(materialApp(BluetoothInput( + manager: MockBluetoothManager(), onMeasurement: reads.add, bluetoothCubit: () => bluetoothCubit, deviceScanCubit: () => deviceScanCubit, @@ -110,9 +105,10 @@ void main() { }); testWidgets("doesn't attempt to turn on bluetooth before interaction", (tester) async { final bluetoothCubit = _MockBluetoothCubitFailingEnable(); - whenListen(bluetoothCubit, Stream.fromIterable([BluetoothDisabled()]), - initialState: BluetoothReady()); + whenListen(bluetoothCubit, Stream.fromIterable([BluetoothStateDisabled()]), + initialState: BluetoothStateReady()); await tester.pumpWidget(materialApp(BluetoothInput( + manager: MockBluetoothManager(), onMeasurement: (_) {}, bluetoothCubit: () => bluetoothCubit, ))); diff --git a/app/test/features/bluetooth/logic/bluetooth_cubit_test.dart b/app/test/features/bluetooth/logic/bluetooth_cubit_test.dart index 77b95bb4..00bbe8ed 100644 --- a/app/test/features/bluetooth/logic/bluetooth_cubit_test.dart +++ b/app/test/features/bluetooth/logic/bluetooth_cubit_test.dart @@ -1,9 +1,10 @@ import 'dart:async'; +import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_manager.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/flutter_blue_plus_mockable.dart'; import 'package:blood_pressure_app/features/bluetooth/logic/bluetooth_cubit.dart'; -import 'package:blood_pressure_app/features/bluetooth/logic/flutter_blue_plus_mockable.dart'; import 'package:flutter/widgets.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; @@ -16,28 +17,29 @@ import 'bluetooth_cubit_test.mocks.dart'; void main() { test('should translate adapter stream to state', () async { WidgetsFlutterBinding.ensureInitialized(); - final bluePlus = MockFlutterBluePlusMockable(); - when(bluePlus.adapterState).thenAnswer((_) => + final flutterBluePlus = MockFlutterBluePlusMockable(); + when(flutterBluePlus.adapterState).thenAnswer((_) => Stream.fromIterable([ - BluetoothAdapterState.unknown, - BluetoothAdapterState.unavailable, - BluetoothAdapterState.turningOff, - BluetoothAdapterState.off, - BluetoothAdapterState.unauthorized, - BluetoothAdapterState.turningOn, - BluetoothAdapterState.on, + fbp.BluetoothAdapterState.unknown, + fbp.BluetoothAdapterState.unavailable, + fbp.BluetoothAdapterState.turningOff, + fbp.BluetoothAdapterState.off, + fbp.BluetoothAdapterState.unauthorized, + fbp.BluetoothAdapterState.turningOn, + fbp.BluetoothAdapterState.on, ])); - final cubit = BluetoothCubit(flutterBluePlus: bluePlus); - expect(cubit.state, isA()); + final manager = FlutterBluePlusManager(flutterBluePlus); + final cubit = BluetoothCubit(manager: manager); + expect(cubit.state, isA()); await expectLater(cubit.stream, emitsInOrder([ - isA(), - isA(), - isA(), - isA(), - isA(), - isA(), - isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), ])); }); } diff --git a/app/test/features/bluetooth/logic/characteristics/ble_measurement_data_test.dart b/app/test/features/bluetooth/logic/characteristics/ble_measurement_data_test.dart index a4c2d745..a1b6fc96 100644 --- a/app/test/features/bluetooth/logic/characteristics/ble_measurement_data_test.dart +++ b/app/test/features/bluetooth/logic/characteristics/ble_measurement_data_test.dart @@ -1,10 +1,12 @@ +import 'dart:typed_data'; + import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_data.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { test('decodes sample data', () { // 22 => 0001 0110 - final result = BleMeasurementData.decode([22, 124, 0, 86, 0, 97, 0, 232, 7, 6, 15, 17, 17, 27, 51, 0, 0, 0], 0); + final result = BleMeasurementData.decode(Uint8List.fromList([22, 124, 0, 86, 0, 97, 0, 232, 7, 6, 15, 17, 17, 27, 51, 0, 0, 0]), 0); expect(result, isNotNull); expect(result!.systolic, 124.0); diff --git a/app/test/features/bluetooth/logic/device_scan_cubit_test.dart b/app/test/features/bluetooth/logic/device_scan_cubit_test.dart index 79b902fe..e1877fab 100644 --- a/app/test/features/bluetooth/logic/device_scan_cubit_test.dart +++ b/app/test/features/bluetooth/logic/device_scan_cubit_test.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_device.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_manager.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/flutter_blue_plus_mockable.dart'; import 'package:blood_pressure_app/features/bluetooth/logic/device_scan_cubit.dart'; -import 'package:blood_pressure_app/features/bluetooth/logic/flutter_blue_plus_mockable.dart'; import 'package:blood_pressure_app/model/storage/settings_store.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -11,18 +13,30 @@ import 'package:mockito/mockito.dart'; @GenerateNiceMocks([ MockSpec(), MockSpec(), - MockSpec(), MockSpec(), MockSpec(), ]) import 'device_scan_cubit_test.mocks.dart'; +/// Helper util to create a [MockScanResult] & [MockBluetoothDevice] +(MockScanResult, MockBluetoothDevice) createScanResultMock(String name) { + final scanResult = MockScanResult(); + final btDevice = MockBluetoothDevice(); + when(btDevice.platformName).thenReturn(name); + when(btDevice.remoteId).thenReturn(DeviceIdentifier(name)); + when(scanResult.device).thenReturn(btDevice); + return (scanResult, btDevice); +} + void main() { test('finds and connects to devices', () async { final StreamController> mockResults = StreamController.broadcast(); final settings = Settings(); final flutterBluePlus = MockFlutterBluePlusMockable(); + final manager = FlutterBluePlusManager(flutterBluePlus); + expect(flutterBluePlus, manager.backend); + when(flutterBluePlus.startScan( withServices: [Guid('1810')] )).thenAnswer((_) async { @@ -32,43 +46,36 @@ void main() { when(flutterBluePlus.isScanningNow).thenReturn(false); }); when(flutterBluePlus.scanResults).thenAnswer((_) => mockResults.stream); + final cubit = DeviceScanCubit( - service: Guid('1810'), + service: '1810', settings: settings, - flutterBluePlus: flutterBluePlus + manager: manager ); expect(cubit.state, isA()); - final wrongRes0 = MockScanResult(); - final wrongDev0 = MockBluetoothDevice(); - final wrongRes1 = MockScanResult(); - final wrongDev1 = MockBluetoothDevice(); - when(wrongDev0.platformName).thenReturn('wrongDev0'); - when(wrongRes0.device).thenReturn(wrongDev0); - when(wrongDev1.platformName).thenReturn('wrongDev1'); - when(wrongRes1.device).thenReturn(wrongDev1); + final (wrongRes0, wrongDev0) = createScanResultMock('wrongDev0'); + final (wrongRes1, wrongDev1) = createScanResultMock('wrongDev1'); + mockResults.sink.add([wrongRes0]); await expectLater(cubit.stream, emits(isA())); mockResults.sink.add([wrongRes0, wrongRes1]); await expectLater(cubit.stream, emits(isA())); - final dev = MockBluetoothDevice(); - when(dev.platformName).thenReturn('testDev'); - final res = MockScanResult(); - when(res.device).thenReturn(dev); - + final (res, dev) = createScanResultMock('testDev'); mockResults.sink.add([res]); - await expectLater(cubit.stream, emits(isA() - .having((r) => r.device.device, 'device', dev))); + await expectLater(cubit.stream, emits(isA() + .having((r) => r.devices.last.source, 'device', res))); expect(settings.knownBleDev, isEmpty); - await cubit.acceptDevice(dev); + await cubit.acceptDevice(FlutterBluePlusDevice(manager, res)); expect(settings.knownBleDev, contains('testDev')); // state should be set as we await above await expectLater(cubit.state, isA() - .having((s) => s.device, 'device', dev)); + .having((s) => s.device.source, 'device', res)); }); + test('recognizes devices', () async { final StreamController> mockResults = StreamController.broadcast(); final settings = Settings( @@ -76,6 +83,7 @@ void main() { ); final flutterBluePlus = MockFlutterBluePlusMockable(); + final manager = FlutterBluePlusManager(flutterBluePlus); when(flutterBluePlus.startScan( withServices: [Guid('1810')] )).thenAnswer((_) async { @@ -86,24 +94,20 @@ void main() { }); when(flutterBluePlus.scanResults).thenAnswer((_) => mockResults.stream); final cubit = DeviceScanCubit( - service: Guid('1810'), + service: '1810', settings: settings, - flutterBluePlus: flutterBluePlus + manager: manager ); expect(cubit.state, isA()); - final wrongRes0 = MockScanResult(); - final wrongDev0 = MockBluetoothDevice(); - when(wrongDev0.platformName).thenReturn('wrongDev0'); - when(wrongRes0.device).thenReturn(wrongDev0); + final (wrongRes0, wrongDev0) = createScanResultMock('wrongDev0'); mockResults.sink.add([wrongRes0]); + await expectLater(cubit.stream, emits(isA())); - final dev = MockBluetoothDevice(); - when(dev.platformName).thenReturn('testDev'); - final res = MockScanResult(); - when(res.device).thenReturn(dev); + final (res, dev) = createScanResultMock('testDev'); mockResults.sink.add([wrongRes0, res]); + // No prompt when finding the correct device again await expectLater(cubit.stream, emits(isA())); }); diff --git a/app/test/features/bluetooth/mock/fake_flutter_blue_plus.dart b/app/test/features/bluetooth/mock/fake_flutter_blue_plus.dart index e975009c..1e742c03 100644 --- a/app/test/features/bluetooth/mock/fake_flutter_blue_plus.dart +++ b/app/test/features/bluetooth/mock/fake_flutter_blue_plus.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:blood_pressure_app/features/bluetooth/logic/flutter_blue_plus_mockable.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/flutter_blue_plus_mockable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; @@ -55,7 +55,7 @@ class FakeFlutterBluePlus extends FlutterBluePlusMockable { List get connectedDevices => throw UnimplementedError(); @override - Future> get systemDevices => throw UnimplementedError(); + Future> systemDevices(List withServices) => throw UnimplementedError(); @override Future> get bondedDevices => throw UnimplementedError(); @@ -64,7 +64,7 @@ class FakeFlutterBluePlus extends FlutterBluePlusMockable { Future setOptions({bool showPowerAlert = true,}) => throw UnimplementedError(); @override - Future turnOn({int timeout = 60}) async => null; + Future turnOn({int timeout = 60}) async {} @override Future startScan({ diff --git a/app/test/features/bluetooth/mock/mock_ble_read_cubit.dart b/app/test/features/bluetooth/mock/mock_ble_read_cubit.dart index eb47188f..ef857422 100644 --- a/app/test/features/bluetooth/mock/mock_ble_read_cubit.dart +++ b/app/test/features/bluetooth/mock/mock_ble_read_cubit.dart @@ -2,7 +2,7 @@ import 'package:blood_pressure_app/features/bluetooth/logic/ble_read_cubit.dart' import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_data.dart'; import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_status.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:logging/logging.dart'; class MockBleReadCubit extends Cubit implements BleReadCubit { MockBleReadCubit(): super(BleReadSuccess( @@ -27,9 +27,22 @@ class MockBleReadCubit extends Cubit implements BleReadCubit { )); @override - Guid get characteristicUUID => throw UnimplementedError(); + String get characteristicUUID => throw UnimplementedError(); @override - Guid get serviceUUID => throw UnimplementedError(); + String get serviceUUID => throw UnimplementedError(); + + @override + Logger get logger => throw UnimplementedError(); + + @override + Future takeMeasurement() { + throw UnimplementedError(); + } + + @override + Future useMeasurement(BleMeasurementData data) { + throw UnimplementedError(); + } } diff --git a/app/test/features/bluetooth/ui/closed_input_test.dart b/app/test/features/bluetooth/ui/closed_input_test.dart index 8143cc40..06d40346 100644 --- a/app/test/features/bluetooth/ui/closed_input_test.dart +++ b/app/test/features/bluetooth/ui/closed_input_test.dart @@ -12,7 +12,9 @@ import '../../../util.dart'; class MockBluetoothCubit extends MockCubit implements BluetoothCubit { + @override Future enableBluetooth() async => true; + @override Future forceRefresh() async {} } @@ -21,7 +23,7 @@ void main() { final states = StreamController.broadcast(); final cubit = MockBluetoothCubit(); - whenListen(cubit, states.stream, initialState: BluetoothInitial()); + whenListen(cubit, states.stream, initialState: BluetoothStateInitial()); int startCount = 0; await tester.pumpWidget(materialApp(ClosedBluetoothInput( @@ -35,12 +37,12 @@ void main() { expect(find.byType(SizedBox), findsOneWidget); expect(find.byType(ListTile), findsNothing); - states.sink.add(BluetoothUnfeasible()); + states.sink.add(BluetoothStateUnfeasible()); await tester.pump(); expect(find.byType(SizedBox), findsOneWidget); expect(find.byType(ListTile), findsNothing); - states.sink.add(BluetoothUnauthorized()); + states.sink.add(BluetoothStateUnauthorized()); await tester.pump(); final localizations = await AppLocalizations.delegate.load(const Locale('en')); expect(find.text(localizations.errBleNoPerms), findsOneWidget); @@ -48,14 +50,14 @@ void main() { await tester.tap(find.byType(ClosedBluetoothInput)); expect(startCount, 0); - states.sink.add(BluetoothDisabled()); + states.sink.add(BluetoothStateDisabled()); await tester.pump(); expect(find.text(localizations.bluetoothDisabled), findsOneWidget); await tester.tap(find.byType(ClosedBluetoothInput)); expect(startCount, 0); - states.sink.add(BluetoothReady()); + states.sink.add(BluetoothStateReady()); await tester.pump(); expect(find.text(localizations.bluetoothInput), findsOneWidget); diff --git a/app/test/features/bluetooth/ui/device_selection_test.dart b/app/test/features/bluetooth/ui/device_selection_test.dart index de4cadb1..74ee3129 100644 --- a/app/test/features/bluetooth/ui/device_selection_test.dart +++ b/app/test/features/bluetooth/ui/device_selection_test.dart @@ -1,30 +1,21 @@ import 'dart:ui'; +import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/mock/mock_device.dart'; +import 'package:blood_pressure_app/features/bluetooth/backend/mock/mock_manager.dart'; import 'package:blood_pressure_app/features/bluetooth/ui/device_selection.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; import '../../../util.dart'; -@GenerateNiceMocks([ - MockSpec(), - MockSpec(), -]) -import 'device_selection_test.mocks.dart'; void main() { testWidgets('Connects with one element', (WidgetTester tester) async { - final dev = MockBluetoothDevice(); - when(dev.platformName).thenReturn('Test device with long name (No.124356)'); - - final scanRes = MockScanResult(); - when(scanRes.device).thenReturn(dev); + final dev = MockBluetoothDevice(MockBluetoothManager(), 'Test device with long name (No.124356)'); final List accepted = []; await tester.pumpWidget(materialApp(DeviceSelection( - scanResults: [ scanRes ], + scanResults: [ dev ], onAccepted: accepted.add, ))); @@ -44,15 +35,7 @@ void main() { }); testWidgets('Shows multiple elements', (WidgetTester tester) async { - ScanResult getDev(String name) { - final dev = MockBluetoothDevice(); - when(dev.platformName).thenReturn(name); - - final scanRes = MockScanResult(); - when(scanRes.device).thenReturn(dev); - - return scanRes; - } + BluetoothDevice getDev(String name) => MockBluetoothDevice(MockBluetoothManager(), name); await tester.pumpWidget(materialApp(DeviceSelection( scanResults: [ diff --git a/app/test/features/bluetooth/ui/measurement_failure_test.dart b/app/test/features/bluetooth/ui/measurement_failure_test.dart index 025a5648..c1f1bc4d 100644 --- a/app/test/features/bluetooth/ui/measurement_failure_test.dart +++ b/app/test/features/bluetooth/ui/measurement_failure_test.dart @@ -12,6 +12,7 @@ void main() { int tapCount = 0; await tester.pumpWidget(materialApp(MeasurementFailure( onTap: () => tapCount++, + reason: '', ))); expect(find.byIcon(Icons.error_outline), findsOneWidget); diff --git a/app/test/features/bluetooth/ui/measurement_multiple_test.dart b/app/test/features/bluetooth/ui/measurement_multiple_test.dart new file mode 100644 index 00000000..3f7698d9 --- /dev/null +++ b/app/test/features/bluetooth/ui/measurement_multiple_test.dart @@ -0,0 +1,86 @@ + +import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_data.dart'; +import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_status.dart'; +import 'package:blood_pressure_app/features/bluetooth/ui/measurement_multiple.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../util.dart'; + + +void main() { + testWidgets('should show everything and be interactive', (WidgetTester tester) async { + int tapCount = 0; + final List selected = []; + final measurements = [ + BleMeasurementData( + systolic: 123, + diastolic: 456, + pulse: 67, + meanArterialPressure: 123456, + isMMHG: true, + userID: 3, + status: BleMeasurementStatus( + bodyMovementDetected: true, + cuffTooLose: true, + irregularPulseDetected: true, + pulseRateInRange: true, + pulseRateExceedsUpperLimit: true, + pulseRateIsLessThenLowerLimit: true, + improperMeasurementPosition: true, + ), + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + ), + BleMeasurementData( + systolic: 124, + diastolic: 457, + pulse: null, + meanArterialPressure: 123457, + isMMHG: true, + userID: null, + status: BleMeasurementStatus( + bodyMovementDetected: true, + cuffTooLose: true, + irregularPulseDetected: true, + pulseRateInRange: true, + pulseRateExceedsUpperLimit: true, + pulseRateIsLessThenLowerLimit: true, + improperMeasurementPosition: true, + ), + timestamp: null, + ), + ]; + + await tester.pumpWidget(materialApp(MeasurementMultiple( + onClosed: () => tapCount++, + onSelect: selected.add, + measurements: measurements, + ))); + + final localizations = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.text(localizations.selectMeasurementTitle), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + + expect(find.byType(ListTile), findsNWidgets(2)); + + expect(find.textContaining(localizations.userID), findsOneWidget); // one measurement has UserID: null + expect(find.textContaining(localizations.bloodPressure), findsNWidgets(2)); + for (final measurement in measurements) { + expect(find.textContaining(measurement.systolic.toInt().toString()), findsOneWidget); + } + + expect(find.text(localizations.measurementIndex(2)), findsOneWidget); + expect(find.text(localizations.select), findsNWidgets(2)); + + expect(selected, isEmpty); + await tester.tap(find.text(localizations.select).first); + expect(selected.length, 1); + expect(selected, contains(measurements[0])); + + expect(tapCount, 0); + await tester.tap(find.byIcon(Icons.close)); + await tester.pump(); + expect(tapCount, 1); + }); +} diff --git a/app/test/features/input/add_measurement_dialoge_test.dart b/app/test/features/input/add_measurement_dialoge_test.dart index 15b2cdde..6c612ffa 100644 --- a/app/test/features/input/add_measurement_dialoge_test.dart +++ b/app/test/features/input/add_measurement_dialoge_test.dart @@ -144,8 +144,8 @@ void main() { bleInput: true, ); await tester.pumpWidget(materialApp( - AddEntryDialoge( - availableMeds: const [], + const AddEntryDialoge( + availableMeds: [], ), settings: settings, ),); @@ -418,7 +418,7 @@ void main() { matching: find.byType(TextFormField), ); expect(focusedTextFormField, findsOneWidget); - final field = await tester.widget(focusedTextFormField); + final field = tester.widget(focusedTextFormField); expect(field.initialValue, '12'); }); testWidgets('should focus next on input finished', (tester) async { diff --git a/app/test/features/statistics/clock_bp_graph_test.dart b/app/test/features/statistics/clock_bp_graph_test.dart index f074df91..10c0ffa7 100644 --- a/app/test/features/statistics/clock_bp_graph_test.dart +++ b/app/test/features/statistics/clock_bp_graph_test.dart @@ -14,7 +14,7 @@ void main() { home: Scaffold( body: ChangeNotifierProvider( create: (_) => Settings(), - child: ClockBpGraph(measurements: []), + child: const ClockBpGraph(measurements: []), ), ), )); @@ -43,7 +43,7 @@ void main() { )); await expectLater(find.byType(ClockBpGraph), matchesGoldenFile('ClockBpGraph-light.png')); }); - testWidgets('renders sample data like expected in dart mode', (tester) async { + testWidgets('renders sample data like expected in dark mode', (tester) async { final rng = Random(1234); await tester.pumpWidget(MaterialApp( theme: ThemeData.dark(useMaterial3: true), diff --git a/app/test/features/statistics/value_graph_test.dart b/app/test/features/statistics/value_graph_test.dart index 8b61f263..6d9789ab 100644 --- a/app/test/features/statistics/value_graph_test.dart +++ b/app/test/features/statistics/value_graph_test.dart @@ -17,6 +17,7 @@ void main() { final records = [ mockRecord(time: DateTime(2000), sys: 123), mockRecord(time: DateTime(2001), sys: 120), + // ignore: avoid_redundant_argument_values mockRecord(time: DateTime(2002), sys: null), mockRecord(time: DateTime(2003), sys: 123), mockRecord(time: DateTime(2004), sys: 200), @@ -35,6 +36,7 @@ void main() { final records = [ mockRecord(time: DateTime(2000), dia: 123), mockRecord(time: DateTime(2001), dia: 120), + // ignore: avoid_redundant_argument_values mockRecord(time: DateTime(2002), dia: null), mockRecord(time: DateTime(2003), dia: 123), mockRecord(time: DateTime(2004), dia: 200), @@ -53,6 +55,7 @@ void main() { final records = [ mockRecord(time: DateTime(2000), pul: 123), mockRecord(time: DateTime(2001), pul: 120), + // ignore: avoid_redundant_argument_values mockRecord(time: DateTime(2002), pul: null), mockRecord(time: DateTime(2003), pul: 123), mockRecord(time: DateTime(2004), pul: 200), diff --git a/app/test/logging_test.dart b/app/test/logging_test.dart new file mode 100644 index 00000000..48fe1a80 --- /dev/null +++ b/app/test/logging_test.dart @@ -0,0 +1,14 @@ +import 'package:blood_pressure_app/logging.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Helper util for tests +void throwError() { + throw Error(); +} + +void main() { + test('Log.withoutTypes strips type references from log statement', () { + const logLine = 'SomeClass'; + expect(Log.withoutTypes(logLine), 'SomeClass'); + }); +} diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc index 4f788487..be6d2395 100644 --- a/app/windows/flutter/generated_plugin_registrant.cc +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,12 @@ #include "generated_plugin_registrant.h" +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + BluetoothLowEnergyWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("BluetoothLowEnergyWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake index 88b22e5c..d9a3f004 100644 --- a/app/windows/flutter/generated_plugins.cmake +++ b/app/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + bluetooth_low_energy_windows url_launcher_windows )