Commit 4973d06

derdilla <82763757+NobodyForNothing@users.noreply.github.com>
2024-04-24 13:22:33
implement bluetooth device scanning cubit
Signed-off-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com>
1 parent 7ed8aad
app/lib/bluetooth/bluetooth_cubit.dart
@@ -23,7 +23,7 @@ class BluetoothCubit extends Cubit<BluetoothState> {
     _adapterStateStateSubscription = _flutterBluePlus.adapterState.listen(_onAdapterStateChanged);
   }
 
-  FlutterBluePlusMockable _flutterBluePlus;
+  final FlutterBluePlusMockable _flutterBluePlus;
 
   BluetoothAdapterState _adapterState = BluetoothAdapterState.unknown;
 
app/lib/bluetooth/device_scan_cubit.dart
@@ -0,0 +1,115 @@
+import 'dart:async';
+
+import 'package:blood_pressure_app/bluetooth/bluetooth_cubit.dart';
+import 'package:blood_pressure_app/bluetooth/flutter_blue_plus_mockable.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> {
+  /// 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;
+  late StreamSubscription<bool> _isScanningSubscription;
+  bool _isScanning = false;
+
+  @override
+  Future<void> close() async {
+    await _scanResultsSubscription.cancel();
+    await _isScanningSubscription.cancel();
+    await super.close();
+  }
+
+  Future<void> _startScanning() async {
+    _scanResultsSubscription = _flutterBluePlus.scanResults
+        .listen(_onScanResult,
+      onError: _onScanError,
+    );
+    try {
+      await _flutterBluePlus.startScan(
+        withServices: [service],
+        // no timeout, the user knows best how long scanning is needed
+      );
+    } catch (e) {
+      _onScanError(e);
+    }
+    _isScanningSubscription = _flutterBluePlus.isScanning
+        .listen(_onIsScanningChanged);
+  }
+
+  void _onScanResult(List<ScanResult> devices) {
+    assert(_isScanning);
+    if(state is DeviceSelected) return;
+    final preferred = devices.firstWhereOrNull((dev) =>
+        settings.knownBleDev.contains(dev.device.advName));
+    if (preferred != null) {
+      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) {
+    // TODO
+  }
+
+  void _onIsScanningChanged(bool isScanning) {
+    _isScanning = isScanning;
+    // TODO: consider restarting
+  }
+
+  /// 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(!_isScanning);
+    emit(DeviceSelected(device));
+    settings.knownBleDev.add(device.advName); // TODO: does this work?
+  }
+
+  /// Remove all known devices and start scanning again.
+  Future<void> clearKnownDevices() async {
+    settings.knownBleDev = [];
+    emit(DeviceListLoading());
+    if (!_isScanning) await _startScanning();
+  }
+
+}
app/lib/bluetooth/device_scan_state.dart
@@ -0,0 +1,38 @@
+part of 'device_scan_cubit.dart';
+
+/// Search of bluetooth devices that meet some criteria.
+@immutable
+abstract 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/model/storage/settings_store.dart
@@ -1,6 +1,7 @@
 import 'dart:collection';
 import 'dart:convert';
 
+import 'package:blood_pressure_app/bluetooth/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';
@@ -43,8 +44,9 @@ class Settings extends ChangeNotifier {
     bool? useLegacyList,
     bool? bottomAppBars,
     List<Medicine>? medications,
-    int? highestMedIndex,
     PressureUnit? preferredPressureUnit,
+    List<String>? knownBleDev,
+    int? highestMedIndex,
   }) {
     if (accentColor != null) _accentColor = accentColor;
     if (sysColor != null) _sysColor = sysColor;
@@ -68,8 +70,9 @@ class Settings extends ChangeNotifier {
     if (lastVersion != null) _lastVersion = lastVersion;
     if (bottomAppBars != null) _bottomAppBars = bottomAppBars;
     if (medications != null) _medications.addAll(medications);
-    if (highestMedIndex != null) _highestMedIndex = highestMedIndex;
     if (preferredPressureUnit != null) _preferredPressureUnit = preferredPressureUnit;
+    if (highestMedIndex != null) _highestMedIndex = highestMedIndex;
+    if (knownBleDev != null) _knownBleDev = knownBleDev;
     _language = language; // No check here, as null is the default as well.
   }
 
@@ -102,7 +105,6 @@ class Settings extends ChangeNotifier {
       medications: ConvertUtil.parseList<String>(map['medications'])?.map((e) =>
           Medicine.fromJson(jsonDecode(e)),).toList(),
       highestMedIndex: ConvertUtil.parseInt(map['highestMedIndex']),
-      preferredPressureUnit: PressureUnit.decode(ConvertUtil.parseInt(map['preferredPressureUnit'])),
     );
 
     // update
@@ -148,6 +150,7 @@ class Settings extends ChangeNotifier {
       'medications': medications.map(jsonEncode).toList(),
       'highestMedIndex': highestMedIndex,
       'preferredPressureUnit': preferredPressureUnit.encode(),
+      'knownBleDev': knownBleDev,
     };
 
   /// Serialize the object to a restoreable string.
@@ -352,7 +355,18 @@ class Settings extends ChangeNotifier {
     _preferredPressureUnit = value;
     notifyListeners();
   }
-  
+
+  List<String> _knownBleDev = [];
+  /// Bluetooth devices that previously connected.
+  ///
+  /// The exact value that is stored here is determined in [DeviceScanCubit].
+  UnmodifiableListView<String> get knownBleDev =>
+      UnmodifiableListView(_knownBleDev);
+  set knownBleDev(List<String> value) {
+    _knownBleDev = value;
+    notifyListeners();
+  }
+
   final List<Medicine> _medications = [];
   /// All medications ever added.
   ///
app/test/model/json_serialization_test.dart
@@ -96,6 +96,7 @@ void main() {
         horizontalGraphLines: [HorizontalGraphLine(Colors.blue, 1230)],
         bottomAppBars: true,
         medications: [mockMedicine(), mockMedicine(defaultDosis: 42)],
+        knownBleDev: ['a', 'b'],
       );
       final fromJson = Settings.fromJson(initial.toJson());
 
@@ -122,6 +123,7 @@ void main() {
       expect(initial.horizontalGraphLines.first.height, fromJson.horizontalGraphLines.first.height);
       expect(initial.needlePinBarWidth, fromJson.needlePinBarWidth);
       expect(initial.bottomAppBars, fromJson.bottomAppBars);
+      expect(initial.knownBleDev, fromJson.knownBleDev);
 
       expect(initial.toJson(), fromJson.toJson());
     });
app/.gitignore
@@ -44,6 +44,10 @@ app.*.map.json
 /android/app/release
 /main.dart.incremental.dill
 
+# Generated files
+*.mocks.dart
+
+# Others
 /l10n_errors.txt
 /fastlane/metadata/android/en-US/images/phoneScreenshots/resizeAll.sh
 /tmp6.csv