Commit 669ac39
Changed files (4)
lib
components
dialoges
model
blood_pressure
storage
screens
subsettings
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) {