Commit d296182
Changed files (71)
.vscode
app
android
gradle
wrapper
lib
data_util
features
bluetooth
backend
bluetooth_low_energy
flutter_blue_plus
logic
ui
statistics
l10n
model
macos
test
features
bluetooth
windows
.vscode/extensions.json
@@ -0,0 +1,6 @@
+{
+ "recommendations": [
+ "dart-code.flutter",
+ "dart-code.dart-code"
+ ]
+}
\ No newline at end of file
.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
app/android/gradle/wrapper/gradle-wrapper.properties
@@ -3,4 +3,4 @@ distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
-distributionSha256Sum=2ab88d6de2c23e6adae7363ae6e29cbdd2a709e992929b48b6530fd0c7133bd6
+distributionSha256Sum=2ab88d6de2c23e6adae7363ae6e29cbdd2a709e992929b48b6530fd0c7133bd6
\ No newline at end of file
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.');
}
}
}
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<BluetoothConnectionState> get connectionStream => _cm.connectionStateChanged
+ .map((PeripheralConnectionStateChangedEventArgs rawState) => switch (rawState.state) {
+ ConnectionState.connected => BluetoothConnectionState.connected,
+ ConnectionState.disconnected => BluetoothConnectionState.disconnected,
+ });
+
+ @override
+ Future<void> backendConnect() => _cm.connect(source.peripheral);
+
+ @override
+ Future<void> backendDisconnect() => _cm.disconnect(source.peripheral);
+
+ @override
+ Future<void> dispose() => disposeCharacteristics();
+
+ @override
+ Future<List<BluetoothLowEnergyService>?> 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<bool> triggerCharacteristicValue(BluetoothLowEnergyCharacteristic characteristic, [bool state = true]) async {
+ await _cm.setCharacteristicNotifyState(source.peripheral, characteristic.source, state: state);
+ return true;
+ }
+
+ @override
+ Future<bool> 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;
+ }
+}
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<BluetoothLowEnergyManager> {
+ /// Construct BluetoothDeviceDiscovery implementation for the 'bluetooth_low_energy' package
+ BluetoothLowEnergyDiscovery(super.manager);
+
+ @override
+ Stream<List<BluetoothDevice>> get discoverStream => manager.backend.discovered.map(
+ (device) => [manager.createDevice(device)]
+ );
+
+ @override
+ Future<void> 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<void> backendStop() => manager.backend.stopDiscovery();
+}
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<DiscoveredEventArgs, UUID, GATTService, GATTCharacteristic> {
+ /// constructor
+ BluetoothLowEnergyManager() {
+ logger.fine('init');
+
+ // Sync current adapter state
+ _adapterStateParser.parseAndCache(BluetoothLowEnergyStateChangedEventArgs(backend.state));
+ }
+
+ @override
+ Future<bool?> 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<BluetoothAdapterState> 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);
+}
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<UUID> {
+ /// 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<GATTService, BluetoothLowEnergyCharacteristic> {
+
+ /// Create a [BluetoothLowEnergyService] from a [GATTService]
+ BluetoothLowEnergyService.fromSource(GATTService service):
+ super(uuid: BluetoothLowEnergyUUID(service.uuid), source: service);
+
+ @override
+ List<BluetoothLowEnergyCharacteristic> get characteristics => source.characteristics.map(BluetoothLowEnergyCharacteristic.fromSource).toList();
+}
+
+/// Wrapper class with generic interface for a [GATTCharacteristic]
+final class BluetoothLowEnergyCharacteristic extends BluetoothCharacteristic<GATTCharacteristic> {
+ /// 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);
+}
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<BluetoothLowEnergyStateChangedEventArgs> {
+ @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,
+ };
+}
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<BluetoothConnectionState> 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<void> backendConnect() => source.device.connect();
+
+ @override
+ Future<void> backendDisconnect() => source.device.disconnect();
+
+ @override
+ Future<void> dispose() => disposeCharacteristics();
+
+ @override
+ Future<List<FlutterBluePlusService>?> 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<bool> triggerCharacteristicValue(FlutterBluePlusCharacteristic characteristic, [bool state = true]) => characteristic.source.setNotifyValue(state);
+
+ @override
+ Future<bool> 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;
+ }
+}
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<FlutterBluePlusManager> {
+ /// constructor
+ FlutterBluePlusDiscovery(super.manager);
+
+ @override
+ Stream<List<BluetoothDevice>> get discoverStream => manager.backend.scanResults.map(
+ (devices) => devices.map(manager.createDevice).toList()
+ );
+
+ @override
+ Future<void> 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<void> backendStop() => manager.backend.stopScan();
+}
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<ScanResult, Guid, fbp.BluetoothService, fbp.BluetoothCharacteristic> {
+ /// constructor
+ FlutterBluePlusManager([FlutterBluePlusMockable? backend]): backend = (backend ?? FlutterBluePlusMockable()) {
+ logger.finer('init');
+ }
+
+ /// backend implementation
+ final FlutterBluePlusMockable backend;
+
+ @override
+ Future<bool?> 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<BluetoothAdapterState> 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);
+}
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<Guid> {
+ /// 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<fbp.BluetoothService, FlutterBluePlusCharacteristic> {
+ /// 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<FlutterBluePlusCharacteristic> get characteristics => source.characteristics
+ .map(FlutterBluePlusCharacteristic.fromSource).toList();
+}
+
+/// Wrapper class with generic interface for a [fbp.BluetoothCharacteristic]
+final class FlutterBluePlusCharacteristic extends BluetoothCharacteristic<fbp.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;
+}
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<fbp.BluetoothAdapterState> {
+ @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,
+ };
+}
app/lib/features/bluetooth/logic/flutter_blue_plus_mockable.dart โ 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<PhySupport> getPhySupport() => FlutterBluePlus.getPhySupport();
-}
\ No newline at end of file
+}
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<BluetoothManager, BluetoothService, BluetoothCharacteristic, String> {
+ /// 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<BluetoothConnectionState> get connectionStream => const Stream<BluetoothConnectionState>.empty();
+
+ @override
+ Future<void> backendConnect() async {}
+
+ @override
+ Future<void> backendDisconnect() async {}
+
+ @override
+ Future<void> dispose() async {}
+
+ @override
+ Future<List<BluetoothService<dynamic, BluetoothCharacteristic>>?> discoverServices() =>
+ Future.value(<BluetoothService<dynamic, BluetoothCharacteristic>>[]);
+
+ @override
+ Future<bool> getCharacteristicValue(BluetoothCharacteristic characteristic, void Function(Uint8List value) onValue) async => true;
+}
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<MockBluetoothManager> {
+ /// constructor
+ MockBluetoothDiscovery(super.manager);
+
+ @override
+ Future<void> backendStart(String serviceUuid) {
+ throw UnimplementedError();
+ }
+
+ @override
+ Future<void> backendStop() {
+ throw UnimplementedError();
+ }
+
+ @override
+ Stream<List<BluetoothDevice>> get discoverStream => throw UnimplementedError();
+
+}
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<String, String, MockedService, MockedCharacteristic> {
+ @override
+ BluetoothDeviceDiscovery<BluetoothManager> get discovery => throw UnimplementedError();
+
+ @override
+ Future<bool?> enable() async => null;
+
+ @override
+ BluetoothAdapterState get lastKnownAdapterState => BluetoothAdapterState.initial;
+
+ @override
+ Stream<BluetoothAdapterState> get stateStream => const Stream.empty();
+
+ @override
+ BluetoothUuid createUuid(String uuid) {
+ throw UnimplementedError();
+ }
+
+ @override
+ BluetoothUuid createUuidFromString(String uuid) {
+ throw UnimplementedError();
+ }
+
+ @override
+ BluetoothDevice<BluetoothManager, BluetoothService<dynamic, BluetoothCharacteristic>, BluetoothCharacteristic, dynamic> createDevice(String device) {
+ throw UnimplementedError();
+ }
+
+ @override
+ BluetoothService<dynamic, BluetoothCharacteristic> createService(MockedService service) {
+ throw UnimplementedError();
+ }
+
+ @override
+ BluetoothCharacteristic createCharacteristic(MockedCharacteristic characteristic) {
+ throw UnimplementedError();
+ }
+}
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<MockedCharacteristic> 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<String> {
+ /// 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<MockedService, BluetoothCharacteristic> {
+ /// Create a FlutterBlueService from a [MockedService]
+ MockBluetoothService.fromSource(MockedService service): super(uuid: MockBluetoothString(service.uuid), source: service);
+
+ @override
+ List<BluetoothCharacteristic> get characteristics => source.characteristics.map(MockBluetoothCharacteristic.fromSource).toList();
+}
+
+/// Wrapper class with generic interface for a [MockedCharacteristic]
+final class MockBluetoothCharacteristic extends BluetoothCharacteristic<MockedCharacteristic> {
+ /// 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;
+}
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;
+}
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;
+}
+
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<BS>? _services;
+
+ StreamSubscription<BluetoothConnectionState>? _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<BluetoothConnectionState> get connectionStream;
+
+ /// Backend implementation to connect to the device
+ Future<void> backendConnect();
+ /// Backend implementation to disconnect to the device
+ Future<void> backendDisconnect();
+
+ /// Require backends to implement a dispose method to cleanup any resources
+ Future<void> 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<bool Function()> 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<void> _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<void> 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<bool> connect({
+ VoidCallback? onConnect,
+ bool Function()? onDisconnect,
+ ValueSetter<Object>? 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<bool>();
+ 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<bool> 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<List<BS>?> discoverServices();
+
+ /// Return all available services for this device
+ ///
+ /// Difference with [discoverServices] is that [getServices] memoizes the results
+ Future<List<BS>?> 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<BS?> 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<bool> 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<BM, BS, BC, BackendDevice> {
+ /// List of read characteristic completers, used for cleanup on device disconnect
+ final List<Completer<bool>> _readCharacteristicCompleters = [];
+ /// List of read characteristic subscriptions, used for cleanup on device disconnect
+ final List<StreamSubscription<Uint8List?>> _readCharacteristicListeners = [];
+
+ /// Dispose of all resources used to read characteristics
+ ///
+ /// Internal method, should not be used by users
+ @protected
+ Future<void> 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<bool> 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<bool> listenCharacteristicValue(
+ BC characteristic,
+ Stream<Uint8List?> characteristicValueStream,
+ void Function(Uint8List value, [void Function(bool success)? complete]) onValue
+ ) async {
+ if (!characteristic.canIndicate) {
+ return false;
+ }
+
+ final completer = Completer<bool>();
+ 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;
+ });
+ }
+}
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<BM extends BluetoothManager> 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<BluetoothDevice> _devices = {};
+
+ /// A stream that returns the discovered devices when discovering
+ Stream<List<BluetoothDevice>> get discoverStream;
+
+ StreamSubscription<List<BluetoothDevice>>? _discoverSubscription;
+
+ /// Backend implementation to start discovering
+ @protected
+ Future<void> backendStart(String serviceUuid);
+
+ /// Backend implementation to stop discovering
+ @protected
+ Future<void> 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<void> start(String serviceUuid, ValueSetter<List<BluetoothDevice>> 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<void> stop() async {
+ if (!_discovering) {
+ return;
+ }
+
+ logger.finer('Stopping discovery');
+ await _discoverSubscription?.cancel();
+ await backendStop();
+ _devices.clear();
+ _discovering = false;
+ }
+}
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<BackendDevice, BackendUuid, BackendService, BackendCharacteristic> 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<bool?> 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<BluetoothAdapterState> 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);
+}
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<BackendUuid> {
+ /// 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<BackendService, BC extends BluetoothCharacteristic> {
+ /// 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<BC> get characteristics;
+
+ /// Returns the characteristic with requested [uuid], returns null if
+ /// requested [uuid] was not found
+ Future<BC?> 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<BackendCharacteristic> {
+ /// 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;
+}
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<BackendState> extends StreamDataParserCached<BackendState, BluetoothAdapterState> {
+ @override
+ BluetoothAdapterState get initialState => BluetoothAdapterState.initial;
+}
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<StreamData, ParsedData> {
+ /// 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<StreamData, ParsedData> extends StreamDataParser<StreamData, ParsedData> {
+ /// 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<StreamData, ParsedData, SD extends StreamDataParser<StreamData, ParsedData>>
+ extends StreamDataTransformer<StreamData, ParsedData> {
+ /// 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<S,T> with TypeLogger implements StreamTransformer<S,T> {
+ /// Create a BluetoothStreamTransformer
+ ///
+ /// - [sync] Passed to [StreamController]
+ /// - [cancelOnError] Passed to [Stream]
+ StreamDataTransformer({ bool sync = false, bool cancelOnError = false }) {
+ _cancelOnError = cancelOnError;
+
+ _controller = StreamController<T>(
+ onListen: _onListen,
+ onCancel: _onCancel,
+ onPause: () => _subscription?.pause(),
+ onResume: () => _subscription?.resume(),
+ sync: sync,
+ );
+ }
+
+ late StreamController<T> _controller;
+ StreamSubscription? _subscription;
+ Stream<S>? _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<T> bind(Stream<S> stream) {
+ logger.finest('bind');
+ _stream = stream;
+ return _controller.stream;
+ }
+
+ @override
+ StreamTransformer<RS, RT> cast<RS, RT>() => StreamTransformer.castFrom(this);
+}
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<int> 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
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<BleReadState> {
+class BleReadCubit extends Cubit<BleReadState> 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<BluetoothConnectionState> _subscription;
late final Timer _timeoutTimer;
- StreamSubscription<List<int>>? _indicationListener;
- @override
- Future<void> 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<void> 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<void> _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<Uint8List> 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<BleMeasurementData> 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<void> _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<BluetoothService> 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<void> 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<BluetoothCharacteristic> 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<void> useMeasurement(BleMeasurementData data) async {
+ assert(state is! BleReadSuccess);
+ emit(BleReadSuccess(data));
}
}
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<BleMeasurementData> data;
+}
/// Data has been successfully read.
class BleReadSuccess extends BleReadState {
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<BluetoothState> {
+/// The only state that allows using the adapter is [BluetoothStateReady].
+class BluetoothCubit extends Cubit<BluetoothState> 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<BluetoothAdapterState> _adapterStateStateSubscription;
+ /// Bluetooth manager
+ late final BluetoothManager manager;
+ late BluetoothAdapterState _lastKnownState;
+ late StreamSubscription<BluetoothAdapterState> _adapterStateSubscription;
@override
Future<void> 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<bool> enableBluetooth() async {
- assert(state is BluetoothDisabled, 'No need to enable bluetooth when '
+ Future<bool?> 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<BluetoothState> {
/// 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<void> forceRefresh() async {
- _onAdapterStateChanged(_flutterBluePlus.adapterStateNow);
- }
+ void forceRefresh() => _onAdapterStateChanged(_lastKnownState);
}
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 {}
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<DeviceScanState> {
+class DeviceScanCubit extends Cubit<DeviceScanState> 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<List<ScanResult>> _scanResultsSubscription;
+ late final BluetoothManager _manager;
@override
Future<void> 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<void> _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<ScanResult> devices) {
- Log.trace('_onScanResult devices: $devices');
+ Future<bool> _stopScanning() async {
+ try {
+ await _manager.discovery.stop();
+ } catch (err) {
+ logger.severe('Failed to stop scanning', err);
+ return false;
+ }
+ return true;
+ }
+
+ void _onScanResult(List<BluetoothDevice> 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<DeviceScanState> {
}
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<void> 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<String> list = settings.knownBleDev.toList();
- list.add(device.platformName);
+ list.add(device.name);
settings.knownBleDev = list;
}
}
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<ScanResult> devices;
+ final List<BluetoothDevice> 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;
}
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<BluetoothCubit, BluetoothState>(
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,
+ )
+ };
},
);
}
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<ScanResult> scanResults;
+ final List<BluetoothDevice> 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
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,),
+ ],
+ ),
),
),
- ),
- );
-
+ );
+ }
}
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<BleMeasurementData> 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),
+ ]
+ ),
+ );
+ }
+}
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),
),
],
),
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<BluetoothInput> createState() => _BluetoothInputState();
}
-class _BluetoothInputState extends State<BluetoothInput> {
- /// 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<BluetoothInput> with TypeLogger {
+ /// Whether the user initiated reading bluetooth input
bool _isActive = false;
late final BluetoothCubit _bluetoothCubit;
@@ -61,7 +72,7 @@ class _BluetoothInputState extends State<BluetoothInput> {
@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<BluetoothInput> {
}
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: <Widget>[
+ 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<Settings>();
_deviceScanCubit ??= widget.deviceScanCubit?.call() ?? DeviceScanCubit(
+ manager: widget.manager,
service: serviceUUID,
settings: settings,
);
+
return BlocBuilder<DeviceScanCubit, DeviceScanState>(
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<BluetoothInput> {
scanResults: [ state.device ],
onAccepted: (dev) => _deviceScanCubit!.acceptDevice(dev),
),
- // distinction
- DeviceSelected() => BlocConsumer<BleReadCubit, BleReadState>(
- 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: <Widget>[
- 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<BleReadCubit, BleReadState>(
+ 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,
+ ),
+ };
+ },
);
}
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<AddEntryDialoge> {
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<AddEntryDialoge> {
),
),
InputDecorator(
- decoration: InputDecoration(
+ decoration: const InputDecoration(
contentPadding: EdgeInsets.zero,
),
child: ColorSelectionListTile(
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,
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"
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;
}
})
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();
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' => 'ะ ัััะบะธะน',
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,9 @@ class AppHome extends StatelessWidget {
_appStart++;
}
- if (orientation == Orientation.landscape) return Scaffold(body: _buildValueGraph(context));
+ if (showValueGraphAsHomeScreenInLandscapeMode && orientation == Orientation.landscape) {
+ return Scaffold(body: _buildValueGraph(context));
+ }
return DefaultTabController(
length: 2,
child: Scaffold(
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 {
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;
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<Object>? 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<SomeType> -> 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)));
}
}
}
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());
+}
app/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -5,6 +5,7 @@
import FlutterMacOS
import Foundation
+import bluetooth_low_energy_darwin
import flutter_blue_plus
import package_info_plus
import shared_preferences_foundation
@@ -12,6 +13,7 @@ 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"))
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);
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<BluetoothInitial>());
+ final manager = FlutterBluePlusManager(flutterBluePlus);
+ final cubit = BluetoothCubit(manager: manager);
+ expect(cubit.state, isA<BluetoothStateInitial>());
await expectLater(cubit.stream, emitsInOrder([
- isA<BluetoothInitial>(),
- isA<BluetoothUnfeasible>(),
- isA<BluetoothDisabled>(),
- isA<BluetoothDisabled>(),
- isA<BluetoothUnauthorized>(),
- isA<BluetoothDisabled>(),
- isA<BluetoothReady>(),
+ isA<BluetoothStateInitial>(),
+ isA<BluetoothStateUnfeasible>(),
+ isA<BluetoothStateDisabled>(),
+ isA<BluetoothStateDisabled>(),
+ isA<BluetoothStateUnauthorized>(),
+ isA<BluetoothStateDisabled>(),
+ isA<BluetoothStateReady>(),
]));
});
}
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<BluetoothDevice>(),
MockSpec<BluetoothService>(),
- MockSpec<AdvertisementData>(),
MockSpec<FlutterBluePlusMockable>(),
MockSpec<ScanResult>(),
])
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<List<ScanResult>> 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<DeviceListLoading>());
- 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<SingleDeviceAvailable>()));
mockResults.sink.add([wrongRes0, wrongRes1]);
await expectLater(cubit.stream, emits(isA<DeviceListAvailable>()));
- 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<SingleDeviceAvailable>()
- .having((r) => r.device.device, 'device', dev)));
+ await expectLater(cubit.stream, emits(isA<DeviceListAvailable>()
+ .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<DeviceSelected>()
- .having((s) => s.device, 'device', dev));
+ .having((s) => s.device.source, 'device', res));
});
+
test('recognizes devices', () async {
final StreamController<List<ScanResult>> 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<DeviceListLoading>());
- 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<SingleDeviceAvailable>()));
- 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<DeviceSelected>()));
});
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<BluetoothDevice> get connectedDevices => throw UnimplementedError();
@override
- Future<List<BluetoothDevice>> get systemDevices => throw UnimplementedError();
+ Future<List<BluetoothDevice>> systemDevices(List<Guid> withServices) => throw UnimplementedError();
@override
Future<List<BluetoothDevice>> get bondedDevices => throw UnimplementedError();
@@ -64,7 +64,7 @@ class FakeFlutterBluePlus extends FlutterBluePlusMockable {
Future<void> setOptions({bool showPowerAlert = true,}) => throw UnimplementedError();
@override
- Future<void> turnOn({int timeout = 60}) async => null;
+ Future<void> turnOn({int timeout = 60}) async {}
@override
Future<void> startScan({
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<BleReadState> implements BleReadCubit {
MockBleReadCubit(): super(BleReadSuccess(
@@ -27,9 +27,22 @@ class MockBleReadCubit extends Cubit<BleReadState> 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<void> takeMeasurement() {
+ throw UnimplementedError();
+ }
+
+ @override
+ Future<void> useMeasurement(BleMeasurementData data) {
+ throw UnimplementedError();
+ }
}
app/test/features/bluetooth/ui/closed_input_test.dart
@@ -12,7 +12,9 @@ import '../../../util.dart';
class MockBluetoothCubit extends MockCubit<BluetoothState>
implements BluetoothCubit {
+ @override
Future<bool> enableBluetooth() async => true;
+ @override
Future<void> forceRefresh() async {}
}
@@ -21,7 +23,7 @@ void main() {
final states = StreamController<BluetoothState>.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);
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<BluetoothDevice>(),
- MockSpec<ScanResult>(),
-])
-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<BluetoothDevice> 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: [
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);
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<BleMeasurementData> 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);
+ });
+}
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<BluetoothState>
void main() {
testWidgets('propagates successful read', (WidgetTester tester) async {
final bluetoothCubit = _MockBluetoothCubit();
- whenListen(bluetoothCubit, Stream<BluetoothState>.fromIterable([BluetoothReady()]),
- initialState: BluetoothReady());
+ whenListen(bluetoothCubit, Stream<BluetoothState>.fromIterable([BluetoothStateReady()]),
+ initialState: BluetoothStateReady());
final deviceScanCubit = _MockDeviceScanCubit();
- final devScanOk = DeviceSelected(BluetoothDevice(remoteId: DeviceIdentifier('tstDev')));
+ final devScanOk = DeviceSelected(MockBluetoothDevice(MockBluetoothManager(), 'tstDev'));
whenListen(deviceScanCubit, Stream<DeviceScanState>.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<BleReadState>.fromIterable([bleReadOk]),
initialState: bleReadOk,
@@ -53,6 +50,7 @@ void main() {
final List<BloodPressureRecord> 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<BluetoothState>.fromIterable([BluetoothReady()]),
- initialState: BluetoothReady());
+ whenListen(bluetoothCubit, Stream<BluetoothState>.fromIterable([BluetoothStateReady()]),
+ initialState: BluetoothStateReady());
final deviceScanCubit = _MockDeviceScanCubit();
- final devScanOk = DeviceSelected(BluetoothDevice(remoteId: DeviceIdentifier('tstDev')));
+ final devScanOk = DeviceSelected(MockBluetoothDevice(MockBluetoothManager(), 'tstDev'));
whenListen(deviceScanCubit, Stream<DeviceScanState>.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<BleReadState>.fromIterable([bleReadOk]),
initialState: bleReadOk,
@@ -93,6 +87,7 @@ void main() {
final List<BloodPressureRecord> 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<BluetoothState>.fromIterable([BluetoothDisabled()]),
- initialState: BluetoothReady());
+ whenListen(bluetoothCubit, Stream<BluetoothState>.fromIterable([BluetoothStateDisabled()]),
+ initialState: BluetoothStateReady());
await tester.pumpWidget(materialApp(BluetoothInput(
+ manager: MockBluetoothManager(),
onMeasurement: (_) {},
bluetoothCubit: () => bluetoothCubit,
)));
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<TextFormField>(focusedTextFormField);
+ final field = tester.widget<TextFormField>(focusedTextFormField);
expect(field.initialValue, '12');
});
testWidgets('should focus next on input finished', (tester) async {
app/test/features/statistics/clock_bp_graph_test.dart
@@ -15,7 +15,7 @@ void main() {
home: Scaffold(
body: ChangeNotifierProvider<Settings>(
create: (_) => Settings(),
- child: ClockBpGraph(measurements: []),
+ child: const ClockBpGraph(measurements: []),
),
),
));
@@ -44,7 +44,7 @@ void main() {
));
await expectLater(find.byType(ClockBpGraph), myMatchesGoldenFile('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),
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),
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<SomeType>';
+ expect(Log.withoutTypes(logLine), 'SomeClass');
+ });
+}
app/windows/flutter/generated_plugin_registrant.cc
@@ -6,9 +6,12 @@
#include "generated_plugin_registrant.h"
+#include <bluetooth_low_energy_windows/bluetooth_low_energy_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
+ BluetoothLowEnergyWindowsPluginCApiRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("BluetoothLowEnergyWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
app/windows/flutter/generated_plugins.cmake
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
+ bluetooth_low_energy_windows
url_launcher_windows
)
app/pubspec.lock
@@ -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,10 +250,10 @@ 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:
@@ -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:
@@ -443,6 +507,14 @@ 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:
@@ -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: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
@@ -852,7 +924,7 @@ packages:
dependency: transitive
description: flutter
source: sdk
- version: "0.0.99"
+ version: "0.0.0"
source_gen:
dependency: transitive
description:
@@ -917,6 +989,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.4"
+ sqflite_darwin:
+ dependency: transitive
+ description:
+ name: sqflite_darwin
+ sha256: "769733dddf94622d5541c73e4ddc6aa7b252d865285914b6fcd54a63c4b4f027"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.3+1"
sqflite_darwin:
dependency: transitive
description:
@@ -969,10 +1049,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:
@@ -1001,26 +1081,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:
app/pubspec.yaml
@@ -32,11 +32,13 @@ dependencies:
health_data_store:
path: ../health_data_store/
flutter_bloc: ^8.1.6
+ bluetooth_low_energy: ^6.0.2
flutter_blue_plus: ^1.33.6
archive: ^3.6.1
file_picker: ^8.1.3
fluttertoast: ^8.2.8
app_settings: ^5.1.1
+ logging: ^1.2.0
persistent_user_dir_access_android: ^0.0.1
# desktop only
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/