Commit 669ac39

derdilla <82763757+NobodyForNothing@users.noreply.github.com>
2024-02-29 15:20:43
implement measurement creation
Signed-off-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com>
1 parent 84288a1
Changed files (4)
lib
components
model
blood_pressure
storage
screens
lib/components/dialoges/tree_selection_dialoge.dart
@@ -6,6 +6,10 @@ import 'package:flutter/material.dart';
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 
 /// Generic multilayered fullscreen dialoge for nesting string selections.
+///
+/// Unlike other dialoges this doesn't drop the scope by default and needs users
+/// to implement it in the [onSaved] attribute.
+// TODO: consider making this a abstract class and require implementation.
 class TreeSelectionDialoge extends StatefulWidget {
   /// Create a multilayered string selection dialoge.
   const TreeSelectionDialoge({super.key,
@@ -13,6 +17,7 @@ class TreeSelectionDialoge extends StatefulWidget {
     required this.buildOptions,
     required this.bottomAppBars,
     this.validator,
+    required this.onSaved,
   });
 
   /// Builder for currently visible options.
@@ -33,11 +38,21 @@ class TreeSelectionDialoge extends StatefulWidget {
 
   /// Validates selections and returns errors.
   ///
+  /// Invoked manually by the user.
+  ///
   /// When this function returns a string saving is not possible and the string
   /// will be shown to the user. When this function returns null or is null the
-  /// selections will be returned popped to the underlying scope.
+  /// the [onSaved] callback will be called.
   final String? Function(List<String> madeSelections)? validator;
 
+  /// Stores form state after [validator] pass.
+  ///
+  /// This function is responsible to prepare the form response for further use
+  /// and to pop the scope.
+  final void Function(List<String> madeSelections) onSaved;
+
+  // TODO: check mutability of passed arguments
+
   /// Whether to move the app bar for saving and loading to the bottom of the
   /// screen.
   final bool bottomAppBars;
@@ -67,7 +82,7 @@ class _TreeSelectionDialogeState extends State<TreeSelectionDialoge>
     super.initState();
     _controller = AnimationController(
       vsync: this,
-      duration: Duration(milliseconds: 100)
+      duration: const Duration(milliseconds: 100),
     );
   }
 
@@ -99,9 +114,7 @@ class _TreeSelectionDialogeState extends State<TreeSelectionDialoge>
           if (_error != null) {
             return;
           }
-          final selections = _selections.toList();
-          _selections.clear();
-          Navigator.pop(context, selections);
+          widget.onSaved(_selections);
         },
         actionButtonText: localizations.btnSave,
         bottomAppBar: widget.bottomAppBars,
lib/model/blood_pressure/record.dart
@@ -34,6 +34,23 @@ class BloodPressureRecord {
   /// Secondary information about the measurement.
   final MeasurementNeedlePin? needlePin;
 
+  /// Creates a new record from this one by updating individual properties.
+  BloodPressureRecord copyWith({
+    DateTime? creationTime,
+    int? systolic,
+    int? diastolic,
+    int? pulse,
+    String? notes,
+    MeasurementNeedlePin? needlePin,
+  }) => BloodPressureRecord(
+    creationTime ?? this.creationTime,
+    systolic ?? this.systolic,
+    diastolic ?? this.diastolic,
+    pulse ?? this.pulse,
+    notes ?? this.notes,
+    needlePin: needlePin ?? this.needlePin,
+  );
+
   @override
   String toString() => 'BloodPressureRecord($creationTime, $systolic, $diastolic, $pulse, $notes, $needlePin)';
 }
lib/model/storage/convert_util.dart
@@ -107,4 +107,21 @@ class ConvertUtil {
         return null;
     }
   }
+
+  /// Does its best attempt at parsing a time in arbitrary format.
+  static DateTime? parseTime(dynamic time) {
+    final intTime = parseInt(time);
+    if (intTime != null) {
+      if (intTime.toString().length == 10) { // seconds
+        return DateTime.fromMillisecondsSinceEpoch(intTime * 1000);
+      } else if (intTime.toString().length == 13) {  // milliseconds
+        return DateTime.fromMillisecondsSinceEpoch(intTime);
+      } else if (intTime.toString().length > 13) {  // nanoseconds
+        return DateTime.fromMicrosecondsSinceEpoch(intTime ~/ 1000);
+      }
+    }
+
+    final timeStr = parseString(time);
+    return DateTime.tryParse(timeStr ?? '');
+  }
 }
lib/screens/subsettings/foreign_db_import_screen.dart
@@ -1,12 +1,19 @@
+import 'dart:convert';
+
 import 'package:blood_pressure_app/components/consistent_future_builder.dart';
 import 'package:blood_pressure_app/components/dialoges/tree_selection_dialoge.dart';
+import 'package:blood_pressure_app/model/blood_pressure/needle_pin.dart';
+import 'package:blood_pressure_app/model/blood_pressure/record.dart';
 import 'package:blood_pressure_app/model/export_import/import_field_type.dart';
+import 'package:blood_pressure_app/model/storage/convert_util.dart';
 import 'package:collection/collection.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 import 'package:sqflite/sqflite.dart';
 
 /// Screen to select the columns from a database and annotate types.
+///
+/// Parses data table to [BloodPressureRecord] list.
 class ForeignDBImportScreen extends StatefulWidget {
   /// Create a screen to import data from a database with unknown structure.
   const ForeignDBImportScreen({super.key, required this.db});
@@ -44,7 +51,7 @@ class _ForeignDBImportScreenState extends State<ForeignDBImportScreen> {
                 .toList();
           }
         },
-        validator: (elements) {
+        validator: (List<String> elements) {
           const kMetaColumns = 2;
           if (elements.isEmpty) return 'No table selected';
           if (elements.length < kMetaColumns) return 'No time column selected';
@@ -55,7 +62,62 @@ class _ForeignDBImportScreenState extends State<ForeignDBImportScreen> {
 
           return 'The schnibledumps doesn\'t schwibble!'; // TODO
         },
-        buildTitle: (selections) {
+        onSaved: (List<String> madeSelections) async {
+          final tableName = madeSelections.removeAt(0);
+          final timeColumn = madeSelections.removeAt(0);
+          final dataColumns = <(String, RowDataFieldType)>[];
+          while (madeSelections.isNotEmpty) {
+            final column = madeSelections.removeAt(0);
+            final typeStr = madeSelections.removeAt(0);
+            final type = RowDataFieldType.values
+                .firstWhere((t) => t.localize(localizations) == typeStr);
+            dataColumns.add((column, type));
+          }
+
+          final data = await widget.db.query(tableName);
+          final measurements = <BloodPressureRecord>[];
+          for (final row in data) {
+            assert(row.containsKey(timeColumn)
+                && madeSelections.every(row.containsKey),);
+            final timestamp = ConvertUtil.parseTime(row[timeColumn]);
+            if (timestamp == null) throw FormatException('Unable to parse time: ${row[timeColumn]}'); // TODO: error handling
+            var record = BloodPressureRecord(timestamp, null, null, null, '');
+            for (final colType in dataColumns) {
+              switch (colType.$2) {
+                case RowDataFieldType.timestamp:
+                  assert(false, 'Not up for selection');
+                case RowDataFieldType.sys:
+                  record = record.copyWith(
+                    systolic: ConvertUtil.parseInt(row[colType.$1]),
+                  );
+                case RowDataFieldType.dia:
+                  record = record.copyWith(
+                    diastolic: ConvertUtil.parseInt(row[colType.$1]),
+                  );
+                case RowDataFieldType.pul:
+                  record = record.copyWith(
+                    pulse: ConvertUtil.parseInt(row[colType.$1]),
+                  );
+                case RowDataFieldType.notes:
+                  record = record.copyWith(
+                    notes: ConvertUtil.parseString(row[colType.$1]),
+                  );
+                case RowDataFieldType.needlePin:
+                  try {
+                    final pin = MeasurementNeedlePin.fromJson(jsonDecode(row[colType.$1].toString()));
+                    record = record.copyWith(
+                      needlePin: pin,
+                    );
+                  } on FormatException {
+                    // Not parsable: silently ignore for now
+                  }
+              }
+            }
+            measurements.add(record);
+          }
+          if (context.mounted) Navigator.pop(context, measurements);
+        },
+        buildTitle: (List<String> selections) {
           if (selections.isEmpty) return 'Select table';
           if (selections.length == 1) return 'Select time column';
           if ((selections.length % 2 == 0)) {
@@ -66,7 +128,6 @@ class _ForeignDBImportScreenState extends State<ForeignDBImportScreen> {
         },
         bottomAppBars: true, // TODO
       );
-      // TODO: perform import
       // TODO: localize everything
       // TODO: detect when no more selections are possible
     },
@@ -79,7 +140,7 @@ class _ColumnImportData {
   static Future<_ColumnImportData> loadFromDB(Database db) async {
     final masterTable = await db.query('sqlite_master',
       columns: ['name', 'sql'],
-      where: 'type = "table"'
+      where: 'type = "table"',
     );
     final columns = <String, List<String>?>{};
     for (final e in masterTable) {