Commit eb95d0b

derdilla <82763757+derdilla@users.noreply.github.com>
2025-10-14 14:56:03
Implement xls export (#604)
* Implement small xsl converter * Implement xsl export
1 parent 583489f
app/lib/features/export_import/active_field_customization.dart
@@ -7,6 +7,7 @@ import 'package:blood_pressure_app/model/storage/export_columns_store.dart';
 import 'package:blood_pressure_app/model/storage/export_csv_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/export_pdf_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/export_settings_store.dart';
+import 'package:blood_pressure_app/model/storage/export_xsl_settings_store.dart';
 import 'package:flutter/material.dart';
 import 'package:blood_pressure_app/l10n/app_localizations.dart';
 import 'package:provider/provider.dart';
@@ -23,6 +24,7 @@ class ActiveExportFieldCustomization extends StatelessWidget {
   Widget build(BuildContext context) => switch (format) {
     ExportFormat.csv => Consumer<CsvExportSettings>(builder: _builder),
     ExportFormat.pdf => Consumer<PdfExportSettings>(builder: _builder),
+    ExportFormat.xsl => Consumer<ExcelExportSettings>(builder: _builder),
     ExportFormat.db => const SizedBox.shrink()
   };
 
app/lib/features/export_import/export_button.dart
@@ -5,11 +5,13 @@ import 'dart:typed_data';
 
 import 'package:blood_pressure_app/logging.dart';
 import 'package:blood_pressure_app/model/export_import/csv_converter.dart';
+import 'package:blood_pressure_app/model/export_import/excel_converter.dart';
 import 'package:blood_pressure_app/model/export_import/pdf_converter.dart';
 import 'package:blood_pressure_app/model/storage/export_columns_store.dart';
 import 'package:blood_pressure_app/model/storage/export_csv_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/export_pdf_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/export_settings_store.dart';
+import 'package:blood_pressure_app/model/storage/export_xsl_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/interval_store.dart';
 import 'package:blood_pressure_app/model/storage/settings_store.dart';
 import 'package:collection/collection.dart';
@@ -93,6 +95,17 @@ void performExport(BuildContext context, bool share) async { // TODO: extract
       final pdf = await pdfConverter.create(await _getEntries(context));
       if (context.mounted) await _exportData(context, pdf, '$filename.pdf', 'text/pdf', share);
       break;
+    case ExportFormat.xsl:
+      final xslConverter = ExcelConverter(
+        Provider.of<ExcelExportSettings>(context, listen: false),
+        Provider.of<ExportColumnsManager>(context, listen: false),
+        await RepositoryProvider.of<MedicineRepository>(context).getAll(),
+      );
+      if (!context.mounted) return;
+      final string = xslConverter.create(await _getEntries(context));
+      final data = Uint8List.fromList(utf8.encode(string));
+      if (context.mounted) await _exportData(context, data, '$filename.xsl', 'application/vnd.ms-excel', share);
+      break;
   }
 
   if (context.mounted) {
app/lib/features/export_import/export_warn_banner.dart
@@ -78,6 +78,8 @@ class _ExportWarnBannerState extends State<ExportWarnBanner> {
           case ExportImportPreset.bloodPressureAppPdf:
             return _buildNotImportable(context);
         }
+      case ExportFormat.xsl:
+        return _buildNotImportable(context);
     }
   }
 
app/lib/features/settings/export_import_screen.dart
@@ -76,11 +76,13 @@ class ExportImportScreen extends StatelessWidget {
                 value: settings.exportFormat,
                 items: [
                   DropdownMenuItem(
-                      value: ExportFormat.csv, child: Text(localizations.csv),),
+                      value: ExportFormat.csv, child: Text(localizations.csv)),
                   DropdownMenuItem(
-                      value: ExportFormat.pdf, child: Text(localizations.pdf),),
+                      value: ExportFormat.pdf, child: Text(localizations.pdf)),
                   DropdownMenuItem(
-                      value: ExportFormat.db, child: Text(localizations.db),),
+                      value: ExportFormat.db, child: Text(localizations.db)),
+                  DropdownMenuItem(
+                    value: ExportFormat.xsl, child: Text(localizations.xsl)),
                 ],
                 onChanged: (ExportFormat? value) {
                   if (value != null) {
app/lib/l10n/app_en.arb
@@ -570,5 +570,7 @@
     "btnShare": "SHARE",
     "@btnShare": {},
     "exportSuccess": "Export successful",
-    "@exportSuccess": {}
+    "@exportSuccess": {},
+    "xsl": "Excel (xsl)",
+    "@xsl": {}
 }
app/lib/model/export_import/excel_converter.dart
@@ -0,0 +1,55 @@
+import 'package:blood_pressure_app/model/storage/export_columns_store.dart';
+import 'package:blood_pressure_app/model/storage/export_xsl_settings_store.dart';
+import 'package:health_data_store/health_data_store.dart';
+
+/// Utility class to convert [FullEntry]s to xsl files.
+class ExcelConverter {
+  /// Initialize object to convert [FullEntry]s to xsl files.
+  ExcelConverter(this.settings, this.availableColumns, this.availableMedicines);
+
+  /// Settings that apply for exports.
+  final ExcelExportSettings settings;
+
+  /// Columns manager used for export.
+  final ExportColumnsManager availableColumns;
+
+  /// Medicines to choose from during import.
+  final List<Medicine> availableMedicines;
+
+  /// Create the contents of a xls file from passed records.
+  String create(List<(DateTime, BloodPressureRecord, Note, List<MedicineIntake>, Weight?)> entries) {
+    final columns = settings.exportFieldsConfiguration.getActiveColumns(availableColumns);
+    final table = entries.map(
+          (entry) => columns.map(
+            (column) => column.encode(entry.$2, entry.$3, entry.$4, entry.$5),
+      ).toList(),
+    ).toList();
+
+    table.insert(0, columns.map((c) => c.csvTitle).toList());
+
+    return _createXls(table);
+  }
+}
+
+/// _Very_ simple string concatenation based xls file writer.
+String _createXls(List<List<String>> data) {
+  final buff = StringBuffer(_preData);
+  for (final row in data) {
+    buff.write('<Row ss:Height="12.816">');
+    for (final cell in row) {
+      final num = int.tryParse(cell) != null || double.tryParse(cell) != null;
+      buff.write('<Cell>'
+          '<Data ss:Type="${num ? 'Number' : 'String'}">$cell</Data>'
+          '</Cell>');
+    }
+    buff.write('</Row>');
+  }
+  buff.write(_postData);
+  return buff.toString();
+}
+
+const _preData = '''
+<?xml version="1.0" encoding="UTF-8"?>
+<?mso-application progid="Excel.Sheet"?><Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:c="urn:schemas-microsoft-com:office:component:spreadsheet" xmlns:html="http://www.w3.org/TR/REC-html40" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet" xmlns:x2="http://schemas.microsoft.com/office/excel/2003/xml" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><OfficeDocumentSettings xmlns="urn:schemas-microsoft-com:office:office"><Colors><Color><Index>3</Index><RGB>#000000</RGB></Color><Color><Index>4</Index><RGB>#0000ee</RGB></Color><Color><Index>5</Index><RGB>#006600</RGB></Color><Color><Index>6</Index><RGB>#333333</RGB></Color><Color><Index>7</Index><RGB>#808080</RGB></Color><Color><Index>8</Index><RGB>#996600</RGB></Color><Color><Index>9</Index><RGB>#c0c0c0</RGB></Color><Color><Index>10</Index><RGB>#cc0000</RGB></Color><Color><Index>11</Index><RGB>#ccffcc</RGB></Color><Color><Index>12</Index><RGB>#dddddd</RGB></Color><Color><Index>13</Index><RGB>#ffcccc</RGB></Color><Color><Index>14</Index><RGB>#ffffcc</RGB></Color><Color><Index>15</Index><RGB>#ffffff</RGB></Color></Colors></OfficeDocumentSettings><ExcelWorkbook xmlns="urn:schemas-microsoft-com:office:excel"><WindowHeight>9000</WindowHeight><WindowWidth>13860</WindowWidth><WindowTopX>240</WindowTopX><WindowTopY>75</WindowTopY><ProtectStructure>False</ProtectStructure><ProtectWindows>False</ProtectWindows></ExcelWorkbook><Styles><Style ss:ID="Default" ss:Name="Default"/><Style ss:ID="Note" ss:Name="Note"><Font ss:FontName="Liberation Sans" ss:Size="10"/></Style><Style ss:ID="Default" ss:Name="Default"/><Style ss:ID="Heading" ss:Name="Heading"><Alignment/><Font ss:Bold="1" ss:Size="24"/></Style><Style ss:ID="Heading_20_1" ss:Name="Heading 1"><Alignment/><Font ss:Bold="1" ss:Size="18"/></Style><Style ss:ID="Heading_20_2" ss:Name="Heading 2"><Alignment/><Font ss:Bold="1" ss:Size="12"/></Style><Style ss:ID="Text" ss:Name="Text"><Alignment/></Style><Style ss:ID="Note" ss:Name="Note"><Alignment/><Borders><Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#808080"/><Border ss:Position="Left" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#808080"/><Border ss:Position="Right" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#808080"/><Border ss:Position="Top" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#808080"/></Borders><Interior ss:Color="#ffffcc" ss:Pattern="Solid"/></Style><Style ss:ID="Footnote" ss:Name="Footnote"><Alignment/></Style><Style ss:ID="Hyperlink" ss:Name="Hyperlink"><Alignment/></Style><Style ss:ID="Status" ss:Name="Status"><Alignment/></Style><Style ss:ID="Good" ss:Name="Good"><Alignment/><Interior ss:Color="#ccffcc" ss:Pattern="Solid"/></Style><Style ss:ID="Neutral" ss:Name="Neutral"><Alignment/><Interior ss:Color="#ffffcc" ss:Pattern="Solid"/></Style><Style ss:ID="Bad" ss:Name="Bad"><Alignment/><Interior ss:Color="#ffcccc" ss:Pattern="Solid"/></Style><Style ss:ID="Warning" ss:Name="Warning"><Alignment/></Style><Style ss:ID="Error" ss:Name="Error"><Alignment/><Interior ss:Color="#cc0000" ss:Pattern="Solid"/></Style><Style ss:ID="Accent" ss:Name="Accent"><Alignment/></Style><Style ss:ID="Accent_20_1" ss:Name="Accent 1"><Alignment/><Font ss:Bold="1" ss:Color="#ffffff"/><Interior ss:Color="#000000" ss:Pattern="Solid"/></Style><Style ss:ID="Accent_20_2" ss:Name="Accent 2"><Alignment/><Font ss:Bold="1" ss:Color="#ffffff"/><Interior ss:Color="#808080" ss:Pattern="Solid"/></Style><Style ss:ID="Accent_20_3" ss:Name="Accent 3"><Alignment/><Interior ss:Color="#dddddd" ss:Pattern="Solid"/></Style><Style ss:ID="Result" ss:Name="Result"><Alignment/><Font ss:Bold="1" ss:Italic="1" ss:Underline="Single"/></Style><Style ss:ID="co1"/><Style ss:ID="ta1"/></Styles><ss:Worksheet ss:Name="Sheet1"><Table ss:StyleID="ta1"><Column ss:Span="2" ss:Width="64.008"/>''';
+
+const _postData = '</Table><x:WorksheetOptions/></ss:Worksheet></Workbook>';
app/lib/model/storage/db/config_dao.dart
@@ -4,6 +4,7 @@ import 'package:blood_pressure_app/model/storage/export_columns_store.dart';
 import 'package:blood_pressure_app/model/storage/export_csv_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/export_pdf_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/export_settings_store.dart';
+import 'package:blood_pressure_app/model/storage/export_xsl_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/interval_store.dart';
 import 'package:blood_pressure_app/model/storage/settings_store.dart';
 
@@ -185,4 +186,8 @@ class ConfigDao implements SettingsLoader {
     _exportColumnsManagerInstance = columnsManager;
     return columnsManager;
   }
+
+  @override
+  Future<ExcelExportSettings> loadXslExportSettings() async =>
+      ExcelExportSettings(); // This was added after file settings
 }
app/lib/model/storage/db/file_settings_loader.dart
@@ -7,6 +7,7 @@ import 'package:blood_pressure_app/model/storage/export_columns_store.dart';
 import 'package:blood_pressure_app/model/storage/export_csv_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/export_pdf_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/export_settings_store.dart';
+import 'package:blood_pressure_app/model/storage/export_xsl_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/interval_store.dart';
 import 'package:blood_pressure_app/model/storage/settings_store.dart';
 import 'package:flutter/widgets.dart';
@@ -102,6 +103,14 @@ class FileSettingsLoader implements SettingsLoader {
     (e) => e.toJson(),
   );
 
+  @override
+  Future<ExcelExportSettings> loadXslExportSettings() async => _loadFile(
+    'xsl-export',
+    ExcelExportSettings.fromJson,
+    ExcelExportSettings.new,
+    (e) => e.toJson(),
+  );
+
   @override
   Future<Settings> loadSettings() async => _loadFile(
     'general',
app/lib/model/storage/db/settings_loader.dart
@@ -2,6 +2,7 @@ import 'package:blood_pressure_app/model/storage/export_columns_store.dart';
 import 'package:blood_pressure_app/model/storage/export_csv_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/export_pdf_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/export_settings_store.dart';
+import 'package:blood_pressure_app/model/storage/export_xsl_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/interval_store.dart';
 import 'package:blood_pressure_app/model/storage/settings_store.dart';
 
@@ -54,4 +55,12 @@ abstract class SettingsLoader {
   ///
   /// Changes to the disk data will not propagate to the object.
   Future<ExportColumnsManager> loadExportColumnsManager();
+
+  /// Loads the profiles [ExcelExportSettings] object from disk.
+  ///
+  /// If any errors occur or the object is not present, a default one will be
+  /// created. Changes in the object will save to the automatically.
+  ///
+  /// Changes to the disk data will not propagate to the object.
+  Future<ExcelExportSettings> loadXslExportSettings();
 }
app/lib/model/storage/export_settings_store.dart
@@ -75,19 +75,16 @@ class ExportSettings extends ChangeNotifier {
 /// File formats to which measurements can be exported.
 enum ExportFormat {
   csv,
+  xsl,
   pdf,
   db;
 
-  int serialize() {
-    switch(this) {
-      case ExportFormat.csv:
-        return 0;
-      case ExportFormat.pdf:
-        return 1;
-      case ExportFormat.db:
-        return 2;
-    }
-  }
+  int serialize() => switch(this) {
+    ExportFormat.csv => 0,
+    ExportFormat.pdf => 1,
+    ExportFormat.db => 2,
+    ExportFormat.xsl => 3,
+  };
 
   factory ExportFormat.deserialize(value) {
     final int? intValue = ConvertUtil.parseInt(value);
@@ -100,6 +97,8 @@ enum ExportFormat {
         return ExportFormat.pdf;
       case 2:
         return ExportFormat.db;
+      case 3:
+        return ExportFormat.xsl;
       default:
         assert(false);
         return ExportFormat.csv;
app/lib/model/storage/export_xsl_settings_store.dart
@@ -0,0 +1,54 @@
+import 'dart:convert';
+
+import 'package:blood_pressure_app/model/export_import/export_configuration.dart';
+import 'package:blood_pressure_app/model/storage/common_settings_interfaces.dart';
+import 'package:flutter/material.dart';
+
+/// Settings that are only important for exporting entries to csv files.
+class ExcelExportSettings extends ChangeNotifier implements CustomFieldsSettings {
+  ExcelExportSettings({
+    ActiveExportColumnConfiguration? exportFieldsConfiguration,
+  }) {
+    if (exportFieldsConfiguration != null) _exportFieldsConfiguration = exportFieldsConfiguration;
+
+    _exportFieldsConfiguration.addListener(notifyListeners);
+  }
+
+  /// Create a instance from a map created by [toMap].
+  factory ExcelExportSettings.fromMap(Map<String, dynamic> map) => ExcelExportSettings(
+    exportFieldsConfiguration: ActiveExportColumnConfiguration.fromJson(map['exportFieldsConfiguration']),
+  );
+
+  /// Create a instance from a map created by [toJson].
+  factory ExcelExportSettings.fromJson(String json) {
+    try {
+      return ExcelExportSettings.fromMap(jsonDecode(json));
+    } catch (e) {
+      assert(e is FormatException || e is TypeError);
+      return ExcelExportSettings();
+    }
+  }
+
+  /// Serialize the object to a restoreable map.
+  Map<String, dynamic> toMap() => <String, dynamic>{
+    'exportFieldsConfiguration': exportFieldsConfiguration.toJson(),
+  };
+
+  /// Serializes the object to json string.
+  String toJson() => jsonEncode(toMap());
+
+  /// Copy all values from another instance.
+  void copyFrom(ExcelExportSettings other) {
+    _exportFieldsConfiguration = other._exportFieldsConfiguration;
+    notifyListeners();
+  }
+
+  /// Reset all fields to their default values.
+  void reset() => copyFrom(ExcelExportSettings());
+
+  ActiveExportColumnConfiguration _exportFieldsConfiguration = ActiveExportColumnConfiguration();
+  @override
+  ActiveExportColumnConfiguration get exportFieldsConfiguration => _exportFieldsConfiguration;
+
+  // Procedure for adding more entries described in the settings_store.dart doc comment
+}
app/lib/app.dart
@@ -5,6 +5,7 @@ import 'package:blood_pressure_app/l10n/app_localizations.dart';
 import 'package:blood_pressure_app/model/storage/db/file_settings_loader.dart';
 import 'package:blood_pressure_app/model/storage/db/settings_loader.dart';
 import 'package:blood_pressure_app/model/storage/export_columns_store.dart';
+import 'package:blood_pressure_app/model/storage/export_xsl_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/storage.dart';
 import 'package:blood_pressure_app/screens/error_reporting_screen.dart';
 import 'package:blood_pressure_app/screens/home_screen.dart';
@@ -41,6 +42,7 @@ class _AppState extends State<App> {
   ExportSettings? _exportSettings;
   CsvExportSettings? _csvExportSettings;
   PdfExportSettings? _pdfExportSettings;
+  ExcelExportSettings? _xslExportSettings;
   IntervalStoreManager? _intervalStorageManager;
   ExportColumnsManager? _exportColumnsManager;
 
@@ -52,6 +54,7 @@ class _AppState extends State<App> {
     _exportSettings?.dispose();
     _csvExportSettings?.dispose();
     _pdfExportSettings?.dispose();
+    _xslExportSettings?.dispose();
     _intervalStorageManager?.dispose();
     _exportColumnsManager?.dispose();
     super.dispose();
@@ -90,6 +93,7 @@ class _AppState extends State<App> {
       _exportSettings ??= await settingsLoader.loadExportSettings();
       _csvExportSettings ??= await settingsLoader.loadCsvExportSettings();
       _pdfExportSettings ??= await settingsLoader.loadPdfExportSettings();
+      _xslExportSettings ??= await settingsLoader.loadXslExportSettings();
       _intervalStorageManager ??= await settingsLoader.loadIntervalStorageManager();
       _exportColumnsManager ??= await settingsLoader.loadExportColumnsManager();
     } catch (e, stack) {
@@ -167,6 +171,7 @@ class _AppState extends State<App> {
           ChangeNotifierProvider.value(value: _exportSettings!),
           ChangeNotifierProvider.value(value: _csvExportSettings!),
           ChangeNotifierProvider.value(value: _pdfExportSettings!),
+          ChangeNotifierProvider.value(value: _xslExportSettings!),
           ChangeNotifierProvider.value(value: _intervalStorageManager!),
           ChangeNotifierProvider.value(value: _exportColumnsManager!),
         ],
app/pubspec.lock
@@ -1377,7 +1377,7 @@ packages:
     source: hosted
     version: "1.1.0"
   xml:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: xml
       sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
app/pubspec.yaml
@@ -43,6 +43,7 @@ dependencies:
   # desktop only
   sqflite_common_ffi: ^2.3.6
   inline_tab_view: ^1.0.1
+  xml: ^6.6.1
 
 dev_dependencies:
   integration_test: