Commit f7b758e

derdilla <82763757+NobodyForNothing@users.noreply.github.com>
2024-05-10 15:43:20
implement bluetooth input ui
Signed-off-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com>
1 parent 378bbe7
app/lib/bluetooth/ble_read_cubit.dart
@@ -27,7 +27,7 @@ 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<BleRead> {
+class BleReadCubit extends Cubit<BleReadState> {
   /// Start reading a characteristic from a device.
   BleReadCubit(this._device)
     : super(BleReadInProgress()) {
app/lib/bluetooth/ble_read_state.dart
@@ -2,16 +2,16 @@ part of 'ble_read_cubit.dart';
 
 /// State of reading a characteristic from a BLE device.
 @immutable
-abstract class BleRead {}
+sealed class BleReadState {}
 
 /// The reading has been started.
-class BleReadInProgress extends BleRead {}
+class BleReadInProgress extends BleReadState {}
 
 /// The reading failed unrecoverable for some reason.
-class BleReadFailure extends BleRead {}
+class BleReadFailure extends BleReadState {}
 
 /// Data has been successfully read.
-class BleReadSuccess extends BleRead {
+class BleReadSuccess extends BleReadState {
   /// Indicate a successful reading of a ble characteristic.
   BleReadSuccess(this.data);
 
app/lib/bluetooth/bluetooth_state.dart
@@ -2,11 +2,12 @@ part of 'bluetooth_cubit.dart';
 
 /// State of the devices bluetooth module.
 @immutable
-abstract class BluetoothState {}
+sealed class BluetoothState {}
 
 /// No information on whether bluetooth is available.
 ///
-/// Options relating to bluetooth should only be shown where they don't disturb.
+/// 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).
app/lib/bluetooth/device_scan_cubit.dart
@@ -71,7 +71,7 @@ class DeviceScanCubit extends Cubit<DeviceScanState> {
     // characteristic as users have to select their device anyways.
     if(state is DeviceSelected) return;
     final preferred = devices.firstWhereOrNull((dev) =>
-        settings.knownBleDev.contains(dev.device.advName));
+        settings.knownBleDev.contains(dev.device.platformName));
     if (preferred != null) {
       emit(DeviceSelected(preferred.device));
     } else if (devices.isEmpty) {
@@ -99,7 +99,7 @@ class DeviceScanCubit extends Cubit<DeviceScanState> {
     assert(!_flutterBluePlus.isScanningNow);
     emit(DeviceSelected(device));
     final List<String> list = settings.knownBleDev.toList();
-    list.add(device.advName);
+    list.add(device.platformName);
     settings.knownBleDev = list;
   }
 
app/lib/bluetooth/device_scan_state.dart
@@ -2,7 +2,7 @@ part of 'device_scan_cubit.dart';
 
 /// Search of bluetooth devices that meet some criteria.
 @immutable
-abstract class DeviceScanState {}
+sealed class DeviceScanState {}
 
 /// Searching for devices or a reason they are not available.
 class DeviceListLoading extends DeviceScanState {}
app/lib/components/bluetooth_input.dart
@@ -0,0 +1,192 @@
+import 'dart:async';
+
+import 'package:blood_pressure_app/bluetooth/ble_read_cubit.dart';
+import 'package:blood_pressure_app/bluetooth/bluetooth_cubit.dart';
+import 'package:blood_pressure_app/bluetooth/device_scan_cubit.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 Guid;
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// Class for inputting measurement through bluetooth.
+class BluetoothInput extends StatefulWidget {
+  /// Create a measurement input through bluetooth.
+  const BluetoothInput({super.key, required this.settings});
+
+  final Settings settings;
+
+  @override
+  State<BluetoothInput> createState() => _BluetoothInputState();
+}
+
+class _BluetoothInputState extends State<BluetoothInput> {
+  /// Whether the user expanded bluetooth input
+  bool _isActive = false;
+
+  final BluetoothCubit _bluetoothCubit =  BluetoothCubit();
+  StreamSubscription<BluetoothState>? _bluetoothSubscription;
+
+  void _returnToIdle() {
+    _bluetoothSubscription?.cancel();
+    _bluetoothSubscription = null;
+    if (_isActive) {
+      setState(() {
+        _isActive = false;
+      });
+    }
+  }
+
+  Widget _buildIdle(BuildContext context) => BlocBuilder<BluetoothCubit, BluetoothState>(
+    bloc: _bluetoothCubit,
+    builder: (context, BluetoothState state) => switch(state) {
+      BluetoothInitial() => const Align(
+        alignment: Alignment.topRight,
+        child: CircularProgressIndicator(),
+      ),
+      BluetoothUnfeasible() => const SizedBox.shrink(),
+      BluetoothUnauthorized() => Align(
+        alignment: Alignment.topRight,
+        child: Row(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            Text(AppLocalizations.of(context)!.errBleNoPerms),
+            const Icon(Icons.bluetooth_disabled),
+            // TODO: tapable
+          ],
+        ),
+      ),
+      BluetoothDisabled() => Align(
+        alignment: Alignment.topRight,
+        child: Row(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            Text(AppLocalizations.of(context)!.bluetoothDisabled),
+            const Icon(Icons.bluetooth_disabled),
+            // TODO: tapable
+          ],
+        ),
+      ),
+      BluetoothReady() => IconButton(
+        icon: const Icon(Icons.bluetooth),
+        onPressed: () => setState(() => _isActive = true),
+      ),
+    },
+  );
+
+  Widget _buildActive(BuildContext context) {
+    _bluetoothSubscription = _bluetoothCubit.stream.listen((state) {
+      if (state is! BluetoothReady) _returnToIdle();
+    });
+    final deviceScanBloc = DeviceScanCubit(
+      service: Guid('1810'),
+      settings: widget.settings,
+    );
+    return BlocBuilder<DeviceScanCubit, DeviceScanState>(
+      bloc: deviceScanBloc,
+      builder: (context, DeviceScanState state) => switch(state) {
+        DeviceListLoading() => _buildMainCard(context,
+          child: const CircularProgressIndicator()),
+        DeviceListAvailable() => _buildMainCard(context,
+          title: Text(AppLocalizations.of(context)!.selectDevice),
+          child: ListView(
+            children: [
+              for (final d in state.devices)
+                ListTile(
+                  // TODO: consider only passing the string
+                  title: Text(d.device.platformName),
+                  onTap: () => deviceScanBloc.acceptDevice(d.device),
+                ),
+            ],
+          ),
+        ),
+        SingleDeviceAvailable() => _buildMainCard(context,
+          title: Text(AppLocalizations.of(context)!
+              .connectTo(state.device.device.platformName)),
+          child: FilledButton(
+            child: Text(AppLocalizations.of(context)!.connect),
+            onPressed: () => deviceScanBloc.acceptDevice(state.device.device),
+          ),
+        ),
+        DeviceSelected() => BlocBuilder<BleReadCubit, BleReadState>(
+          bloc: BleReadCubit(state.device),
+          builder: (BuildContext context, BleReadState state) => switch (state) {
+            BleReadInProgress() => _buildMainCard(context, child: CircularProgressIndicator()),
+            BleReadFailure() => _buildMainCard(context,
+              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),
+                  ],
+                ),
+              ),
+            ),
+            BleReadSuccess() => _buildMainCard(context,
+              child: Center(
+                child: Column(
+                  mainAxisAlignment: MainAxisAlignment.center,
+                  children: [
+                    const Icon(Icons.done, color: Colors.green),
+                    const SizedBox(height: 8,),
+                    Text(AppLocalizations.of(context)!.measurementSuccess),
+                  ],
+                ),
+              ),
+            ),
+
+          },
+        ),
+      },
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (_isActive) return _buildActive(context);
+    return _buildIdle(context);
+  }
+
+
+  Widget _buildMainCard(BuildContext context, {
+    required Widget child,
+    Widget? title,
+  }) => Card.outlined(
+    color: Theme.of(context).cardColor,
+    // borderRadius: BorderRadius.circular(24),
+    // width: MediaQuery.of(context).size.width,
+    // height: MediaQuery.of(context).size.width,
+    // padding: const EdgeInsets.all(24),
+    margin: const EdgeInsets.all(8),
+    child: Stack(
+      children: [
+        Padding( // content
+          padding: const EdgeInsets.all(24),
+          child: child,
+        ),
+        if (title != null)
+          Align(
+            alignment: Alignment.topLeft,
+            child: title,
+          ),
+        Align(
+          alignment: Alignment.topRight,
+          child: IconButton(
+            icon: const Icon(Icons.close),
+            onPressed: () => null, // TODO
+          ),
+        ),
+      ],
+    ),
+  );
+}
+
+enum _State {
+  /// Default state of the widget shown when no interaction happened.
+  idle,
+  /// Scanning and selecting devices.
+  deviceScan,
+  measurementRead,
+}
app/lib/l10n/app_en.arb
@@ -505,20 +505,21 @@
   "@valueDistribution": {},
   "titleInCsv": "Title in CSV",
   "@titleInCsv": {},
-  "scanningDevices": "Scanning for devices...",
-  "@scanningDevices": {},
-  "errBleCantOpen": "Failed loading input devices. Ensure the app has all necessary permissions and report this issue in case it persists.",
-  "@errBleCantOpen": {},
-  "errBleNoDev": "No compatible BLE GATT devices found. Tap to refresh!",
-  "@errBleNoDev": {},
-  "errBleNoPerms": "Permissions error. Please allow all bluetooth permissions. On pre-Android 12 devices the location permission is required.",
+  "errBleNoPerms": "No bluetooth permissions",
   "@errBleNoPerms": {},
-  "errBleCouldNotConnect": "Connection to bluetooth device failed. Tap to restart.",
-  "@errBleCouldNotConnect": {},
-  "bleConnecting": "Connecting to bluetooth device...",
-  "@bleConnecting": {},
-  "bleConnected": "Connected to device. Waiting for measurement...",
-  "@bleConnected": {},
-  "bleProcessing": "Processing incoming measurement...",
-  "@bleProcessing": {}
+  "bluetoothDisabled": "Bluetooth disabled",
+  "@bluetoothDisabled": {},
+  "selectDevice": "Select device",
+  "@selectDevice": {},
+  "connectTo": "Connect to {deviceName}?",
+  "@connectTo": {
+    "type": "String",
+    "example": "boso medicus CE6674"
+  },
+  "errMeasurementRead": "Error reading measurement",
+  "@errBluetooth": {},
+  "measurementSuccess": "Measurement taken successfully",
+  "@measurementSuccess": {},
+  "connect": "Connect",
+  "@connect": {}
 }