Commit 604db83
Changed files (6)
app
lib
features
bluetooth
input
forms
l10n
model
storage
screens
test
features
input
app/lib/features/bluetooth/bluetooth_input.dart
@@ -11,11 +11,11 @@ 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/l10n/app_localizations.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:blood_pressure_app/l10n/app_localizations.dart';
import 'package:health_data_store/health_data_store.dart';
/// Class for inputting measurement through bluetooth.
app/lib/features/input/forms/add_entry_form.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
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/bluetooth/logic/bluetooth_cubit.dart';
@@ -9,6 +11,7 @@ import 'package:blood_pressure_app/features/input/forms/medicine_intake_form.dar
import 'package:blood_pressure_app/features/input/forms/note_form.dart';
import 'package:blood_pressure_app/features/input/forms/weight_form.dart';
import 'package:blood_pressure_app/features/old_bluetooth/bluetooth_input.dart';
+import 'package:blood_pressure_app/l10n/app_localizations.dart';
import 'package:blood_pressure_app/logging.dart';
import 'package:blood_pressure_app/model/storage/bluetooth_input_mode.dart';
import 'package:blood_pressure_app/model/storage/storage.dart';
@@ -25,6 +28,7 @@ class AddEntryForm extends FormBase<AddEntryFormValue> with TypeLogger {
super.initialValue,
this.meds = const [],
this.bluetoothCubit,
+ this.mockBleInput,
});
/// All medicines selectable.
@@ -33,9 +37,15 @@ class AddEntryForm extends FormBase<AddEntryFormValue> with TypeLogger {
final List<Medicine> meds;
/// Function to customize [BluetoothCubit] creation.
+ ///
+ /// Works on [BluetoothInputMode.newBluetoothInputCrossPlatform].
@visibleForTesting
final BluetoothCubit Function()? bluetoothCubit;
+ /// A builder for a widget that can act as a bluetooth input.
+ @visibleForTesting
+ final Widget Function(void Function(BloodPressureRecord data))? mockBleInput;
+
@override
FormStateBase createState() => AddEntryFormState();
}
@@ -212,13 +222,42 @@ class AddEntryFormState extends FormStateBase<AddEntryFormValue, AddEntryForm>
}
}
- void _onExternalMeasurement(BloodPressureRecord record) => fillForm((
- timestamp: record.time,
- note: null,
- record: record,
- intake: null,
- weight: null,
- ));
+ /// Gets called on inputs from a bluetooth device or similar.
+ void _onExternalMeasurement(BloodPressureRecord record) {
+ final settings = context.read<Settings>();
+ if (settings.trustBLETime
+ && settings.showBLETimeTrustDialog
+ && record.time.difference(DateTime.now()).inHours.abs() > 5) {
+ unawaited(showDialog(context: context, builder: (context) => AlertDialog(
+ content: Text(AppLocalizations.of(context)!.warnBLETimeSus(
+ record.time.difference(DateTime.now()).inHours
+ )),
+ actions: [
+ ElevatedButton(
+ onPressed: () {
+ settings.showBLETimeTrustDialog = false;
+ Navigator.pop(context);
+ },
+ child: Text(AppLocalizations.of(context)!.dontShowAgain),
+ ),
+ ElevatedButton(
+ onPressed: () => Navigator.pop(context),
+ child: Text(AppLocalizations.of(context)!.btnConfirm),
+ ),
+ ],
+ )));
+ }
+
+ fillForm((
+ timestamp: settings.trustBLETime
+ ? record.time
+ : _timeForm.currentState?.save() ?? DateTime.now(),
+ note: null,
+ record: record,
+ intake: null,
+ weight: null,
+ ));
+ }
@override
Widget build(BuildContext context) {
@@ -226,6 +265,8 @@ class AddEntryFormState extends FormStateBase<AddEntryFormValue, AddEntryForm>
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
+ if (widget.mockBleInput != null)
+ widget.mockBleInput!.call(_onExternalMeasurement),
(() => switch (settings.bleInput) {
BluetoothInputMode.disabled => SizedBox.shrink(),
BluetoothInputMode.oldBluetoothInput => OldBluetoothInput(
app/lib/l10n/app_en.arb
@@ -553,7 +553,18 @@
"@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": {},
- "@bluetoothInputDesc": {},
"tapToSelect": "Tap to select",
- "@tapToSelect": {}
+ "@tapToSelect": {},
+ "trustBLETime": "Trust time reported by bluetooth devices",
+ "@trustBLETime": {},
+ "warnBLETimeSus": "The bluetooth device reported a time off by {hours} hours. You can disable trusting timestamps from bluetooth devices in the settings.",
+ "@warnBLETimeSus": {
+ "placeholders": {
+ "hours": {
+ "type": "int"
+ }
+ }
+ },
+ "dontShowAgain": "Don''t show again",
+ "@dontShowAgain": {}
}
app/lib/model/storage/settings_store.dart
@@ -51,6 +51,8 @@ class Settings extends ChangeNotifier {
BluetoothInputMode? bleInput,
bool? weightInput,
WeightUnit? weightUnit,
+ bool? trustBLETime,
+ bool? showBLETimeTrustDialog,
}) {
if (accentColor != null) _accentColor = accentColor;
if (sysColor != null) _sysColor = sysColor;
@@ -79,6 +81,8 @@ class Settings extends ChangeNotifier {
if (bleInput != null) _bleInput = bleInput;
if (weightInput != null) _weightInput = weightInput;
if (weightUnit != null) _weightUnit = weightUnit;
+ if (trustBLETime != null) _trustBLETime = trustBLETime;
+ if (showBLETimeTrustDialog != null) _showBLETimeTrustDialog = showBLETimeTrustDialog;
_language = language; // No check here, as null is the default as well.
}
@@ -114,6 +118,8 @@ class Settings extends ChangeNotifier {
weightInput: ConvertUtil.parseBool(map['weightInput']),
preferredPressureUnit: PressureUnit.decode(ConvertUtil.parseInt(map['preferredPressureUnit'])),
weightUnit: WeightUnit.deserialize(ConvertUtil.parseInt(map['weightUnit'])),
+ trustBLETime: ConvertUtil.parseBool(map['trustBLETime']),
+ showBLETimeTrustDialog: ConvertUtil.parseBool(map['showBLETimeTrustDialog']),
);
// update
@@ -162,6 +168,8 @@ class Settings extends ChangeNotifier {
'bleInput': bleInput.serialize(),
'weightInput': weightInput,
'weightUnit': weightUnit.serialized,
+ 'trustBLETime': trustBLETime,
+ 'showBLETimeTrustDialog': showBLETimeTrustDialog,
};
/// Serialize the object to a restoreable string.
@@ -197,6 +205,8 @@ class Settings extends ChangeNotifier {
_highestMedIndex = other._highestMedIndex;
_weightInput = other._weightInput;
_weightUnit = other._weightUnit;
+ _trustBLETime = other._trustBLETime;
+ _showBLETimeTrustDialog = other._showBLETimeTrustDialog;
notifyListeners();
}
@@ -441,7 +451,26 @@ class Settings extends ChangeNotifier {
_weightUnit = value;
notifyListeners();
}
-
+
+
+ bool _trustBLETime = true;
+ /// Whether to autofill the time the bluetooth device reports.
+ ///
+ /// This was introduced because the system time tends to be more accurate.
+ bool get trustBLETime => _trustBLETime;
+ set trustBLETime(bool value) {
+ _trustBLETime = value;
+ notifyListeners();
+ }
+
+ bool _showBLETimeTrustDialog = true;
+ /// Whether to warn the user when the time the bluetooth device reports is far
+ /// in the past and [trustBLETime] is true.
+ bool get showBLETimeTrustDialog => _showBLETimeTrustDialog;
+ set showBLETimeTrustDialog(bool value) {
+ _showBLETimeTrustDialog = value;
+ notifyListeners();
+ }
// When adding fields notice the checklist at the top.
}
app/lib/screens/settings_screen.dart
@@ -319,6 +319,14 @@ class SettingsPage extends StatelessWidget {
if (value != null) settings.weightUnit = value;
},
),
+ SwitchListTile(
+ value: settings.trustBLETime,
+ title: Text(localizations.trustBLETime),
+ secondary: const Icon(Icons.lock_clock_outlined),
+ onChanged: (value) {
+ settings.trustBLETime = value;
+ },
+ ),
],),
TitledColumn(
title: Text(localizations.data),
app/test/features/input/forms/add_entry_form_test.dart
@@ -7,11 +7,11 @@ import 'package:blood_pressure_app/features/input/forms/medicine_intake_form.dar
import 'package:blood_pressure_app/features/input/forms/note_form.dart';
import 'package:blood_pressure_app/features/input/forms/weight_form.dart';
import 'package:blood_pressure_app/features/old_bluetooth/bluetooth_input.dart';
+import 'package:blood_pressure_app/l10n/app_localizations.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';
-import 'package:blood_pressure_app/l10n/app_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:health_data_store/health_data_store.dart';
import 'package:intl/intl.dart';
@@ -79,7 +79,7 @@ void main() {
testWidgets('show the BluetoothInput specified by setting', (tester) async {
final settings = Settings(bleInput: BluetoothInputMode.disabled);
await tester.pumpWidget(materialApp(AddEntryForm(
- bluetoothCubit: _MockBluetoothCubit.new
+ bluetoothCubit: _MockBoringBluetoothCubit.new
), settings: settings));
expect(find.byType(TabBar, skipOffstage: false), findsNothing);
@@ -442,9 +442,135 @@ void main() {
final FullEntry entry = mockEntry(note: 'Test');
expect(entry.asAddEntry.note?.note, 'Test');
});
+
+ testWidgets("doesn't update time from ble if setting isn't set", (tester) async {
+ final key = GlobalKey<AddEntryFormState>();
+ final initialTime = DateTime.now();
+
+ await tester.pumpWidget(materialApp(AddEntryForm(key: key,
+ initialValue: (
+ timestamp: initialTime,
+ weight: null,
+ record: null,
+ note: null,
+ intake: null,
+ ),
+ mockBleInput: (callback) => ListTile(
+ onTap: () => callback(mockRecord(time: DateTime(2000))),
+ title: Text('mockBleInput'),
+ ),
+ ),
+ settings: Settings(
+ bleInput: BluetoothInputMode.disabled,
+ trustBLETime: false,
+ ),
+ ));
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('mockBleInput'));
+ final returnedEntry = key.currentState!.save();
+ expect(returnedEntry!.timestamp.isAfter(DateTime(2000)), isTrue);
+ expect(returnedEntry.timestamp, initialTime);
+
+ // also check if the hint dialog isn't incorrectly displayed
+ await tester.pumpAndSettle();
+ expect(find.byType(AlertDialog), findsNothing);
+ });
+
+ testWidgets('updates time from ble if setting is set', (tester) async {
+ final key = GlobalKey<AddEntryFormState>();
+ final initialTime = DateTime.now();
+
+ await tester.pumpWidget(materialApp(AddEntryForm(key: key,
+ initialValue: (
+ timestamp: initialTime,
+ weight: null,
+ record: null,
+ note: null,
+ intake: null,
+ ),
+ mockBleInput: (callback) => ListTile(
+ onTap: () => callback(mockRecord(time: DateTime(2000))),
+ title: Text('mockBleInput'),
+ ),
+ ),
+ settings: Settings(
+ bleInput: BluetoothInputMode.disabled,
+ trustBLETime: true,
+ ),
+ ));
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('mockBleInput'));
+ final returnedEntry = key.currentState!.save();
+ expect(returnedEntry!.timestamp, equals(DateTime(2000)));
+ });
+
+ testWidgets('shows warning if time from ble is too old', (tester) async {
+ final localizations = await AppLocalizations.delegate.load(const Locale('en'));
+ await tester.pumpWidget(materialApp(AddEntryForm(
+ mockBleInput: (callback) => ListTile(
+ onTap: () => callback(mockRecord(time: DateTime(2000))),
+ title: Text('mockBleInput'),
+ ),
+ ),
+ settings: Settings(
+ bleInput: BluetoothInputMode.disabled,
+ trustBLETime: true,
+ ),
+ ));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AlertDialog), findsNothing);
+ await tester.tap(find.text('mockBleInput'));
+ await tester.pumpAndSettle();
+ expect(find.byType(AlertDialog), findsOneWidget);
+ expect(find.textContaining('The bluetooth device reported a time off by'), findsOneWidget);
+ expect(find.text(localizations.btnConfirm), findsOneWidget);
+
+ await tester.tap(find.text(localizations.btnConfirm));
+ await tester.pumpAndSettle();
+ expect(find.byType(AlertDialog), findsNothing);
+
+ // reopens the next time
+ await tester.tap(find.text('mockBleInput'));
+ await tester.pumpAndSettle();
+ expect(find.byType(AlertDialog), findsOneWidget);
+ });
+
+ testWidgets('allows disabling warning if time from ble is too old', (tester) async {
+ final localizations = await AppLocalizations.delegate.load(const Locale('en'));
+ await tester.pumpWidget(materialApp(AddEntryForm(
+ mockBleInput: (callback) => ListTile(
+ onTap: () => callback(mockRecord(time: DateTime(2000))),
+ title: Text('mockBleInput'),
+ ),
+ ),
+ settings: Settings(
+ bleInput: BluetoothInputMode.disabled,
+ trustBLETime: true,
+ ),
+ ));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AlertDialog), findsNothing);
+ await tester.tap(find.text('mockBleInput'));
+ await tester.pumpAndSettle();
+ expect(find.byType(AlertDialog), findsOneWidget);
+ expect(find.textContaining('The bluetooth device reported a time off by'), findsOneWidget);
+ expect(find.text(localizations.dontShowAgain), findsOneWidget);
+
+ await tester.tap(find.text(localizations.dontShowAgain));
+ await tester.pumpAndSettle();
+ expect(find.byType(AlertDialog), findsNothing);
+ await tester.tap(find.text('mockBleInput'));
+ await tester.pumpAndSettle();
+ expect(find.byType(AlertDialog), findsNothing);
+ });
}
-class _MockBluetoothCubit extends Fake implements BluetoothCubit {
+/// A mock ble cubit that never does anything.
+class _MockBoringBluetoothCubit extends Fake implements BluetoothCubit {
@override
Future<void> close() async {}