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}