1import 'dart:async';
  2import 'dart:convert';
  3import 'dart:io';
  4import 'dart:typed_data';
  5
  6import 'package:blood_pressure_app/l10n/app_localizations.dart';
  7import 'package:blood_pressure_app/logging.dart';
  8import 'package:blood_pressure_app/model/export_import/csv_converter.dart';
  9import 'package:blood_pressure_app/model/export_import/excel_converter.dart';
 10import 'package:blood_pressure_app/model/export_import/pdf_converter.dart';
 11import 'package:blood_pressure_app/model/storage/export_columns_store.dart';
 12import 'package:blood_pressure_app/model/storage/export_csv_settings_store.dart';
 13import 'package:blood_pressure_app/model/storage/export_pdf_settings_store.dart';
 14import 'package:blood_pressure_app/model/storage/export_settings_store.dart';
 15import 'package:blood_pressure_app/model/storage/export_xsl_settings_store.dart';
 16import 'package:blood_pressure_app/model/storage/interval_store.dart';
 17import 'package:blood_pressure_app/model/storage/settings_store.dart';
 18import 'package:collection/collection.dart';
 19import 'package:file_picker/file_picker.dart';
 20import 'package:flutter/material.dart';
 21import 'package:flutter_bloc/flutter_bloc.dart';
 22import 'package:health_data_store/health_data_store.dart';
 23import 'package:logging/logging.dart';
 24import 'package:path/path.dart';
 25import 'package:persistent_user_dir_access_android/persistent_user_dir_access_android.dart';
 26import 'package:provider/provider.dart';
 27import 'package:share_plus/share_plus.dart';
 28import 'package:sqflite/sqflite.dart';
 29
 30/// Text button to export entries like configured in the context.
 31class ExportButton extends StatelessWidget {
 32  /// Create a text button to export entries like configured in the context.
 33  const ExportButton({
 34    super.key,
 35    required this.share,
 36  });
 37
 38  /// Whether to use the device sharing feature instead of the saving feature
 39  /// for export.
 40  final bool share;
 41
 42  @override
 43  Widget build(BuildContext context) => TextButton.icon(
 44    label: Text(share ? AppLocalizations.of(context)!.btnShare : AppLocalizations.of(context)!.export),
 45    icon: Icon(share ? Icons.share : Icons.file_download_outlined),
 46    onPressed: () => performExport(context, share),
 47  );
 48}
 49
 50Logger _logger = Logger('BPM[export_button]');
 51
 52/// Perform a full export according to the configuration in [context].
 53void performExport(BuildContext context, bool share) async { // TODO: extract
 54  _logger.finer('performExport - mounted=${context.mounted}');
 55  final localizations = AppLocalizations.of(context);
 56  final exportSettings = Provider.of<ExportSettings>(context, listen: false);
 57  _logger.fine('performExport - exportSettings=${exportSettings.toJson()}');
 58  final filename = 'blood_press_${DateTime.now().toIso8601String()}';
 59  switch (exportSettings.exportFormat) {
 60    case ExportFormat.db:
 61      final path = join(await getDatabasesPath(), 'bp.db');
 62      final data = await File(path).readAsBytes();
 63
 64      if (context.mounted) await _exportData(context, data, '$filename.db', 'application/vnd.sqlite3', share);
 65      break;
 66    case ExportFormat.csv:
 67      final csvSettings = Provider.of<CsvExportSettings>(context, listen: false);
 68      final exportColumnsManager = Provider.of<ExportColumnsManager>(context, listen: false);
 69      final csvConverter = CsvConverter(
 70        csvSettings,
 71        exportColumnsManager,
 72        await RepositoryProvider.of<MedicineRepository>(context).getAll(),
 73      );
 74      if (!context.mounted) {
 75        _logger.warning('performExport - No longer mounted: stopping export');
 76        return;
 77      }
 78      final csvString = csvConverter.create(await _getEntries(context));
 79      _logger.fine('performExport - Created csvString=$csvString');
 80      final data = Uint8List.fromList(utf8.encode(csvString));
 81      if (context.mounted) {
 82        _logger.finer('performExport - Calling _exportData');
 83        await _exportData(context, data, '$filename.csv', 'text/csv', share);
 84      } else  {
 85        _logger.warning('performExport - No longer mounted: stopping export');
 86      }
 87      break;
 88    case ExportFormat.pdf:
 89      final pdfConverter = PdfConverter(
 90          Provider.of<PdfExportSettings>(context, listen: false),
 91          localizations!,
 92          Provider.of<Settings>(context, listen: false),
 93          Provider.of<ExportColumnsManager>(context, listen: false),
 94      );
 95      final pdf = await pdfConverter.create(await _getEntries(context));
 96      if (context.mounted) await _exportData(context, pdf, '$filename.pdf', 'text/pdf', share);
 97      break;
 98    case ExportFormat.xsl:
 99      final xslConverter = ExcelConverter(
100        Provider.of<ExcelExportSettings>(context, listen: false),
101        Provider.of<ExportColumnsManager>(context, listen: false),
102        await RepositoryProvider.of<MedicineRepository>(context).getAll(),
103      );
104      if (!context.mounted) return;
105      final string = xslConverter.create(await _getEntries(context));
106      final data = Uint8List.fromList(utf8.encode(string));
107      if (context.mounted) await _exportData(context, data, '$filename.xsl', 'application/vnd.ms-excel', share);
108      break;
109  }
110
111  if (context.mounted) {
112    ScaffoldMessenger.of(context).showSnackBar(SnackBar(
113      content: Row(
114        children: [
115          Icon(Icons.check_circle, color: Colors.green),
116          SizedBox(width: 8.0),
117          Text(localizations!.exportSuccess),
118        ],
119      ),
120    ));
121  }
122}
123
124/// Get the records that should be exported (oldest first).
125Future<List<(DateTime, BloodPressureRecord, Note, List<MedicineIntake>, Weight?)>> _getEntries(BuildContext context) async {
126  final range = Provider.of<IntervalStoreManager>(context, listen: false).exportPage.currentRange;
127  _logger.fine('_getEntries - range=$range');
128  final bpRepo = RepositoryProvider.of<BloodPressureRepository>(context);
129  final noteRepo = RepositoryProvider.of<NoteRepository>(context);
130  final intakeRepo = RepositoryProvider.of<MedicineIntakeRepository>(context);
131  final weightRepo = RepositoryProvider.of<BodyweightRepository>(context);
132  final intervalManager = context.read<IntervalStoreManager>();
133
134  List<BloodPressureRecord> records = await bpRepo.get(range);
135  List<Note> notes = await noteRepo.get(range);
136  List<MedicineIntake> intakes = await intakeRepo.get(range);
137  List<BodyweightRecord> weights = await weightRepo.get(range);
138
139  // Apply time of day filter
140
141  final timeLimitRange = intervalManager
142      .get(IntervalStoreManagerLocation.exportPage)
143      .timeLimitRange;
144  if (timeLimitRange != null) {
145    records = records.where((r) {
146      final time = TimeOfDay.fromDateTime(r.time);
147      return time.isAfter(timeLimitRange.start) && time.isBefore(timeLimitRange.end);
148    }).toList();
149    intakes = intakes.where((i) {
150      final time = TimeOfDay.fromDateTime(i.time);
151      return time.isAfter(timeLimitRange.start) && time.isBefore(timeLimitRange.end);
152    }).toList();
153    notes = notes.where((n) {
154      final time = TimeOfDay.fromDateTime(n.time);
155      return time.isAfter(timeLimitRange.start) && time.isBefore(timeLimitRange.end);
156    }).toList();
157    weights = weights.where((w) {
158      final time = TimeOfDay.fromDateTime(w.time);
159      return time.isAfter(timeLimitRange.start) && time.isBefore(timeLimitRange.end);
160    }).toList();
161  }
162
163  _logger.finest('_getEntries - range=$range');
164
165  final entries = FullEntryList.merged(records, notes, intakes);
166  _logger.fine('_getEntries - merged ${records.length} records, ${notes.length}'
167      ' notes, and ${intakes.length} intakes to ${entries.length} entries');
168
169  final entriesWithWeight = entries
170      .map((e) => (e.time, e.recordObj, e.noteObj, e.intakes, weights.firstWhereOrNull((w) => e.time == w.time)?.weight))
171      .toList();
172  for (final e in weights.where((w) => entriesWithWeight.firstWhereOrNull((n) => n.$1 == w.time) == null)) {
173    entriesWithWeight.add((e.time, BloodPressureRecord(time: e.time), Note(time: e.time), [], e.weight));
174  }
175
176  _logger.fine('_getEntries - added ${weights.length} weights to get'
177      ' ${entries.length} entries');
178
179  entriesWithWeight.sort((a, b) => a.$1.compareTo(b.$1));
180  return entriesWithWeight;
181}
182
183/// Save to default export path or share by providing binary data.
184Future<void> _exportData(BuildContext context, Uint8List data, String fullFileName, String mimeType, bool share) async {
185  if (share) {
186    _logger.fine('_exportData - Saving file using SharePlus');
187    final result = await SharePlus.instance.share(ShareParams(
188      title: AppLocalizations.of(context)!.bloodPressure,
189      files: [XFile.fromData(data, name: fullFileName, mimeType: mimeType)]
190    ));
191    log.info('_exportData - Shared data with result: $result');
192    return;
193  }
194
195  final settings = Provider.of<ExportSettings>(context, listen: false);
196  if (settings.defaultExportDir.isEmpty || !Platform.isAndroid) {
197    _logger.fine('_exportData - Saving file using FilePicker');
198    await FilePicker.platform.saveFile(
199      type: FileType.any, // mimeType
200      fileName: fullFileName,
201      bytes: data,
202    );
203  } else {
204    _logger.fine('_exportData - Saving file using PersistentUserDirAccessAndroid');
205    const userDir = PersistentUserDirAccessAndroid();
206    await userDir.writeFile(settings.defaultExportDir, fullFileName, mimeType, data);
207  }
208}