Commit 51c9841
Changed files (26)
app
lib
features
old_bluetooth
logic
ui
l10n
model
screens
test
features
bluetooth
app/lib/features/input/add_measurement_dialoge.dart
@@ -7,8 +7,10 @@ import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.
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';
+import 'package:blood_pressure_app/features/old_bluetooth/bluetooth_input.dart';
import 'package:blood_pressure_app/features/settings/tiles/color_picker_list_tile.dart';
import 'package:blood_pressure_app/model/blood_pressure/pressure_unit.dart';
+import 'package:blood_pressure_app/model/storage/bluetooth_input_mode.dart';
import 'package:blood_pressure_app/model/storage/storage.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -191,6 +193,11 @@ class _AddEntryDialogeState extends State<AddEntryDialoge> {
),
);
+ void _onExternalMeasurement(BloodPressureRecord record) => setState(() {
+ final note = Note(time: record.time, note: noteController.text, color: color?.value);
+ _loadFields((record, note, []));
+ });
+
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
@@ -253,19 +260,20 @@ class _AddEntryDialogeState extends State<AddEntryDialoge> {
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 8),
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), [])),
- ),
+ (() => switch (settings.bleInput) {
+ BluetoothInputMode.disabled => SizedBox.shrink(),
+ BluetoothInputMode.oldBluetoothInput => OldBluetoothInput(
+ onMeasurement: _onExternalMeasurement,
+ ),
+ BluetoothInputMode.newBluetoothInputOldLib => BluetoothInput(
+ manager: BluetoothManager.create(isTestingEnvironment ? BluetoothBackend.mock : BluetoothBackend.flutterBluePlus),
+ onMeasurement: _onExternalMeasurement,
+ ),
+ BluetoothInputMode.newBluetoothInputCrossPlatform => BluetoothInput(
+ manager: BluetoothManager.create(isTestingEnvironment ? BluetoothBackend.mock : BluetoothBackend.bluetoothLowEnergy),
+ onMeasurement: _onExternalMeasurement,
),
+ })(),
if (settings.allowManualTimeInput)
DateTimeForm(
validate: settings.validateInputs,
app/lib/features/old_bluetooth/logic/characteristics/ble_date_time.dart
@@ -0,0 +1,32 @@
+import 'package:blood_pressure_app/features/old_bluetooth/logic/characteristics/decoding_util.dart';
+
+extension BleDateTimeParser on DateTime {
+ static DateTime? parseBle(List<int> bytes, int offset) {
+ if (bytes.length < offset + 7) return null;
+
+ final int? year = readUInt16Le(bytes, offset);
+ offset += 2;
+ final int? month = readUInt8(bytes, offset);
+ offset += 1;
+ final int? day = readUInt8(bytes, offset);
+ offset += 1;
+ final int? hourOfDay = readUInt8(bytes, offset);
+ offset += 1;
+ final int? minute = readUInt8(bytes, offset);
+ offset += 1;
+ final int? second = readUInt8(bytes, offset);
+
+ if (year == null
+ || month == null
+ || day == null
+ || hourOfDay == null
+ || minute == null
+ || second == null) return null;
+
+ if (year <= 0
+ || month <= 0
+ || day <= 0) return null;
+
+ return DateTime(year, month, day, hourOfDay, minute, second);
+ }
+}
app/lib/features/old_bluetooth/logic/characteristics/ble_measurement_data.dart
@@ -0,0 +1,110 @@
+import 'package:blood_pressure_app/logging.dart';
+
+import 'ble_date_time.dart';
+import 'ble_measurement_status.dart';
+import 'decoding_util.dart';
+
+/// 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 with TypeLogger {
+ 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,
+ });
+
+ static BleMeasurementData? decode(List<int> 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.finest('BleMeasurementData decodeMeasurement: Not enough data, $data has less than 7 bytes.');
+ return null;
+ }
+
+ int offset = 0;
+
+ final int flagsByte = data[offset];
+ offset += 1;
+
+ final bool isMMHG = !isBitIntByteSet(flagsByte, 0); // 0 => mmHg 1 =>kPA
+ final bool timestampPresent = isBitIntByteSet(flagsByte, 1);
+ final bool pulseRatePresent = isBitIntByteSet(flagsByte, 2);
+ final bool userIdPresent = isBitIntByteSet(flagsByte, 3);
+ final bool measurementStatusPresent = isBitIntByteSet(flagsByte, 4);
+
+ if (data.length < (7
+ + (timestampPresent ? 7 : 0)
+ + (pulseRatePresent ? 2 : 0)
+ + (userIdPresent ? 1 : 0)
+ + (measurementStatusPresent ? 2 : 0)
+ )) {
+ log.finest("BleMeasurementData decodeMeasurement: Flags don't match, $data has less bytes than expected.");
+ return null;
+ }
+
+ final double? systolic = readSFloat(data, offset);
+ offset += 2;
+ final double? diastolic = readSFloat(data, offset);
+ offset += 2;
+ final double? meanArterialPressure = readSFloat(data, offset);
+ offset += 2;
+
+ if (systolic == null || diastolic == null || meanArterialPressure == null) {
+ log.finest('BleMeasurementData decodeMeasurement: Unable to decode required values sys, dia, and meanArterialPressure, $data.');
+ return null;
+ }
+
+ DateTime? timestamp;
+ if (timestampPresent) {
+ timestamp = BleDateTimeParser.parseBle(data, offset);
+ offset += 7;
+ }
+
+ double? pulse;
+ if (pulseRatePresent) {
+ pulse = readSFloat(data, offset);
+ offset += 2;
+ }
+
+ int? userId;
+ if (userIdPresent) {
+ userId = data[offset];
+ offset += 1;
+ }
+
+ BleMeasurementStatus? status;
+ if (measurementStatusPresent) {
+ status = BleMeasurementStatus.decode(data[offset]);
+ }
+
+ return BleMeasurementData(
+ systolic: systolic,
+ diastolic: diastolic,
+ meanArterialPressure: meanArterialPressure,
+ isMMHG: isMMHG,
+ pulse: pulse,
+ userID: userId,
+ status: status,
+ timestamp: timestamp,
+ );
+ }
+
+ final double systolic;
+ final double diastolic;
+ final double meanArterialPressure;
+ final bool isMMHG; // mmhg or kpa
+ final double? pulse;
+ final int? userID;
+ final BleMeasurementStatus? status;
+ final DateTime? timestamp;
+
+ @override
+ String toString() => 'BleMeasurementData{systolic: $systolic, diastolic: $diastolic, meanArterialPressure: $meanArterialPressure, isMMHG: $isMMHG, pulse: $pulse, userID: $userID, status: $status, timestamp: $timestamp}';
+}
app/lib/features/old_bluetooth/logic/characteristics/ble_measurement_status.dart
@@ -0,0 +1,34 @@
+import 'package:blood_pressure_app/features/old_bluetooth/logic/characteristics/decoding_util.dart';
+
+class BleMeasurementStatus {
+ BleMeasurementStatus({
+ required this.bodyMovementDetected,
+ required this.cuffTooLose,
+ required this.irregularPulseDetected,
+ required this.pulseRateInRange,
+ required this.pulseRateExceedsUpperLimit,
+ required this.pulseRateIsLessThenLowerLimit,
+ required this.improperMeasurementPosition,
+ });
+
+ factory BleMeasurementStatus.decode(int byte) => BleMeasurementStatus(
+ bodyMovementDetected: isBitIntByteSet(byte, 1),
+ cuffTooLose: isBitIntByteSet(byte, 2),
+ irregularPulseDetected: isBitIntByteSet(byte, 3),
+ pulseRateInRange: (byte & (1 << 4) >> 3) == 0,
+ pulseRateExceedsUpperLimit: (byte & (1 << 4) >> 3) == 1,
+ pulseRateIsLessThenLowerLimit: (byte & (1 << 4) >> 3) == 2,
+ improperMeasurementPosition: isBitIntByteSet(byte, 5),
+ );
+
+ final bool bodyMovementDetected;
+ final bool cuffTooLose;
+ final bool irregularPulseDetected;
+ final bool pulseRateInRange;
+ final bool pulseRateExceedsUpperLimit;
+ final bool pulseRateIsLessThenLowerLimit;
+ final bool improperMeasurementPosition;
+
+ @override
+ String toString() => 'BleMeasurementStatus{bodyMovementDetected: $bodyMovementDetected, cuffTooLose: $cuffTooLose, irregularPulseDetected: $irregularPulseDetected, pulseRateInRange: $pulseRateInRange, pulseRateExceedsUpperLimit: $pulseRateExceedsUpperLimit, pulseRateIsLessThenLowerLimit: $pulseRateIsLessThenLowerLimit, improperMeasurementPosition: $improperMeasurementPosition}';
+}
app/lib/features/old_bluetooth/logic/characteristics/decoding_util.dart
@@ -0,0 +1,33 @@
+import 'dart:math';
+
+/// Whether the bit at offset (0-7) is set.
+///
+/// Masks the byte with a 1 that has [offset] to the right and moves the
+/// remaining bit to the first position and checks if it's equal to 1.
+bool isBitIntByteSet(int byte, int offset) =>
+ (((byte & (1 << offset)) >> offset) == 1);
+
+/// Attempts to read an IEEE-11073 16bit SFloat starting at data[offset].
+double? readSFloat(List<int> data, int offset) {
+ if (data.length < offset + 2) return null;
+ // TODO: special values (NaN, Infinity)
+ // If this ever stops working: https://github.com/NordicSemiconductor/Kotlin-BLE-Library/blob/6b565e59de21dfa53ef80ff8351ac4a4550e8d58/core/src/main/java/no/nordicsemi/android/kotlin/ble/core/data/util/DataByteArray.kt#L392
+ final mantissa = data[offset] + ((data[offset + 1] & 0x0F) << 8);
+ final exponent = data[offset + 1] >> 4;
+ return (mantissa * (pow(10, exponent))).toDouble();
+}
+
+int? readUInt8(List<int> data, int offset) {
+ if (data.length < offset + 1) return null;
+ return data[offset];
+}
+
+int? readUInt16Le(List<int> data, int offset) {
+ if (data.length < offset + 2) return null;
+ return data[offset] + (data[offset+1] << 8);
+}
+
+int? readUInt16Be(List<int> data, int offset) {
+ if (data.length < offset + 2) return null;
+ return (data[offset] << 8) + data[offset + 1];
+}
app/lib/features/old_bluetooth/logic/ble_read_cubit.dart
@@ -0,0 +1,186 @@
+import 'dart:async';
+
+import 'package:blood_pressure_app/features/old_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';
+
+/// Logic for reading a characteristic from a device through a "indication".
+///
+/// For using this Cubit the flow is as follows:
+/// 1. Create a instance with the initial [BleReadInProgress] state
+/// 2. Wait for either a [BleReadFailure] or a [BleReadSuccess].
+///
+/// When a read failure is emitted, the only way to try again is to create a new
+/// cubit. This should be accompanied with reconnecting to the [_device].
+///
+/// Internally the class performs multiple steps to successfully read data, if
+/// one of them fails the entire cubit fails:
+/// 1. Discover services
+/// 2. If the searched service is found read characteristics
+/// 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> 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
+ _timeoutTimer = Timer(const Duration(minutes: 2), () {
+ if (state is BleReadInProgress) {
+ logger.finest('BleReadCubit timeout reached and still running');
+ emit(BleReadFailure());
+ } else {
+ logger.finest('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.
+ final BluetoothDevice _device;
+
+ late final StreamSubscription<BluetoothConnectionState> _subscription;
+ late final Timer _timeoutTimer;
+ StreamSubscription<List<int>>? _indicationListener;
+
+ @override
+ Future<void> close() async {
+ logger.finest('BleReadCubit close');
+ await _subscription.cancel();
+ _timeoutTimer.cancel();
+
+ if (_device.isConnected) {
+ try {
+ logger.finest('BleReadCubit close: Attempting disconnect from ${_device.advName}');
+ await _device.disconnect();
+ assert(_device.isDisconnected);
+ } catch (e) {
+ logger.severe('unable to disconnect', [e, _device]);
+ }
+ }
+
+ await super.close();
+ }
+
+ bool _ensureConnectionInProgress = false;
+ Future<void> _ensureConnection([int attemptCount = 0]) async {
+ logger.finest('BleReadCubit _ensureConnection');
+ if (_ensureConnectionInProgress) return;
+ _ensureConnectionInProgress = true;
+
+ if (_device.isAutoConnectEnabled) {
+ logger.finest('BleReadCubit Waiting for auto connect...');
+ _ensureConnectionInProgress = false;
+ return;
+ }
+
+ if (_device.isDisconnected) {
+ logger.finest('BleReadCubit _ensureConnection: Attempting to connect with ${_device.advName}');
+ try {
+ await _device.connect();
+ } on FlutterBluePlusException catch (e) {
+ logger.severe('BleReadCubit _device.connect failed:', [_device, e]);
+ }
+
+ if (_device.isDisconnected) {
+ logger.finest('BleReadCubit _ensureConnection: Device not connected');
+ _ensureConnectionInProgress = false;
+ if (attemptCount >= 5) {
+ emit(BleReadFailure());
+ return;
+ } else {
+ return _ensureConnection(attemptCount + 1);
+ }
+ } else {
+ logger.finest('BleReadCubit Connection successful');
+ }
+ }
+ assert(_device.isConnected);
+ _ensureConnectionInProgress = false;
+ }
+
+ Future<void> _onConnectionStateChanged(BluetoothConnectionState state) async {
+ logger.finest('BleReadCubit _onConnectionStateChanged: $state');
+ if (super.state is BleReadSuccess) return;
+ if (state == BluetoothConnectionState.disconnected) {
+ logger.finest('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();
+ logger.finest('BleReadCubit allServices: $allServices');
+ } catch (e) {
+ logger.severe('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) {
+ logger.severe('unsupported service', [_device, allServices]);
+ emit(BleReadFailure());
+ return;
+ }
+
+ // 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;
+ logger.finest('BleReadCubit allCharacteristics: $allCharacteristics');
+ final BluetoothCharacteristic? characteristic = allCharacteristics
+ .firstWhereOrNull((c) => c.uuid == characteristicUUID,);
+ if (characteristic == null) {
+ logger.severe('no characteristic', [_device, allServices, allCharacteristics]);
+ emit(BleReadFailure());
+ return;
+ }
+
+ // This characteristic only supports indication so we need to listen to values.
+ await _indicationListener?.cancel();
+ _indicationListener = characteristic
+ .onValueReceived.listen((rawData) {
+ logger.finest('BleReadCubit data received: $rawData');
+ final decodedData = BleMeasurementData.decode(rawData, 0);
+ if (decodedData == null) {
+ logger.severe('BleReadCubit decoding failed', [ rawData ]);
+ emit(BleReadFailure());
+ } else {
+ logger.finest('BleReadCubit decoded: $decodedData');
+ emit(BleReadSuccess(decodedData));
+ }
+ _indicationListener?.cancel();
+ _indicationListener = null;
+ });
+
+ final bool indicationsSet = await characteristic.setNotifyValue(true);
+ logger.finest('BleReadCubit indicationsSet: $indicationsSet');
+ }
+}
app/lib/features/old_bluetooth/logic/ble_read_state.dart
@@ -0,0 +1,20 @@
+part of 'ble_read_cubit.dart';
+
+/// State of reading a characteristic from a BLE device.
+@immutable
+sealed class BleReadState {}
+
+/// The reading has been started.
+class BleReadInProgress extends BleReadState {}
+
+/// The reading failed unrecoverable for some reason.
+class BleReadFailure extends BleReadState {}
+
+/// Data has been successfully read.
+class BleReadSuccess extends BleReadState {
+ /// Indicate a successful reading of a ble characteristic.
+ BleReadSuccess(this.data);
+
+ /// Measurement decoded from the device.
+ final BleMeasurementData data;
+}
app/lib/features/old_bluetooth/logic/bluetooth_cubit.dart
@@ -0,0 +1,80 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:blood_pressure_app/features/old_bluetooth/logic/flutter_blue_plus_mockable.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> {
+ /// 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;
+
+ BluetoothAdapterState _adapterState = BluetoothAdapterState.unknown;
+
+ late StreamSubscription<BluetoothAdapterState> _adapterStateStateSubscription;
+
+ @override
+ Future<void> close() async {
+ await _adapterStateStateSubscription.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());
+ }
+ }
+
+ /// Request to enable bluetooth on the device
+ Future<bool> enableBluetooth() async {
+ assert(state is BluetoothDisabled, '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 false;
+ }
+ }
+
+ /// Reevaluate the current state.
+ ///
+ /// When the user is in another app like the device settings, sometimes
+ /// 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);
+ }
+}
app/lib/features/old_bluetooth/logic/bluetooth_state.dart
@@ -0,0 +1,26 @@
+part of 'bluetooth_cubit.dart';
+
+/// State of the devices bluetooth module.
+@immutable
+sealed class BluetoothState {}
+
+/// No information on whether bluetooth is available.
+///
+/// Users may show a loading indication but can not assume bluetooth is
+/// available.
+class BluetoothInitial 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 {}
+
+/// There is a bluetooth sensor but the app has no permission.
+class BluetoothUnauthorized extends BluetoothState {}
+
+/// The device has Bluetooth and the app has permissions, but it is disabled in
+/// the device settings.
+class BluetoothDisabled extends BluetoothState {}
+
+/// Bluetooth is ready for use by the app.
+class BluetoothReady extends BluetoothState {}
app/lib/features/old_bluetooth/logic/device_scan_cubit.dart
@@ -0,0 +1,112 @@
+import 'dart:async';
+
+import 'package:blood_pressure_app/features/old_bluetooth/logic/bluetooth_cubit.dart';
+import 'package:blood_pressure_app/features/old_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';
+
+/// A component to search for bluetooth devices.
+///
+/// For this to work the app must have access to the bluetooth adapter
+/// ([BluetoothCubit]).
+///
+/// A device counts as recognized, when the user connected with it at least
+/// once. Recognized devices connect automatically.
+class DeviceScanCubit extends Cubit<DeviceScanState> with TypeLogger {
+ /// Search for bluetooth devices that match the criteria or are known
+ /// ([Settings.knownBleDev]).
+ DeviceScanCubit({
+ FlutterBluePlusMockable? flutterBluePlus,
+ required this.service,
+ required this.settings,
+ }) : _flutterBluePlus = flutterBluePlus ?? FlutterBluePlusMockable(),
+ super(DeviceListLoading()) {
+ assert(!_flutterBluePlus.isScanningNow);
+ _startScanning();
+ }
+
+ /// Storage for known devices.
+ final Settings settings;
+
+ /// Service required from bluetooth devices.
+ final Guid service;
+
+ final FlutterBluePlusMockable _flutterBluePlus;
+
+ late StreamSubscription<List<ScanResult>> _scanResultsSubscription;
+
+ @override
+ Future<void> close() async {
+ await _scanResultsSubscription.cancel();
+ try {
+ await _flutterBluePlus.stopScan();
+ } catch (e) {
+ logger.severe('Failed to stop scanning', [e]);
+ return;
+ }
+ 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).
+ );
+ } catch (e) {
+ _onScanError(e);
+ }
+ }
+
+ void _onScanResult(List<ScanResult> devices) {
+ logger.finest('_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;
+ final preferred = devices.firstWhereOrNull((dev) =>
+ settings.knownBleDev.contains(dev.device.platformName));
+ if (preferred != null) {
+ _flutterBluePlus.stopScan()
+ .then((_) => emit(DeviceSelected(preferred.device)));
+ } else if (devices.isEmpty) {
+ emit(DeviceListLoading());
+ } else if (devices.length == 1) {
+ emit(SingleDeviceAvailable(devices.first));
+ } else {
+ emit(DeviceListAvailable(devices));
+ }
+ }
+
+ void _onScanError(Object error) {
+ logger.severe('Starting device scan failed', [ 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();
+ } catch (e) {
+ _onScanError(e);
+ return;
+ }
+ assert(!_flutterBluePlus.isScanningNow);
+ emit(DeviceSelected(device));
+ final List<String> list = settings.knownBleDev.toList();
+ list.add(device.platformName);
+ settings.knownBleDev = list;
+ }
+}
app/lib/features/old_bluetooth/logic/device_scan_state.dart
@@ -0,0 +1,38 @@
+part of 'device_scan_cubit.dart';
+
+/// Search of bluetooth devices that meet some criteria.
+@immutable
+sealed class DeviceScanState {}
+
+/// Searching for devices or a reason they are not available.
+class DeviceListLoading extends DeviceScanState {}
+
+/// A device has been selected, either automatically or by the user.
+class DeviceSelected extends DeviceScanState {
+ /// Indicate that a device has been selected.
+ DeviceSelected(this.device);
+
+ /// The selected device.
+ final BluetoothDevice device;
+}
+
+/// Multiple unrecognized devices.
+class DeviceListAvailable extends DeviceScanState {
+ /// Indicate that multiple unrecognized have been found.
+ DeviceListAvailable(this.devices);
+
+ /// All found devices.
+ final List<ScanResult> devices;
+}
+
+/// One unrecognized device has been found.
+///
+/// While not technically correct, this can be understood as a connection
+/// request the user has to accept.
+class SingleDeviceAvailable extends DeviceScanState {
+ /// Indicate that one unrecognized device has been found.
+ SingleDeviceAvailable(this.device);
+
+ /// The only found device.
+ final ScanResult device;
+}
app/lib/features/old_bluetooth/logic/flutter_blue_plus_mockable.dart
@@ -0,0 +1,139 @@
+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
+class FlutterBluePlusMockable {
+ LogLevel get logLevel => FlutterBluePlus.logLevel;
+
+ /// Checks whether the hardware supports Bluetooth
+ Future<bool> get isSupported => FlutterBluePlus.isSupported;
+
+ /// The current adapter state
+ BluetoothAdapterState get adapterStateNow => FlutterBluePlus.adapterStateNow;
+
+ /// Return the friendly Bluetooth name of the local Bluetooth adapter
+ Future<String> get adapterName => FlutterBluePlus.adapterName;
+
+ /// returns whether we are scanning as a stream
+ Stream<bool> get isScanning => FlutterBluePlus.isScanning;
+
+ /// are we scanning right now?
+ bool get isScanningNow => FlutterBluePlus.isScanningNow;
+
+ /// the most recent scan results
+ List<ScanResult> get lastScanResults => FlutterBluePlus.lastScanResults;
+
+ /// a stream of scan results
+ /// - if you re-listen to the stream it re-emits the previous results
+ /// - the list contains all the results since the scan started
+ /// - the returned stream is never closed.
+ Stream<List<ScanResult>> get scanResults => FlutterBluePlus.scanResults;
+
+ /// This is the same as scanResults, except:
+ /// - it *does not* re-emit previous results after scanning stops.
+ Stream<List<ScanResult>> get onScanResults => FlutterBluePlus.onScanResults;
+
+ /// Get access to all device event streams
+ BluetoothEvents get events => FlutterBluePlus.events;
+
+ /// Gets the current state of the Bluetooth module
+ Stream<BluetoothAdapterState> get adapterState =>
+ FlutterBluePlus.adapterState;
+
+ /// Retrieve a list of devices currently connected to your app
+ List<BluetoothDevice> get connectedDevices =>
+ FlutterBluePlus.connectedDevices;
+
+ /// Retrieve a list of devices currently connected to the system
+ /// - The list includes devices connected to by *any* app
+ /// - You must still call device.connect() to connect them to *your app*
+ Future<List<BluetoothDevice>> systemDevices(List<Guid> withServices) =>
+ FlutterBluePlus.systemDevices(withServices);
+
+ /// Retrieve a list of bonded devices (Android only)
+ Future<List<BluetoothDevice>> get bondedDevices =>
+ FlutterBluePlus.bondedDevices;
+
+ /// Set configurable options
+ /// - [showPowerAlert] Whether to show the power alert (iOS & MacOS only). i.e. CBCentralManagerOptionShowPowerAlertKey
+ /// To set this option you must call this method before any other method in this package.
+ /// See: https://developer.apple.com/documentation/corebluetooth/cbcentralmanageroptionshowpoweralertkey
+ /// This option has no effect on Android.
+ Future<void> setOptions({
+ bool showPowerAlert = true,
+ }) => FlutterBluePlus.setOptions(showPowerAlert: showPowerAlert);
+
+ /// Turn on Bluetooth (Android only),
+ Future<void> turnOn({int timeout = 60}) =>
+ FlutterBluePlus.turnOn(timeout: timeout);
+
+ /// Start a scan, and return a stream of results
+ /// Note: scan filters use an "or" behavior. i.e. if you set `withServices` & `withNames` we
+ /// return all the advertisments that match any of the specified services *or* any of the specified names.
+ /// - [withServices] filter by advertised services
+ /// - [withRemoteIds] filter for known remoteIds (iOS: 128-bit guid, android: 48-bit mac address)
+ /// - [withNames] filter by advertised names (exact match)
+ /// - [withKeywords] filter by advertised names (matches any substring)
+ /// - [withMsd] filter by manfacture specific data
+ /// - [withServiceData] filter by service data
+ /// - [timeout] calls stopScan after a specified duration
+ /// - [removeIfGone] if true, remove devices after they've stopped advertising for X duration
+ /// - [continuousUpdates] If `true`, we continually update 'lastSeen' & 'rssi' by processing
+ /// duplicate advertisements. This takes more power. You typically should not use this option.
+ /// - [continuousDivisor] Useful to help performance. If divisor is 3, then two-thirds of advertisements are
+ /// ignored, and one-third are processed. This reduces main-thread usage caused by the platform channel.
+ /// The scan counting is per-device so you always get the 1st advertisement from each device.
+ /// If divisor is 1, all advertisements are returned. This argument only matters for `continuousUpdates` mode.
+ /// - [oneByOne] if `true`, we will stream every advertistment one by one, possibly including duplicates.
+ /// If `false`, we deduplicate the advertisements, and return a list of devices.
+ /// - [androidScanMode] choose the android scan mode to use when scanning
+ /// - [androidUsesFineLocation] request `ACCESS_FINE_LOCATION` permission at runtime
+ Future<void> startScan({
+ List<Guid> withServices = const [],
+ List<String> withRemoteIds = const [],
+ List<String> withNames = const [],
+ List<String> withKeywords = const [],
+ List<MsdFilter> withMsd = const [],
+ List<ServiceDataFilter> withServiceData = const [],
+ Duration? timeout,
+ Duration? removeIfGone,
+ bool continuousUpdates = false,
+ int continuousDivisor = 1,
+ bool oneByOne = false,
+ AndroidScanMode androidScanMode = AndroidScanMode.lowLatency,
+ bool androidUsesFineLocation = false,
+ }) => FlutterBluePlus.startScan(
+ withServices: withServices,
+ withRemoteIds: withRemoteIds,
+ withNames: withNames,
+ withKeywords: withKeywords,
+ withMsd: withMsd,
+ withServiceData: withServiceData,
+ timeout: timeout,
+ removeIfGone: removeIfGone,
+ continuousUpdates: continuousUpdates,
+ continuousDivisor: continuousDivisor,
+ oneByOne: oneByOne,
+ androidScanMode: androidScanMode,
+ androidUsesFineLocation: androidUsesFineLocation,
+ );
+
+ /// Stops a scan for Bluetooth Low Energy devices
+ Future<void> stopScan() => FlutterBluePlus.stopScan();
+
+ /// Register a subscription to be canceled when scanning is complete.
+ /// This function simplifies cleanup, to prevent creating duplicate stream subscriptions.
+ /// - this is an optional convenience function
+ /// - prevents accidentally creating duplicate subscriptions before each scan
+ void cancelWhenScanComplete(StreamSubscription subscription) =>
+ FlutterBluePlus.cancelWhenScanComplete(subscription);
+
+ /// Sets the internal FlutterBlue log level
+ Future<void> setLogLevel(LogLevel level, {bool color = true}) =>
+ FlutterBluePlus.setLogLevel(level, color: color);
+
+ /// Request Bluetooth PHY support
+ Future<PhySupport> getPhySupport() => FlutterBluePlus.getPhySupport();
+}
\ No newline at end of file
app/lib/features/old_bluetooth/ui/closed_bluetooth_input.dart
@@ -0,0 +1,75 @@
+import 'package:app_settings/app_settings.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/logic/bluetooth_cubit.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 {
+ /// Show adapter state and allow starting inputs
+ const ClosedBluetoothInput({super.key,
+ required this.bluetoothCubit,
+ required this.onStarted,
+ this.inputInfo,
+ });
+
+ /// State update provider and interaction with the device.
+ final BluetoothCubit bluetoothCubit;
+
+ /// Called when the user taps on an active start button.
+ final void Function() onStarted;
+
+ /// Callback called when the user wants to know more about this input.
+ ///
+ /// The info icon is not shown when this is null.
+ final void Function()? inputInfo;
+
+ Widget _buildTile({
+ required String text,
+ required IconData icon,
+ required void Function() onTap,
+ }) => ListTile(
+ title: Text(text),
+ leading: Icon(icon),
+ onTap: onTap,
+ trailing: inputInfo == null ? null : IconButton(
+ icon: const Icon(Icons.info_outline),
+ onPressed: inputInfo!,
+ ),
+ );
+
+ @override
+ Widget build(BuildContext context) {
+ 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,
+ ),
+ },
+ );
+ }
+
+}
app/lib/features/old_bluetooth/ui/device_selection.dart
@@ -0,0 +1,44 @@
+import 'package:blood_pressure_app/features/old_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.
+class DeviceSelection extends StatelessWidget {
+ /// Create a pairing dialoge with a single bluetooth device.
+ const DeviceSelection({super.key,
+ required this.scanResults,
+ required this.onAccepted,
+ });
+
+ /// The name of the device trying to connect.
+ final List<ScanResult> 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),
+ trailing: FilledButton(
+ onPressed: () => onAccepted(dev.device),
+ child: Text(AppLocalizations.of(context)!.connect),
+ ),
+ onTap: () => onAccepted(dev.device),
+ );
+
+ @override
+ Widget build(BuildContext context) {
+ assert(scanResults.isNotEmpty);
+ return InputCard(
+ title: Text(AppLocalizations.of(context)!.availableDevices),
+ child: ListView(
+ shrinkWrap: true,
+ children: [
+ for (final dev in scanResults)
+ _buildDeviceTile(context, dev),
+ ]
+ ),
+ );
+ }
+
+}
app/lib/features/old_bluetooth/ui/input_card.dart
@@ -0,0 +1,70 @@
+import 'package:flutter/material.dart';
+
+/// Card to place a complex opened input on.
+class InputCard extends StatelessWidget {
+ /// Create a card to host a complex input.
+ const InputCard({super.key,
+ required this.child,
+ this.title,
+ this.onClosed
+ });
+
+ /// Main content of the card
+ final Widget child;
+
+ /// Description of the card or the state of the card.
+ final Widget? title;
+
+ /// When provided a close icon at the top left corner is shown.
+ final void Function()? onClosed;
+
+ Widget _buildCloseIcon() => Align(
+ alignment: Alignment.topRight,
+ child: IconButton(
+ icon: const Icon(Icons.close),
+ onPressed: onClosed!,
+ ),
+ );
+
+ Widget _buildTitle(BuildContext context) => Align(
+ alignment: Alignment.topLeft,
+ child: Padding(
+ padding: const EdgeInsets.only(
+ top: 8.0,
+ left: 16.0,
+ ),
+ child: DefaultTextStyle(
+ style: Theme.of(context).textTheme.titleMedium ?? const TextStyle(),
+ child: title!,
+ ),
+ ),
+ );
+
+ Widget _buildBody() => Padding( // content
+ padding: EdgeInsets.only(
+ top: (title == null) ? 12.0 : 42.0,
+ bottom: 8.0,
+ left: 8.0,
+ right: 8.0,
+ ),
+ child: Center(
+ child: child,
+ ),
+ );
+
+ @override
+ Widget build(BuildContext context) => Card(
+ color: Theme.of(context).cardColor,
+ margin: const EdgeInsets.only(top: 8.0, bottom: 16.0),
+ child: Stack(
+ children: [
+ _buildBody(),
+ if (title != null)
+ _buildTitle(context),
+ if (onClosed != null)
+ _buildCloseIcon(),
+ ],
+ ),
+ );
+
+}
app/lib/features/old_bluetooth/ui/measurement_failure.dart
@@ -0,0 +1,34 @@
+import 'package:blood_pressure_app/features/old_bluetooth/ui/input_card.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 {
+ /// Indicate a failure while taking a bluetooth measurement.
+ const MeasurementFailure({super.key, required this.onTap});
+
+ /// Called when the user requests closing.
+ final void Function() onTap;
+
+ @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,),
+ ],
+ ),
+ ),
+ ),
+ );
+
+}
app/lib/features/old_bluetooth/ui/measurement_success.dart
@@ -0,0 +1,84 @@
+import 'package:blood_pressure_app/features/old_bluetooth/logic/characteristics/ble_measurement_data.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/ui/input_card.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// Indication of a successful bluetooth measurement.
+class MeasurementSuccess extends StatelessWidget {
+ /// Indicate a successful while taking a bluetooth measurement.
+ const MeasurementSuccess({super.key,
+ required this.onTap,
+ required this.data,
+ });
+
+ /// Data decoded from bluetooth.
+ final BleMeasurementData data;
+
+ /// Called when the user requests closing.
+ final void Function() onTap;
+
+ @override
+ Widget build(BuildContext context) => GestureDetector(
+ onTap: onTap,
+ child: InputCard(
+ onClosed: onTap,
+ child: Center(
+ child: ListTileTheme(
+ data: ListTileThemeData(
+ iconColor: Colors.orange,
+ ),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Icon(Icons.done, color: Colors.green),
+ const SizedBox(height: 8,),
+ Text(AppLocalizations.of(context)!.measurementSuccess,
+ style: Theme.of(context).textTheme.titleMedium,),
+ const SizedBox(height: 8,),
+ ListTile(
+ title: Text(AppLocalizations.of(context)!.meanArterialPressure),
+ subtitle: Text(data.meanArterialPressure.round().toString()),
+ ),
+ if (data.userID != null)
+ ListTile(
+ title: Text(AppLocalizations.of(context)!.userID),
+ subtitle: Text(data.userID!.toString()),
+ ),
+ if (data.status?.bodyMovementDetected ?? false)
+ ListTile(
+ title: Text(AppLocalizations.of(context)!.bodyMovementDetected),
+ leading: Icon(Icons.directions_walk),
+ ),
+ if (data.status?.cuffTooLose ?? false)
+ ListTile(
+ title: Text(AppLocalizations.of(context)!.cuffTooLoose),
+ leading: Icon(Icons.space_bar),
+ ),
+ if (data.status?.improperMeasurementPosition ?? false)
+ ListTile(
+ title: Text(AppLocalizations.of(context)!.improperMeasurementPosition),
+ leading: Icon(Icons.emoji_people),
+ ),
+ if (data.status?.irregularPulseDetected ?? false)
+ ListTile(
+ title: Text(AppLocalizations.of(context)!.irregularPulseDetected),
+ leading: Icon(Icons.heart_broken),
+ ),
+ if (data.status?.pulseRateExceedsUpperLimit ?? false)
+ ListTile(
+ title: Text(AppLocalizations.of(context)!.pulseRateExceedsUpperLimit),
+ leading: Icon(Icons.monitor_heart),
+ ),
+ if (data.status?.pulseRateIsLessThenLowerLimit ?? false)
+ ListTile(
+ title: Text(AppLocalizations.of(context)!.pulseRateLessThanLowerLimit),
+ leading: Icon(Icons.monitor_heart),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+}
app/lib/features/old_bluetooth/bluetooth_input.dart
@@ -0,0 +1,221 @@
+import 'dart:async';
+
+import 'package:blood_pressure_app/features/bluetooth/bluetooth_input.dart' show BluetoothInput;
+import 'package:blood_pressure_app/features/old_bluetooth/logic/ble_read_cubit.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/logic/bluetooth_cubit.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/logic/characteristics/ble_measurement_data.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/logic/device_scan_cubit.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/ui/closed_bluetooth_input.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/ui/device_selection.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/ui/input_card.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/ui/measurement_failure.dart';
+import 'package:blood_pressure_app/features/old_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';
+
+/// Class for inputting measurement through bluetooth.
+///
+/// This widget is superseded by [BluetoothInput].
+class OldBluetoothInput extends StatefulWidget {
+ /// Create a measurement input through bluetooth.
+ const OldBluetoothInput({super.key,
+ required this.onMeasurement,
+ this.bluetoothCubit,
+ this.deviceScanCubit,
+ this.bleReadCubit,
+ });
+
+ /// Called when a measurement was received through bluetooth.
+ final void Function(BloodPressureRecord data) onMeasurement;
+
+ /// Function to customize [BluetoothCubit] creation.
+ final BluetoothCubit Function()? bluetoothCubit;
+
+ /// Function to customize [DeviceScanCubit] creation.
+ final DeviceScanCubit Function()? deviceScanCubit;
+
+ /// Function to customize [BleReadCubit] creation.
+ final BleReadCubit Function(BluetoothDevice dev)? bleReadCubit;
+
+ @override
+ State<OldBluetoothInput> createState() => _OldBluetoothInputState();
+}
+
+class _OldBluetoothInputState extends State<OldBluetoothInput> with TypeLogger {
+ /// Whether the user expanded bluetooth input
+ bool _isActive = false;
+
+ late final BluetoothCubit _bluetoothCubit;
+ DeviceScanCubit? _deviceScanCubit;
+ BleReadCubit? _deviceReadCubit;
+
+ StreamSubscription<BluetoothState>? _bluetoothSubscription;
+
+ /// Data received from reading bluetooth values.
+ ///
+ /// Its presence indicates that this input is done.
+ BleMeasurementData? _finishedData;
+
+ @override
+ void initState() {
+ super.initState();
+ _bluetoothCubit = widget.bluetoothCubit?.call() ?? BluetoothCubit();
+ }
+
+ @override
+ void dispose() {
+ unawaited(_bluetoothSubscription?.cancel());
+ unawaited(_bluetoothCubit.close());
+ unawaited(_deviceScanCubit?.close());
+ unawaited(_deviceReadCubit?.close());
+ super.dispose();
+ }
+
+ void _returnToIdle() async {
+ // No need to show wait in the UI.
+ if (_isActive) {
+ setState(() {
+ _isActive = false;
+ _finishedData = null;
+ });
+ }
+
+ await _deviceReadCubit?.close();
+ _deviceReadCubit = null;
+ await _deviceScanCubit?.close();
+ _deviceScanCubit = null;
+ await _bluetoothSubscription?.cancel();
+ _bluetoothSubscription = null;
+ }
+
+ Widget _buildActive(BuildContext context) {
+ final Guid serviceUUID = Guid('1810');
+ final Guid characteristicUUID = Guid('2A35');
+ _bluetoothSubscription = _bluetoothCubit.stream.listen((state) {
+ if (state is! BluetoothReady) {
+ logger.finest('_OldBluetoothInputState: _bluetoothSubscription state=$state, calling _returnToIdle');
+ _returnToIdle();
+ }
+ });
+ final settings = context.watch<Settings>();
+ _deviceScanCubit ??= widget.deviceScanCubit?.call() ?? DeviceScanCubit(
+ service: serviceUUID,
+ settings: settings,
+ );
+ return BlocBuilder<DeviceScanCubit, DeviceScanState>(
+ bloc: _deviceScanCubit,
+ builder: (context, DeviceScanState state) {
+ logger.finest('OldBluetoothInput _OldBluetoothInputState _deviceScanCubit: $state');
+ const SizeChangedLayoutNotification().dispatch(context);
+ return switch(state) {
+ DeviceListLoading() => _buildMainCard(context,
+ title: Text(AppLocalizations.of(context)!.scanningForDevices),
+ child: const CircularProgressIndicator(),
+ ),
+ DeviceListAvailable() => DeviceSelection(
+ scanResults: state.devices,
+ onAccepted: (dev) => _deviceScanCubit!.acceptDevice(dev),
+ ),
+ SingleDeviceAvailable() => DeviceSelection(
+ 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) {
+ logger.finest('_OldBluetoothInputState 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,
+ ),
+ };
+ },
+ ),
+ };
+ },
+ );
+ }
+
+ @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(),
+ ),
+ ],
+ ),
+ );
+ }
+ },
+ );
+ }
+
+ Widget _buildMainCard(BuildContext context, {
+ required Widget child,
+ Widget? title,
+ }) => InputCard(
+ onClosed: _returnToIdle,
+ title: title,
+ child: child,
+ );
+}
app/lib/l10n/app_en.arb
@@ -542,5 +542,15 @@
"preferredWeightUnit": "Preferred weight unit",
"@preferredWeightUnit": {
"description": "Setting for the unit the app will use for displaying weight"
- }
+ },
+ "disabled": "Disabled",
+ "@disabled": {},
+ "oldBluetoothInput": "Stable",
+ "@oldBluetoothInput": {},
+ "newBluetoothInputOldLib": "Beta",
+ "@newBluetoothInputOldLib": {},
+ "newBluetoothInputCrossPlatform": "Beta cross-platform",
+ "@newBluetoothInputCrossPlatform": {},
+ "bluetoothInputDesc": "The beta backend works on more devices but is less tested. The cross-platform version may work on non-android and is planned to supersede the stable implementation once mature enough.",
+ "@bluetoothInputDesc": {}
}
\ No newline at end of file
app/lib/model/storage/bluetooth_input_mode.dart
@@ -0,0 +1,38 @@
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// Different modes for the bluetooth input field.
+enum BluetoothInputMode {
+ /// No bluetooth input.
+ disabled,
+ /// The established bluetooth input.
+ oldBluetoothInput,
+ /// The new bluetooth input with flutter_blue_plus backend.
+ newBluetoothInputOldLib,
+ /// The new bluetooth input with bluetooth_low_energy backend.
+ newBluetoothInputCrossPlatform;
+
+ /// Turn object into [deserialize]able number.
+ int serialize() => switch(this) {
+ BluetoothInputMode.disabled => 0,
+ BluetoothInputMode.oldBluetoothInput => 1,
+ BluetoothInputMode.newBluetoothInputOldLib => 2,
+ BluetoothInputMode.newBluetoothInputCrossPlatform => 3,
+ };
+
+ /// Try to create an object from [serialize]d form.
+ static BluetoothInputMode? deserialize(int? value) => switch (value) {
+ 0 => BluetoothInputMode.disabled,
+ 1 => BluetoothInputMode.oldBluetoothInput,
+ 2 => BluetoothInputMode.newBluetoothInputOldLib,
+ 3 => BluetoothInputMode.newBluetoothInputCrossPlatform,
+ _ => null,
+ };
+
+ /// Determine the matching localization.
+ String localize(AppLocalizations localizations) => switch(this) {
+ BluetoothInputMode.disabled => localizations.disabled,
+ BluetoothInputMode.oldBluetoothInput => localizations.oldBluetoothInput,
+ BluetoothInputMode.newBluetoothInputOldLib => localizations.newBluetoothInputOldLib,
+ BluetoothInputMode.newBluetoothInputCrossPlatform => localizations.newBluetoothInputCrossPlatform,
+ };
+}
app/lib/model/storage/settings_store.dart
@@ -2,9 +2,11 @@ import 'dart:collection';
import 'dart:convert';
import 'package:blood_pressure_app/config.dart';
+import 'package:blood_pressure_app/features/bluetooth/logic/device_scan_cubit.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/bluetooth_input_mode.dart';
import 'package:blood_pressure_app/model/storage/convert_util.dart';
import 'package:blood_pressure_app/model/weight_unit.dart';
import 'package:flutter/material.dart';
@@ -48,7 +50,7 @@ class Settings extends ChangeNotifier {
PressureUnit? preferredPressureUnit,
List<String>? knownBleDev,
int? highestMedIndex,
- bool? bleInput,
+ BluetoothInputMode? bleInput,
bool? weightInput,
WeightUnit? weightUnit,
}) {
@@ -113,7 +115,7 @@ class Settings extends ChangeNotifier {
Medicine.fromJson(jsonDecode(e)),).toList(),
highestMedIndex: ConvertUtil.parseInt(map['highestMedIndex']),
knownBleDev: ConvertUtil.parseList<String>(map['knownBleDev']),
- bleInput: ConvertUtil.parseBool(map['bleInput']),
+ bleInput: BluetoothInputMode.deserialize(ConvertUtil.parseInt(map['bleInput'])),
weightInput: ConvertUtil.parseBool(map['weightInput']),
preferredPressureUnit: PressureUnit.decode(ConvertUtil.parseInt(map['preferredPressureUnit'])),
weightUnit: WeightUnit.deserialize(ConvertUtil.parseInt(map['weightUnit'])),
@@ -163,7 +165,7 @@ class Settings extends ChangeNotifier {
'highestMedIndex': highestMedIndex,
'preferredPressureUnit': preferredPressureUnit.encode(),
'knownBleDev': knownBleDev,
- 'bleInput': bleInput,
+ 'bleInput': bleInput.serialize(),
'weightInput': weightInput,
'weightUnit': weightUnit.serialized,
};
@@ -409,11 +411,11 @@ class Settings extends ChangeNotifier {
notifyListeners();
}
- bool _bleInput = true;
+ BluetoothInputMode _bleInput = BluetoothInputMode.oldBluetoothInput;
/// Whether to show bluetooth input on add measurement page.
- bool get bleInput => isPlatformSupportedBluetooth && _bleInput;
- set bleInput(bool value) {
- _bleInput = value;
+ BluetoothInputMode get bleInput => _bleInput;
+ set bleInput(BluetoothInputMode value) {
+ if (isPlatformSupportedBluetooth) _bleInput = value;
notifyListeners();
}
app/lib/screens/settings_screen.dart
@@ -21,6 +21,7 @@ import 'package:blood_pressure_app/logging.dart';
import 'package:blood_pressure_app/model/blood_pressure/pressure_unit.dart';
import 'package:blood_pressure_app/model/blood_pressure/warn_values.dart';
import 'package:blood_pressure_app/model/iso_lang_names.dart';
+import 'package:blood_pressure_app/model/storage/bluetooth_input_mode.dart';
import 'package:blood_pressure_app/model/storage/db/config_db.dart';
import 'package:blood_pressure_app/model/storage/db/file_settings_loader.dart';
import 'package:blood_pressure_app/model/storage/db/settings_loader.dart';
@@ -160,12 +161,20 @@ class SettingsPage extends StatelessWidget {
title: Text(localizations.medications),
trailing: const Icon(Icons.arrow_forward_ios),
),
- SwitchListTile(
- value: settings.bleInput,
- onChanged: isPlatformSupportedBluetooth ? (value) { settings.bleInput = value; } : null,
- secondary: const Icon(Icons.bluetooth),
+ DropDownListTile<BluetoothInputMode>(
title: Text(localizations.bluetoothInput),
- subtitle: isPlatformSupportedBluetooth ? null : Text(localizations.errFeatureNotSupported),
+ subtitle: Text(localizations.bluetoothInputDesc),
+ leading: const Icon(Icons.bluetooth),
+ items: [
+ for (final e in BluetoothInputMode.values)
+ DropdownMenuItem(
+ value: e,
+ child: Text(e.localize(localizations)),
+ ),
+ ],
+ value: settings.bleInput,
+ onChanged: (value) => settings.bleInput = value ?? settings.bleInput,
+
),
SwitchListTile(
value: settings.allowManualTimeInput,
app/test/features/bluetooth/mock/fake_characteristic.dart
@@ -81,4 +81,8 @@ class FakeBleBpCharacteristic implements BluetoothCharacteristic {
throw UnimplementedError();
}
+ @override
+ // TODO: implement primaryServiceUuid
+ Guid? get primaryServiceUuid => throw UnimplementedError();
+
}
\ No newline at end of file
app/test/features/bluetooth/mock/fake_service.dart
@@ -27,4 +27,16 @@ class FakeBleBPService implements BluetoothService {
@override
Guid get uuid => Guid('1810');
+ @override
+ // TODO: implement isSecondary
+ bool get isSecondary => throw UnimplementedError();
+
+ @override
+ // TODO: implement primaryService
+ BluetoothService? get primaryService => throw UnimplementedError();
+
+ @override
+ // TODO: implement primaryServiceUuid
+ Guid? get primaryServiceUuid => throw UnimplementedError();
+
}
app/test/features/input/add_measurement_dialoge_test.dart
@@ -2,6 +2,7 @@ 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/add_measurement_dialoge.dart';
import 'package:blood_pressure_app/features/settings/tiles/color_picker_list_tile.dart';
+import 'package:blood_pressure_app/model/storage/bluetooth_input_mode.dart';
import 'package:blood_pressure_app/model/storage/settings_store.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -141,7 +142,7 @@ void main() {
});
testWidgets('respects settings about showing bluetooth input', (tester) async {
final settings = Settings(
- bleInput: true,
+ bleInput: BluetoothInputMode.newBluetoothInputCrossPlatform,
);
await tester.pumpWidget(materialApp(
const AddEntryDialoge(
@@ -152,7 +153,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(BluetoothInput, skipOffstage: false), findsOneWidget);
- settings.bleInput = false;
+ settings.bleInput = BluetoothInputMode.disabled;
await tester.pumpAndSettle();
expect(find.byType(BluetoothInput), findsNothing);
});
app/test/model/json_serialization_test.dart
@@ -3,6 +3,7 @@ import 'package:blood_pressure_app/model/blood_pressure/pressure_unit.dart';
import 'package:blood_pressure_app/model/export_import/column.dart';
import 'package:blood_pressure_app/model/export_import/export_configuration.dart';
import 'package:blood_pressure_app/model/horizontal_graph_line.dart';
+import 'package:blood_pressure_app/model/storage/bluetooth_input_mode.dart';
import 'package:blood_pressure_app/model/storage/export_columns_store.dart';
import 'package:blood_pressure_app/model/storage/export_csv_settings_store.dart';
import 'package:blood_pressure_app/model/storage/export_pdf_settings_store.dart';
@@ -97,7 +98,7 @@ void main() {
horizontalGraphLines: [HorizontalGraphLine(Colors.blue, 1230)],
bottomAppBars: true,
knownBleDev: ['a', 'b'],
- bleInput: false,
+ bleInput: BluetoothInputMode.newBluetoothInputCrossPlatform,
weightInput: true,
weightUnit: WeightUnit.st,
preferredPressureUnit: PressureUnit.kPa,