main
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}