Commit e26f182

derdilla <derdilla06@gmail.com>
2023-08-01 16:17:02
allow adding and editing export items
1 parent 8af0e3f
lib/components/export_item_order.dart
@@ -4,6 +4,7 @@ import 'dart:async';
 import 'package:badges/badges.dart' as badges;
 import 'package:blood_pressure_app/components/consistent_future_builder.dart';
 import 'package:blood_pressure_app/model/export_options.dart';
+import 'package:blood_pressure_app/screens/subsettings/export_column_data.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 import 'package:provider/provider.dart';
@@ -11,11 +12,11 @@ import 'package:provider/provider.dart';
 import '../model/settings_store.dart';
 
 class ExportItemsCustomizer extends StatefulWidget {
-  final List<String> exportItems;
-  final List<String> exportAddableItems;
-  final FutureOr<void> Function(List<String> exportItems, List<String> exportAddableItems) onReorder;
+  final List<ExportColumn> shownItems;
+  final List<ExportColumn> hiddenItems;
+  final FutureOr<void> Function(List<ExportColumn> exportItems, List<ExportColumn> exportAddableItems) onReorder;
 
-  const ExportItemsCustomizer({super.key, required this.exportItems, required this.exportAddableItems,
+  const ExportItemsCustomizer({super.key, required this.shownItems, required this.hiddenItems,
       required this.onReorder});
 
   @override
@@ -32,7 +33,6 @@ class _ExportItemsCustomizerState extends State<ExportItemsCustomizer> {
     return ConsistentFutureBuilder(
       future: _future!,
       onData: (BuildContext context, ExportConfigurationModel result) {
-        final formats = result.availableFormats;
         return badges.Badge(
           badgeStyle: badges.BadgeStyle(
             badgeColor: Theme.of(context).colorScheme.background,
@@ -40,15 +40,21 @@ class _ExportItemsCustomizerState extends State<ExportItemsCustomizer> {
           ),
           position: badges.BadgePosition.bottomEnd(bottom: 3, end: 3),
           badgeContent: Container(
-          decoration: BoxDecoration(
-              border: Border.all(color: Theme.of(context).colorScheme.onBackground),
-              shape: BoxShape.circle
-          ),
-          child: IconButton(
-            tooltip: 'add exportformat',
-            onPressed:() {},
-            icon: const Icon(Icons.add),
-          ),
+            decoration: BoxDecoration(
+                border: Border.all(color: Theme.of(context).colorScheme.onBackground),
+                shape: BoxShape.circle
+            ),
+            child: IconButton(
+              tooltip: 'add exportformat',
+              onPressed:() {// TODO move outside of potential reusable thing
+                Navigator.of(context).push(MaterialPageRoute(builder: (context) =>
+                  EditExportColumnPage(onValidSubmit: (value) {
+                    result.add(value); 
+                  },)
+                ));
+              },
+              icon: const Icon(Icons.add),
+            ),
           ),
           child: Container(
             margin: const EdgeInsets.all(25),
@@ -60,22 +66,26 @@ class _ExportItemsCustomizerState extends State<ExportItemsCustomizer> {
             ),
             clipBehavior: Clip.hardEdge,
             child: ReorderableListView(
-              physics: const NeverScrollableScrollPhysics(),
               shrinkWrap: true,
               onReorder: _onReorderList,
               children: <Widget>[
-                for (int i = 0; i < widget.exportItems.length; i += 1)
+                for (int i = 0; i < widget.shownItems.length; i += 1)
                   ListTile(
-                    key: Key('l_${widget.exportItems[i]}'),
-                    title: Text(formats[widget.exportItems[i]]?.columnTitle ?? widget.exportItems[i]),
-                    trailing: const Icon(Icons.drag_handle),
+                    key: Key('l_${widget.shownItems[i].internalName}'),
+                    title: Text(widget.shownItems[i].columnTitle),
+                    trailing: _buildListItemTrailing( context, widget.shownItems[i]),
+                    contentPadding: EdgeInsets.zero
                   ),
                 _buildListSectionDivider(context),
-                for (int i = 0; i < widget.exportAddableItems.length; i += 1)
+                for (int i = 0; i < widget.hiddenItems.length; i += 1)
                   ListTile(
-                    key: Key('ul_${widget.exportAddableItems[i]}'),
-                    title: Opacity(opacity: 0.7,child: Text(widget.exportAddableItems[i]),),
-                    trailing: const Icon(Icons.drag_handle),
+                    key: Key('ul_${widget.hiddenItems[i].internalName}'),
+                    title: Opacity(
+                      opacity: 0.7,
+                      child: Text(widget.hiddenItems[i].columnTitle),
+                    ),
+                    trailing: _buildListItemTrailing(context, widget.hiddenItems[i]),
+                    contentPadding: EdgeInsets.zero
                   ),
               ],
             ),
@@ -85,6 +95,30 @@ class _ExportItemsCustomizerState extends State<ExportItemsCustomizer> {
     );
   }
 
+  Widget _buildListItemTrailing(BuildContext context, ExportColumn data) {
+    return Row(
+      mainAxisSize: MainAxisSize.min,
+      children: [
+        IconButton(
+          onPressed: () {
+            Navigator.of(context).push(MaterialPageRoute(builder: (context) =>
+                EditExportColumnPage(
+                  initialDisplayName: data.columnTitle,
+                  initialInternalName: data.internalName,
+                  initialFormatPattern: data.formatPattern,
+                  onValidSubmit: (value) {
+                    // TODO save
+                  },
+                )
+            ));
+          },
+          icon: const Icon(Icons.edit)
+        ),
+        const Icon(Icons.drag_handle),
+      ],
+    );
+  }
+
   Widget _buildListSectionDivider(BuildContext context) {
     return IgnorePointer(
       key: UniqueKey(),
@@ -133,24 +167,24 @@ class _ExportItemsCustomizerState extends State<ExportItemsCustomizer> {
       newIndex -= 1;
     }
 
-    final String item;
-    if (0 <= oldIndex && oldIndex < widget.exportItems.length) {
-      item = widget.exportItems.removeAt(oldIndex);
-    } else if ((widget.exportItems.length + 1) <= oldIndex && oldIndex < (widget.exportItems.length + 1 + widget.exportAddableItems.length)) {
-      item = widget.exportAddableItems.removeAt(oldIndex - (widget.exportItems.length + 1));
+    final ExportColumn item;
+    if (0 <= oldIndex && oldIndex < widget.shownItems.length) {
+      item = widget.shownItems.removeAt(oldIndex);
+    } else if ((widget.shownItems.length + 1) <= oldIndex && oldIndex < (widget.shownItems.length + 1 + widget.hiddenItems.length)) {
+      item = widget.hiddenItems.removeAt(oldIndex - (widget.shownItems.length + 1));
     } else {
       assert(false, 'oldIndex outside expected boundaries');
       return;
     }
 
-    if (newIndex < (widget.exportItems.length + 1)) {
-      widget.exportItems.insert(newIndex, item);
+    if (newIndex < (widget.shownItems.length + 1)) {
+      widget.shownItems.insert(newIndex, item);
     } else {
-      newIndex -= (widget.exportItems.length + 1);
-      widget.exportAddableItems.insert(newIndex, item);
+      newIndex -= (widget.shownItems.length + 1);
+      widget.hiddenItems.insert(newIndex, item);
     }
 
-    widget.onReorder(widget.exportItems, widget.exportAddableItems);
+    widget.onReorder(widget.shownItems, widget.hiddenItems);
   }
 }
 
lib/l10n/app_en.arb
@@ -358,5 +358,15 @@
         "type": "String"
       }
     }
-  }
+  },
+  "displayTitle": "Display title",
+  "@displayTitle": {},
+  "internalName": "Internal name",
+  "@internalName": {},
+  "errOnlyLatinCharactersAndArabicNumbers": "Only latin characters or arabic number allowed",
+  "@errOnlyLatinCharactersAndArabicNumbers":  {},
+  "fieldFormat": "Field format",
+  "@fieldFormat": {},
+  "result": "Result:",
+  "@result": {}
 }
lib/model/export_options.dart
@@ -35,7 +35,7 @@ class ExportConfigurationModel {
 
     final existingDbEntries = await _database.rawQuery('SELECT * FROM exportStrings');
     for (final e in existingDbEntries) {
-      _availableFormats.add(ExportColumn(internalColumnName: e['internalColumnName'].toString(),
+      _availableFormats.add(ExportColumn(internalName: e['internalColumnName'].toString(),
           columnTitle: e['columnTitle'].toString(), formatPattern: e['formatPattern'].toString()));
     }
     _availableFormats.addAll(_getDefaultFormates());
@@ -49,31 +49,31 @@ class ExportConfigurationModel {
   }
 
   List<ExportColumn> _getDefaultFormates() => [ // TODO: localizations
-    ExportColumn(internalColumnName: 'timestampUnixMs', columnTitle: 'Unix timestamp', formatPattern: r'$TIMESTAMP'),
-    ExportColumn(internalColumnName: 'formattedTimestamp', columnTitle: 'Time', formatPattern: '\$FORMAT{\$TIMESTAMP,${settings.dateFormatString}}'),
-    ExportColumn(internalColumnName: 'systolic', columnTitle: 'Systolic', formatPattern: r'$SYS'),
-    ExportColumn(internalColumnName: 'diastolic', columnTitle: 'Diastolic', formatPattern: r'$DIA'),
-    ExportColumn(internalColumnName: 'pulse', columnTitle: 'Pulse', formatPattern: r'$PUL'),
-    ExportColumn(internalColumnName: 'notes', columnTitle: 'Notes', formatPattern: r'$NOTE'),
-    ExportColumn(internalColumnName: 'pulsePressure', columnTitle: 'Pulse pressure', formatPattern: r'{{$SYS-$DIA}}')
+    ExportColumn(internalName: 'timestampUnixMs', columnTitle: 'Unix timestamp', formatPattern: r'$TIMESTAMP'),
+    ExportColumn(internalName: 'formattedTimestamp', columnTitle: 'Time', formatPattern: '\$FORMAT{\$TIMESTAMP,${settings.dateFormatString}}'),
+    ExportColumn(internalName: 'systolic', columnTitle: 'Systolic', formatPattern: r'$SYS'),
+    ExportColumn(internalName: 'diastolic', columnTitle: 'Diastolic', formatPattern: r'$DIA'),
+    ExportColumn(internalName: 'pulse', columnTitle: 'Pulse', formatPattern: r'$PUL'),
+    ExportColumn(internalName: 'notes', columnTitle: 'Notes', formatPattern: r'$NOTE'),
+    ExportColumn(internalName: 'pulsePressure', columnTitle: 'Pulse pressure', formatPattern: r'{{$SYS-$DIA}}')
   ];
 
   void add(ExportColumn format) {
     _availableFormats.add(format);
     _database.insert('exportStrings', {
-      'internalColumnName': format.internalColumnName,
+      'internalColumnName': format.internalName,
       'columnTitle': format.columnTitle,
       'formatPattern': format.formatPattern
     },);
   }
 
   UnmodifiableMapView<String, ExportColumn> get availableFormats =>
-      UnmodifiableMapView(Map.fromIterable(_availableFormats, key: (e) => e.internalColumnName));
+      UnmodifiableMapView(Map.fromIterable(_availableFormats, key: (e) => e.internalName));
 }
 
 class ExportColumn {
   /// pure name as in the title of the csv file and for internal purposes. Should not contain special characters and spaces.
-  late final String internalColumnName;
+  late final String internalName;
   /// Display title of the column. Possibly localized
   late final String columnTitle;
   /// Pattern to create the field contents from: TODO implement user input and documentation
@@ -94,20 +94,20 @@ class ExportColumn {
   late final String formatPattern;
 
   /// Example: ExportColumn(internalColumnName: 'pulsePressure', columnTitle: 'Pulse pressure', formatPattern: '{{$SYS-$DIA}}')
-  ExportColumn({required this.internalColumnName, required this.columnTitle, required String formatPattern}) {
+  ExportColumn({required this.internalName, required this.columnTitle, required String formatPattern}) {
     this.formatPattern = formatPattern.replaceAll('{{}}', '');
   }
 
   ExportColumn.fromJson(Map<String, dynamic> json) {
     ExportColumn(
-      internalColumnName: json['internalColumnName'],
+      internalName: json['internalColumnName'],
       columnTitle: json['columnTitle'],
       formatPattern: json['formatPattern']
     );
   }
 
   Map<String, dynamic> toJson() => {
-    'internalColumnName': internalColumnName,
+    'internalColumnName': internalName,
     'columnTitle': columnTitle,
     'formatPattern': formatPattern
   };
@@ -144,6 +144,6 @@ class ExportColumn {
 
   @override
   String toString() {
-    return 'ExportColumn{internalColumnName: $internalColumnName, columnTitle: $columnTitle, formatPattern: $formatPattern}';
+    return 'ExportColumn{internalColumnName: $internalName, columnTitle: $columnTitle, formatPattern: $formatPattern}';
   }
 }
\ No newline at end of file
lib/screens/subsettings/export_column_data.dart
@@ -0,0 +1,159 @@
+import 'package:blood_pressure_app/model/blood_pressure.dart';
+import 'package:blood_pressure_app/model/export_options.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+class  EditExportColumnPage extends StatefulWidget {
+  final String? initialInternalName;
+  final String? initialDisplayName;
+  final String? initialFormatPattern;
+  final void Function(ExportColumn) onValidSubmit;
+  
+  const EditExportColumnPage({super.key, this.initialDisplayName, this.initialInternalName, 
+    this.initialFormatPattern, required this.onValidSubmit});
+
+  @override
+  State<EditExportColumnPage> createState() => _EditExportColumnPageState();
+}
+
+class _EditExportColumnPageState extends State<EditExportColumnPage> {
+  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
+  String? _internalName;
+  String? _displayName;
+  String? _formatPattern;
+  bool _editedInternalName = false;
+  var _internalNameKeyNr = 0;
+
+
+  @override
+  void initState() {
+    super.initState();
+    _internalName = widget.initialInternalName;
+    _displayName = widget.initialDisplayName;
+    _formatPattern= widget.initialFormatPattern;
+  }
+
+  _EditExportColumnPageState();
+  
+  @override
+  Widget build(BuildContext context) {
+    final localizations = AppLocalizations.of(context)!;
+    return Scaffold(
+      body: Center(
+        child: Padding(
+          padding: const EdgeInsets.all(60.0),
+          child: Form(
+            key: _formKey,
+            child: SingleChildScrollView(
+              child: Column(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  TextFormField(
+                    key: const Key('displayName'),
+                    initialValue: _displayName,
+                    decoration: InputDecoration(hintText: localizations.displayTitle),
+                    onChanged: (String? value) {
+                      if (value != null && value.isNotEmpty) {
+                        setState(() {
+                          _displayName = value;
+                        });
+                        if (_editedInternalName) return;
+                        final asciiName = value.replaceAll(RegExp(r'[^A-Za-z0-9 ]'), '');
+                        final internalName = asciiName.replaceAllMapped(RegExp(r' (.)'), (match) {
+                          return match.group(1)!.toUpperCase();
+                        }).replaceAll(' ', '');
+                        setState(() {
+                          _internalNameKeyNr++;
+                          _internalName = internalName;
+                        });
+                      }
+                    },
+                  ),
+                  TextFormField(
+                    key: Key('internalName$_internalNameKeyNr'), // it should update when display name is changed without unfocussing on edit
+                    initialValue: _internalName,
+                    decoration: InputDecoration(hintText: localizations.internalName),
+                    validator: (String? value) {
+                      if (value == null || value.isEmpty || RegExp(r'[^A-Za-z0-9]').hasMatch(value)) {
+                        return localizations.errOnlyLatinCharactersAndArabicNumbers;
+                      } // TODO: check if one with this name already exists
+                      return null;
+                    },
+                    onChanged: (String? value) {
+                      if (value != null && value.isNotEmpty && !RegExp(r'[^A-Za-z0-9]').hasMatch(value)) {
+                        setState(() {
+                          _internalName = value;
+                          _editedInternalName = true;
+                        });
+                      }
+                    },
+                  ),
+                  TextFormField( // TODO: documentation
+                    key: const Key('formatPattern'),
+                    initialValue: _formatPattern,
+                    decoration: InputDecoration(hintText: localizations.fieldFormat),
+                    maxLines: 6,
+                    minLines: 1,
+                    validator: (String? value) {
+                      if (value == null || value.isEmpty) {
+                        return AppLocalizations.of(context)!.errNoValue;
+                      } else if (_internalName != null && _displayName != null) {
+                        try {
+                          final column = ExportColumn(internalName: _internalName!, columnTitle: _displayName!, formatPattern: value);
+                          column.formatRecord(BloodPressureRecord(DateTime.now(), 100, 80, 60, ''));
+                          _formatPattern = value;
+                        } catch (e) {
+                          _formatPattern = null;
+                          return e.toString();
+                        }
+                      }
+                      return null;
+                    },
+                    onChanged: (value) => setState(() {_formatPattern = value;}),
+                  ),
+                  const SizedBox(height: 12,),
+                  Text(localizations.result),
+                  Text(((){try {
+                  final column = ExportColumn(internalName: _internalName!, columnTitle: _displayName!, formatPattern: _formatPattern!);
+                    return column.formatRecord(BloodPressureRecord(DateTime.now(), 100, 80, 60, 'test'));
+                  } catch (e) {
+                    return '-';
+                  }})()),
+                  const SizedBox(height: 24,),
+                  Row(
+                    children: [
+                      TextButton(
+                          key: const Key('btnCancel'),
+                          onPressed: () {
+                            Navigator.of(context).pop();
+                          },
+
+                          child: Text(AppLocalizations.of(context)!.btnCancel)
+                      ),
+                      const Spacer(),
+                      FilledButton.icon(
+                        key: const Key('btnSave'),
+                        icon: const Icon(Icons.save),
+                        label: Text(AppLocalizations.of(context)!.btnSave),
+                        onPressed: () async {
+                          if (_formKey.currentState?.validate() ?? false) {
+                            widget.onValidSubmit(ExportColumn(
+                                internalName: _internalName!,
+                                columnTitle: _displayName!,
+                                formatPattern: _formatPattern!
+                            ));
+                            Navigator.of(context).pop();
+                          }
+                        },
+                      )
+                    ],
+                  )
+                ],
+              ),
+            ),
+          ),
+        ),
+      )
+    );
+  }
+}
\ No newline at end of file
lib/screens/subsettings/export_import_screen.dart
@@ -1,3 +1,4 @@
+import 'package:blood_pressure_app/components/consistent_future_builder.dart';
 import 'package:blood_pressure_app/components/display_interval_picker.dart';
 import 'package:blood_pressure_app/components/export_item_order.dart';
 import 'package:blood_pressure_app/components/settings_widgets.dart';
@@ -9,6 +10,8 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 import 'package:jsaver/jSaver.dart';
 import 'package:provider/provider.dart';
 
+import '../../model/export_options.dart';
+
 class ExportImportScreen extends StatelessWidget {
   const ExportImportScreen({super.key});
 
@@ -101,34 +104,62 @@ class ExportImportScreen extends StatelessWidget {
   }
 }
 
-class ExportFieldCustomisationSetting extends StatelessWidget {
+class ExportFieldCustomisationSetting extends StatefulWidget {
   const ExportFieldCustomisationSetting({super.key});
 
+  @override
+  State<ExportFieldCustomisationSetting> createState() => _ExportFieldCustomisationSettingState();
+}
+
+class _ExportFieldCustomisationSettingState extends State<ExportFieldCustomisationSetting> {
+  // hack so that FutureBuilder doesn't always rebuild
+  Future<ExportConfigurationModel>? _future;
+  
   @override
   Widget build(BuildContext context) {
-    return Consumer<Settings>(builder: (context, settings, child) {
-      return Column(
-        children: [
-          SwitchSettingsTile(
-              title: Text(AppLocalizations.of(context)!.exportCustomEntries),
-              initialValue: settings.exportCustomEntries,
-              disabled: settings.exportFormat != ExportFormat.csv,
-              onToggle: (value) {
-                settings.exportCustomEntries = value;
-              }
-          ),
-          (settings.exportFormat == ExportFormat.csv && settings.exportCustomEntries) ?
-            ExportItemsCustomizer(
-              exportItems: settings.exportItems,
-              exportAddableItems: settings.exportAddableItems,
-              onReorder: (exportItems, exportAddableItems) {
-                settings.exportItems = exportItems;
-                settings.exportAddableItems = exportAddableItems;
-              },
-            ) : const SizedBox.shrink()
-        ],
-      );
-    });
+    _future ??= ExportConfigurationModel.get(Provider.of<Settings>(context, listen: false), AppLocalizations.of(context)!);
+    
+    
+    
+    return ConsistentFutureBuilder(
+      future: _future!,
+      onData: (context, result) {
+        return Consumer<Settings>(builder: (context, settings, child) {
+          final formats = result.availableFormats;
+          List<ExportColumn> activeFields = [];
+          List<ExportColumn> hiddenFields = [];
+          formats.forEach((internalName, e) { // todo: maintain ordering of exportItems
+            if (settings.exportItems.contains(internalName)) {
+              activeFields.add(e);
+            } else {
+              hiddenFields.add(e);
+            }
+          });
+          
+          return Column(
+            children: [
+              SwitchSettingsTile(
+                  title: Text(AppLocalizations.of(context)!.exportCustomEntries),
+                  initialValue: settings.exportCustomEntries,
+                  disabled: settings.exportFormat != ExportFormat.csv,
+                  onToggle: (value) {
+                    settings.exportCustomEntries = value;
+                  }
+              ),
+              (settings.exportFormat == ExportFormat.csv && settings.exportCustomEntries) ?
+                ExportItemsCustomizer(
+                  shownItems: activeFields,
+                  hiddenItems: hiddenFields,
+                  onReorder: (exportItems, exportAddableItems) {
+                    settings.exportItems = exportItems.map((e) => e.internalName).toList();
+                    //settings.exportAddableItems = exportAddableItems; // todo remove from data
+                  },
+                ) : const SizedBox.shrink()
+            ],
+          );
+        });
+      }
+    );
   }
 }
 
lib/screens/add_measurement.dart
@@ -130,7 +130,7 @@ class _AddMeasurementPageState extends State<AddMeasurementPage> {
                             return null;
                           }
                       ),
-                      ValueInput(
+                      ValueInput(// TODO: multiline input (minlines and maxlines)
                           key: const Key('txtPul'),
                           initialValue: (_pulse ?? '').toString(),
                           hintText: AppLocalizations.of(context)!.pulLong,