main
  1import 'dart:async';
  2
  3import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart';
  4import 'package:blood_pressure_app/features/bluetooth/logic/ble_read_cubit.dart';
  5import 'package:blood_pressure_app/features/bluetooth/logic/bluetooth_cubit.dart';
  6import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_data.dart';
  7import 'package:blood_pressure_app/features/bluetooth/logic/device_scan_cubit.dart';
  8import 'package:blood_pressure_app/features/bluetooth/ui/closed_bluetooth_input.dart';
  9import 'package:blood_pressure_app/features/bluetooth/ui/device_selection.dart';
 10import 'package:blood_pressure_app/features/bluetooth/ui/input_card.dart';
 11import 'package:blood_pressure_app/features/bluetooth/ui/measurement_failure.dart';
 12import 'package:blood_pressure_app/features/bluetooth/ui/measurement_multiple.dart';
 13import 'package:blood_pressure_app/features/bluetooth/ui/measurement_success.dart';
 14import 'package:blood_pressure_app/l10n/app_localizations.dart';
 15import 'package:blood_pressure_app/logging.dart';
 16import 'package:blood_pressure_app/model/storage/storage.dart';
 17import 'package:flutter/material.dart';
 18import 'package:flutter_bloc/flutter_bloc.dart';
 19import 'package:health_data_store/health_data_store.dart';
 20
 21/// Class for inputting measurement through bluetooth.
 22class BluetoothInput extends StatefulWidget {
 23  /// Create a measurement input through bluetooth.
 24  const BluetoothInput({super.key,
 25    required this.onMeasurement,
 26    required this.manager,
 27    this.bluetoothCubit,
 28    this.deviceScanCubit,
 29    this.bleReadCubit,
 30  });
 31
 32  /// Bluetooth Backend manager
 33  final BluetoothManager manager;
 34
 35  /// Called when a measurement was received through bluetooth.
 36  final void Function(BloodPressureRecord data) onMeasurement;
 37
 38  /// Function to customize [BluetoothCubit] creation.
 39  final BluetoothCubit Function()? bluetoothCubit;
 40
 41  /// Function to customize [DeviceScanCubit] creation.
 42  final DeviceScanCubit Function()? deviceScanCubit;
 43
 44  /// Function to customize [BleReadCubit] creation.
 45  final BleReadCubit Function(BluetoothDevice dev)? bleReadCubit;
 46
 47  @override
 48  State<BluetoothInput> createState() => _BluetoothInputState();
 49}
 50
 51/// Read bluetooth input happy workflow:
 52/// - build is called and renders ClosedBluetoothInput with read bluetooth input button
 53/// - User clicks button, toggles _isActive
 54/// - _buildActive is called, waits for device_scan_state.DeviceSelected
 55/// - _buildReadDevice is called, waits for ble_read_state.BleReadSuccess
 56/// - onMeasurement callback triggered
 57class _BluetoothInputState extends State<BluetoothInput> with TypeLogger {
 58  /// Whether the user initiated reading bluetooth input
 59  bool _isActive = false;
 60
 61  late final BluetoothCubit _bluetoothCubit;
 62  DeviceScanCubit? _deviceScanCubit;
 63  BleReadCubit? _deviceReadCubit;
 64
 65  StreamSubscription<BluetoothState>? _bluetoothSubscription;
 66
 67  /// Data received from reading bluetooth values.
 68  ///
 69  /// Its presence indicates that this input is done.
 70  BleMeasurementData? _finishedData;
 71
 72  @override
 73  void initState() {
 74    super.initState();
 75    _bluetoothCubit = widget.bluetoothCubit?.call() ?? BluetoothCubit(manager: widget.manager);
 76  }
 77
 78  @override
 79  void dispose() {
 80    unawaited(_bluetoothSubscription?.cancel());
 81    unawaited(_bluetoothCubit.close());
 82    unawaited(_deviceScanCubit?.close());
 83    unawaited(_deviceReadCubit?.close());
 84    super.dispose();
 85  }
 86
 87  void _returnToIdle() async {
 88    // No need to show wait in the UI.
 89    if (_isActive) {
 90      setState(() {
 91        _isActive = false;
 92        _finishedData = null;
 93      });
 94    }
 95
 96    await _deviceReadCubit?.close();
 97    await _deviceScanCubit?.close();
 98    await _bluetoothSubscription?.cancel();
 99    _deviceReadCubit = null;
100    _deviceScanCubit = null;
101    _bluetoothSubscription = null;
102  }
103
104  // TODO(derdilla): extract logic from UI
105  @override
106  Widget build(BuildContext context) {
107    const SizeChangedLayoutNotification().dispatch(context);
108    logger.finer('build[_isActive: $_isActive, _finishedData: $_finishedData]');
109
110    if (_finishedData != null) {
111      return MeasurementSuccess(
112        onTap: _returnToIdle,
113        data: _finishedData!,
114      );
115    }
116
117    if (_isActive) {
118      return _buildActive(context);
119    }
120
121    return ClosedBluetoothInput(
122      bluetoothCubit: _bluetoothCubit,
123      onStarted: () async {
124        setState(() => _isActive = true);
125      },
126      inputInfo: () async {
127        logger.finer('build.inputInfo[mounted: ${context.mounted}]');
128        if (context.mounted) {
129          await showDialog(
130            context: context,
131            builder: (BuildContext context) => AlertDialog(
132              title: Text(AppLocalizations.of(context)!.bluetoothInput),
133              content: Text(AppLocalizations.of(context)!.aboutBleInput),
134              actions: <Widget>[
135                ElevatedButton(
136                  child: Text((AppLocalizations.of(context)!.btnConfirm)),
137                  onPressed: () => Navigator.of(context).pop(),
138                ),
139              ],
140            ),
141          );
142        }
143      },
144    );
145  }
146
147  /// Build widget for 'adapter ready & discovering devices from bluetooth' state
148  Widget _buildActive(BuildContext context) {
149    /// 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
150    const String serviceUUID = '1810';
151    /// 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
152    const String characteristicUUID = '2A35';
153
154    _bluetoothSubscription = _bluetoothCubit.stream.listen((state) {
155      if (state is BluetoothStateReady) {
156        logger.finest('_bluetoothSubscription.listen: state=$state');
157      } else {
158        logger.finer('_bluetoothSubscription.listen: state=$state, calling _returnToIdle');
159        _returnToIdle();
160      }
161    });
162
163    final settings = context.watch<Settings>();
164    _deviceScanCubit ??= widget.deviceScanCubit?.call() ?? DeviceScanCubit(
165      manager: widget.manager,
166      service: serviceUUID,
167      settings: settings,
168    );
169
170    return BlocBuilder<DeviceScanCubit, DeviceScanState>(
171      bloc: _deviceScanCubit,
172      builder: (context, DeviceScanState state) {
173        logger.finer('DeviceScanCubit.builder deviceScanState: $state');
174        const SizeChangedLayoutNotification().dispatch(context);
175        return switch(state) {
176          DeviceListLoading() => _buildMainCard(context,
177            title: Text(AppLocalizations.of(context)!.scanningForDevices),
178            child: const CircularProgressIndicator(),
179          ),
180          DeviceListAvailable() => DeviceSelection(
181            scanResults: state.devices,
182            onAccepted: (dev) => _deviceScanCubit!.acceptDevice(dev),
183          ),
184          SingleDeviceAvailable() => DeviceSelection(
185            scanResults: [ state.device ],
186            onAccepted: (dev) => _deviceScanCubit!.acceptDevice(dev),
187          ),
188          DeviceSelected() => _buildReadDevice(state, serviceUUID: serviceUUID, characteristicUUID: characteristicUUID)
189        };
190      },
191    );
192  }
193
194  /// Build widget for 'reading characteristic value from bluetooth' state
195  Widget _buildReadDevice(DeviceSelected state, { required String serviceUUID, required String characteristicUUID }) {
196    logger.finer('_buildReadDevice: state: $state');
197    return BlocConsumer<BleReadCubit, BleReadState>(
198      bloc: () {
199        _deviceReadCubit = widget.bleReadCubit?.call(state.device) ?? BleReadCubit(
200          state.device,
201          characteristicUUID: characteristicUUID,
202          serviceUUID: serviceUUID,
203        );
204        return _deviceReadCubit;
205      }(),
206      listener: (BuildContext context, BleReadState state) {
207        if (state is BleReadSuccess) {
208          final BloodPressureRecord record = state.data.asBloodPressureRecord();
209          widget.onMeasurement(record);
210          setState(() => _finishedData = state.data);
211        }
212      },
213      builder: (BuildContext context, BleReadState state) {
214        logger.finer('BleReadCubit.builder: $state');
215        const SizeChangedLayoutNotification().dispatch(context);
216
217        return switch (state) {
218          BleReadInProgress() => _buildMainCard(context,
219            child: const CircularProgressIndicator(),
220          ),
221          BleReadFailure() => MeasurementFailure(
222            onTap: _returnToIdle,
223            reason: state.reason,
224          ),
225          BleReadMultiple() => MeasurementMultiple(
226            onClosed: _returnToIdle,
227            onSelect: (data) => _deviceReadCubit!.useMeasurement(data),
228            measurements: state.data,
229          ),
230          BleReadSuccess() => MeasurementSuccess(
231            onTap: _returnToIdle,
232            data: state.data,
233          ),
234        };
235      },
236    );
237  }
238
239  Widget _buildMainCard(BuildContext context, {
240    required Widget child,
241    Widget? title,
242  }) => InputCard(
243    onClosed: _returnToIdle,
244    title: title,
245    child: child,
246  );
247}