main
  1import 'dart:async';
  2
  3import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart';
  4import 'package:blood_pressure_app/features/bluetooth/bluetooth_input.dart';
  5import 'package:blood_pressure_app/features/bluetooth/logic/bluetooth_cubit.dart';
  6import 'package:blood_pressure_app/features/input/forms/blood_pressure_form.dart';
  7import 'package:blood_pressure_app/features/input/forms/date_time_form.dart';
  8import 'package:blood_pressure_app/features/input/forms/form_base.dart';
  9import 'package:blood_pressure_app/features/input/forms/form_switcher.dart';
 10import 'package:blood_pressure_app/features/input/forms/medicine_intake_form.dart';
 11import 'package:blood_pressure_app/features/input/forms/note_form.dart';
 12import 'package:blood_pressure_app/features/input/forms/weight_form.dart';
 13import 'package:blood_pressure_app/features/old_bluetooth/bluetooth_input.dart';
 14import 'package:blood_pressure_app/l10n/app_localizations.dart';
 15import 'package:blood_pressure_app/logging.dart';
 16import 'package:blood_pressure_app/model/storage/bluetooth_input_mode.dart';
 17import 'package:blood_pressure_app/model/storage/storage.dart';
 18import 'package:flutter/material.dart';
 19import 'package:flutter/services.dart';
 20import 'package:flutter_bloc/flutter_bloc.dart';
 21import 'package:health_data_store/health_data_store.dart';
 22import 'package:provider/provider.dart';
 23
 24/// Primary form to enter all types of entries.
 25class AddEntryForm extends FormBase<AddEntryFormValue> with TypeLogger {
 26  /// Create primary form to enter all types of entries.
 27  const AddEntryForm({super.key,
 28    super.initialValue,
 29    this.meds = const [],
 30    this.bluetoothCubit,
 31    this.mockBleInput,
 32  });
 33
 34  /// All medicines selectable.
 35  ///
 36  /// Hides med input when this is empty.
 37  final List<Medicine> meds;
 38
 39  /// Function to customize [BluetoothCubit] creation.
 40  ///
 41  /// Works on [BluetoothInputMode.newBluetoothInputCrossPlatform].
 42  @visibleForTesting
 43  final BluetoothCubit Function()? bluetoothCubit;
 44
 45  /// A builder for a widget that can act as a bluetooth input.
 46  @visibleForTesting
 47  final Widget Function(void Function(BloodPressureRecord data))? mockBleInput;
 48
 49  @override
 50  FormStateBase createState() => AddEntryFormState();
 51}
 52
 53/// State of primary form to enter all types of entries.
 54class AddEntryFormState extends FormStateBase<AddEntryFormValue, AddEntryForm>
 55    with TypeLogger {
 56  final _timeForm = GlobalKey<DateTimeFormState>();
 57  final _noteForm = GlobalKey<NoteFormState>();
 58  final _bpForm = GlobalKey<BloodPressureFormState>();
 59  final _weightForm = GlobalKey<WeightFormState>();
 60  final _intakeForm = GlobalKey<MedicineIntakeFormState>();
 61
 62  final _controller = FormSwitcherController();
 63
 64  // because these values are no necessarily in tree a copy is needed to get
 65  // overridden values.
 66  BloodPressureRecord? _lastSavedPressure;
 67  BodyweightRecord? _lastSavedWeight;
 68  MedicineIntake? _lastSavedIntake;
 69
 70  @override
 71  void initState() {
 72    super.initState();
 73    logger.finer('Initializing with ${widget.initialValue}');
 74    if (widget.initialValue != null) {
 75      _lastSavedPressure = widget.initialValue?.record;
 76      _lastSavedWeight = widget.initialValue?.weight;
 77      _lastSavedIntake = widget.initialValue?.intake;
 78      if (widget.initialValue!.record == null
 79          && widget.initialValue!.intake == null
 80          && widget.initialValue!.weight != null) {
 81        _controller.animateTo(widget.meds.isEmpty ? 1 : 2);
 82      } else if (widget.initialValue!.record == null
 83          && widget.initialValue!.intake != null) {
 84        _controller.animateTo(1);
 85      }
 86      // In all other cases we are at the correct position (0)
 87      // or don't need to jump at all.
 88    }
 89    ServicesBinding.instance.keyboard.addHandler(_onKey);
 90  }
 91
 92  @override
 93  void dispose() {
 94    ServicesBinding.instance.keyboard.removeHandler(_onKey);
 95    super.dispose();
 96  }
 97
 98  bool _onKey(KeyEvent event) {
 99    if(event.logicalKey == LogicalKeyboardKey.backspace
100      && ((_bpForm.currentState?.isEmptyInputFocused() ?? false)
101          || (_noteForm.currentState?.isEmptyInputFocused() ?? false)
102          || (_weightForm.currentState?.isEmptyInputFocused() ?? false)
103          || (_intakeForm.currentState?.isEmptyInputFocused() ?? false))) {
104      FocusScope.of(context).previousFocus();
105    }
106    return false;
107  }
108
109  @override
110  bool validate() {
111    final settings = context.read<Settings>();
112
113    final timeFormValidation = settings.allowManualTimeInput
114      ? _timeForm.currentState?.validate()
115      : true;
116    final noteFormValidation = _noteForm.currentState?.validate();
117    final bpFormValidation = _bpForm.currentState?.validate();
118    final weightFormValidation = _weightForm.currentState?.validate();
119    final intakeFormValidation = _intakeForm.currentState?.validate();
120    logger.fine('validating...');
121    logger.finest('time: $timeFormValidation');
122    logger.finest('note: $noteFormValidation');
123    logger.finest('bp: $bpFormValidation');
124    logger.finest('weight: $weightFormValidation');
125    logger.finest('intake: $intakeFormValidation');
126    return !context.read<Settings>().validateInputs
127    || (timeFormValidation ?? false)
128    && (noteFormValidation ?? false)
129    // the following become null when unopened
130    && (bpFormValidation ?? true)
131    && (weightFormValidation ?? true)
132    && (intakeFormValidation ?? true);
133  }
134
135  @override
136  AddEntryFormValue? save() {
137    logger.fine('Calling save');
138    if (!validate()) return null;
139    final time = _timeForm.currentState?.save() ?? DateTime.now();
140    Note? note;
141    BloodPressureRecord? record = _lastSavedPressure;
142    BodyweightRecord? weight = _lastSavedWeight;
143    MedicineIntake? intake = _lastSavedIntake;
144
145    final noteFormValue = _noteForm.currentState?.save();
146    if (noteFormValue != null) {
147      note = Note(time: time, note: noteFormValue.$1, color: noteFormValue.$2?.toARGB32());
148    }
149    final recordFormValue = _bpForm.currentState?.save();
150    if (recordFormValue != null) {
151      final unit = context.read<Settings>().preferredPressureUnit;
152      record = BloodPressureRecord(
153        time: time,
154        sys: recordFormValue.sys == null ? null : unit.wrap(recordFormValue.sys!),
155        dia: recordFormValue.dia == null ? null : unit.wrap(recordFormValue.dia!),
156        pul: recordFormValue.pul,
157      );
158    }
159    final weightFormValue = _weightForm.currentState?.save();
160    if (weightFormValue != null) {
161      weight = BodyweightRecord(time: time, weight: weightFormValue);
162    }
163    final intakeFormValue = _intakeForm.currentState?.save();
164    if (intakeFormValue != null) {
165      intake = MedicineIntake(
166        time: time,
167        medicine: intakeFormValue.$1,
168        dosis: intakeFormValue.$2,
169      );
170    }
171    logger.finer('Saving values: $note, $record, $weight, $intake');
172
173    if (note == null
174      && record == null
175      && weight == null
176      && intake == null) {
177      logger.fine('note, record, weight, and intake are null: returning null');
178      return null;
179    }
180    return (
181      timestamp: time,
182      note: note,
183      record: record,
184      intake: intake,
185      weight: weight,
186    );
187  }
188
189  @override
190  bool isEmptyInputFocused() => false; // doesn't contain text inputs
191
192  @override
193  void fillForm(AddEntryFormValue? value) {
194    logger.finer('fillForm($value)');
195    _lastSavedPressure = value?.record;
196    _lastSavedWeight = value?.weight;
197    _lastSavedIntake = value?.intake;
198    if (value == null) {
199      _timeForm.currentState?.fillForm(null);
200      _noteForm.currentState?.fillForm(null);
201      _bpForm.currentState?.fillForm(null);
202      _weightForm.currentState?.fillForm(null);
203      _intakeForm.currentState?.fillForm(null);
204    } else {
205      _timeForm.currentState?.fillForm(value.timestamp);
206      if (value.note != null) {
207        final c = value.note?.color == null ? null : Color(value.note!.color!);
208        _noteForm.currentState?.fillForm((value.note!.note, c));
209      }
210      if (value.record != null) {
211        _bpForm.currentState?.fillForm((
212          sys: value.record?.sys?.mmHg,
213          dia: value.record?.dia?.mmHg,
214          pul: value.record?.pul,
215        ));
216      }
217      if (value.weight != null) {
218        _weightForm.currentState?.fillForm(value.weight!.weight);
219      }
220      if (value.intake != null) {
221        _intakeForm.currentState?.fillForm((
222          value.intake!.medicine,
223          value.intake!.dosis,
224        ));
225      }
226    }
227  }
228
229  /// Gets called on inputs from a bluetooth device or similar.
230  void _onExternalMeasurement(BloodPressureRecord record) {
231    final settings = context.read<Settings>();
232    if (settings.trustBLETime
233        && settings.showBLETimeTrustDialog
234        && record.time.difference(DateTime.now()).inHours.abs() > 5) {
235      unawaited(showDialog(context: context, builder: (context) => AlertDialog(
236        content: Text(AppLocalizations.of(context)!.warnBLETimeSus(
237          record.time.difference(DateTime.now()).inHours
238        )),
239        actions: [
240          ElevatedButton(
241            onPressed: () {
242              settings.showBLETimeTrustDialog = false;
243              Navigator.pop(context);
244            },
245            child: Text(AppLocalizations.of(context)!.dontShowAgain),
246          ),
247          ElevatedButton(
248            onPressed: () => Navigator.pop(context),
249            child: Text(AppLocalizations.of(context)!.btnConfirm),
250          ),
251        ],
252      )));
253    }
254
255    fillForm((
256      timestamp: settings.trustBLETime
257          ? record.time
258          : _timeForm.currentState?.save() ?? DateTime.now(),
259      note: null,
260      record: record,
261      intake: null,
262      weight: null,
263    ));
264  }
265
266  @override
267  Widget build(BuildContext context) {
268    final settings = context.watch<Settings>();
269    return ListView(
270      padding: const EdgeInsets.symmetric(horizontal: 8),
271      children: [
272        if (widget.mockBleInput != null)
273          widget.mockBleInput!.call(_onExternalMeasurement),
274        (() => switch (settings.bleInput) {
275          BluetoothInputMode.disabled => SizedBox.shrink(),
276          BluetoothInputMode.oldBluetoothInput => OldBluetoothInput(
277            onMeasurement: _onExternalMeasurement,
278          ),
279          BluetoothInputMode.newBluetoothInputOldLib => BluetoothInput(
280            manager: BluetoothManager.create(BluetoothBackend.flutterBluePlus),
281            onMeasurement: _onExternalMeasurement,
282            bluetoothCubit: widget.bluetoothCubit,
283          ),
284          BluetoothInputMode.newBluetoothInputCrossPlatform => BluetoothInput(
285            manager: BluetoothManager.create(BluetoothBackend.bluetoothLowEnergy),
286            onMeasurement: _onExternalMeasurement,
287            bluetoothCubit: widget.bluetoothCubit,
288          ),
289        })(),
290        if (settings.allowManualTimeInput)
291          DateTimeForm(
292            key: _timeForm,
293            initialValue: widget.initialValue?.timestamp,
294          ),
295        SizedBox(height: 10),
296        FormSwitcher(
297          key: Key('AddEntryFormSwitcher'), // ensures widgets are in tree
298          controller: _controller,
299          subForms: [
300            (Icon(Icons.monitor_heart_outlined), BloodPressureForm(
301              key: _bpForm,
302              initialValue: (
303                sys: widget.initialValue?.record?.sys?.mmHg,
304                dia: widget.initialValue?.record?.dia?.mmHg,
305                pul: widget.initialValue?.record?.pul,
306              ),
307            )),
308            if (widget.meds.isNotEmpty)
309              (Icon(Icons.medication_outlined), MedicineIntakeForm(
310                key: _intakeForm,
311                meds: widget.meds,
312                initialValue: widget.initialValue?.intake == null ? null : (
313                  widget.initialValue!.intake!.medicine,
314                  widget.initialValue!.intake!.dosis,
315                ),
316              )),
317            if (settings.weightInput)
318              (Icon(Icons.scale), WeightForm(
319                key: _weightForm,
320                initialValue: widget.initialValue?.weight?.weight,
321              ),),
322          ],
323        ),
324        NoteForm(
325          key: _noteForm,
326          initialValue: (){
327            logger.fine('NoteForm.initialValue: ${widget.initialValue?.note}');
328            if (widget.initialValue?.note == null) return null;
329            final note = widget.initialValue!.note!;
330            final color = note.color == null ? null : Color(note.color!);
331            return (note.note, color);
332          }(),
333        ),
334      ]
335    );
336  }
337}
338
339/// Types of entries supported by [AddEntryForm].
340typedef AddEntryFormValue = ({
341  DateTime timestamp,
342  Note? note,
343  BloodPressureRecord? record,
344  MedicineIntake? intake,
345  BodyweightRecord? weight,
346});
347
348/// Compatibility extension for simpler API surface.
349extension AddEntryFormValueCompat on FullEntry {
350  /// Utility converter for the differences in API.
351  AddEntryFormValue get asAddEntry {
352    assert(intakes.length <= 1);
353    return (
354      timestamp: time,
355      note: (note == null && color == null) ? null : noteObj,
356      record: (sys == null && dia == null && pul == null) ? null : recordObj,
357      intake: intakes.firstOrNull,
358      weight: null,
359    );
360  }
361}