main
1import 'dart:io';
2
3import 'package:archive/archive_io.dart';
4import 'package:blood_pressure_app/components/input_dialoge.dart';
5import 'package:blood_pressure_app/data_util/consistent_future_builder.dart';
6import 'package:blood_pressure_app/features/settings/configure_warn_values_screen.dart';
7import 'package:blood_pressure_app/features/settings/delete_data_screen.dart';
8import 'package:blood_pressure_app/features/settings/enter_timeformat_dialoge.dart';
9import 'package:blood_pressure_app/features/settings/export_import_screen.dart';
10import 'package:blood_pressure_app/features/settings/graph_markings_screen.dart';
11import 'package:blood_pressure_app/features/settings/medicine_manager_screen.dart';
12import 'package:blood_pressure_app/features/settings/tiles/color_picker_list_tile.dart';
13import 'package:blood_pressure_app/features/settings/tiles/dropdown_list_tile.dart';
14import 'package:blood_pressure_app/features/settings/tiles/number_input_list_tile.dart';
15import 'package:blood_pressure_app/features/settings/tiles/slider_list_tile.dart';
16import 'package:blood_pressure_app/features/settings/tiles/titled_column.dart';
17import 'package:blood_pressure_app/features/settings/version_screen.dart';
18import 'package:blood_pressure_app/features/settings/warn_about_screen.dart';
19import 'package:blood_pressure_app/logging.dart';
20import 'package:blood_pressure_app/model/blood_pressure/pressure_unit.dart';
21import 'package:blood_pressure_app/model/blood_pressure/warn_values.dart';
22import 'package:blood_pressure_app/model/iso_lang_names.dart';
23import 'package:blood_pressure_app/model/storage/bluetooth_input_mode.dart';
24import 'package:blood_pressure_app/model/storage/db/config_db.dart';
25import 'package:blood_pressure_app/model/storage/db/file_settings_loader.dart';
26import 'package:blood_pressure_app/model/storage/db/settings_loader.dart';
27import 'package:blood_pressure_app/model/storage/export_columns_store.dart';
28import 'package:blood_pressure_app/model/storage/storage.dart';
29import 'package:blood_pressure_app/model/weight_unit.dart';
30import 'package:file_picker/file_picker.dart';
31import 'package:flutter/material.dart';
32import 'package:blood_pressure_app/l10n/app_localizations.dart';
33import 'package:package_info_plus/package_info_plus.dart';
34import 'package:path/path.dart';
35import 'package:provider/provider.dart';
36import 'package:url_launcher/url_launcher.dart';
37
38/// Primary settings page to manage basic settings and link to subsettings.
39class SettingsPage extends StatelessWidget {
40 /// Create a primary settings screen.
41 const SettingsPage({super.key});
42
43 @override
44 Widget build(BuildContext context) {
45 final localizations = AppLocalizations.of(context)!;
46 return Scaffold(
47 appBar: AppBar(
48 title: Text(localizations.settings),
49 backgroundColor: Theme.of(context).primaryColor,
50 ),
51 body: Consumer<Settings>(builder: (context, settings, child) => ListView(
52 children: [
53 TitledColumn(title: Text(localizations.layout), children: [
54 ListTile(
55 key: const Key('EnterTimeFormatScreen'),
56 title: Text(localizations.enterTimeFormatScreen),
57 subtitle: Text(settings.dateFormatString),
58 leading: const Icon(Icons.schedule),
59 trailing: const Icon(Icons.arrow_forward_ios),
60 onTap: () async {
61 final pickedFormat = await showTimeFormatPickerDialoge(context,
62 settings.dateFormatString,
63 settings.bottomAppBars,);
64 if (pickedFormat != null) {
65 settings.dateFormatString = pickedFormat;
66 }
67 },
68 ),
69 DropDownListTile<ThemeMode>(
70 leading: const Icon(Icons.brightness_4),
71 title: Text(localizations.theme),
72 value: settings.themeMode,
73 items: [
74 DropdownMenuItem(value: ThemeMode.system, child: Text(localizations.system)),
75 DropdownMenuItem(value: ThemeMode.dark, child: Text(localizations.dark)),
76 DropdownMenuItem(value: ThemeMode.light, child: Text(localizations.light)),
77 ],
78 onChanged: (ThemeMode? value) {
79 if (value != null) settings.themeMode = value;
80 },
81 ),
82 ColorSelectionListTile(
83 onMainColorChanged: (color) => settings.accentColor = color,
84 initialColor: settings.accentColor,
85 title: Text(localizations.accentColor),),
86 DropDownListTile<Locale?>(
87 leading: const Icon(Icons.language),
88 title: Text(localizations.language),
89 value: settings.language,
90 items: [
91 DropdownMenuItem(child: Text(localizations.system)),
92 for (final l in AppLocalizations.supportedLocales)
93 DropdownMenuItem(value: l, child: Text(getDisplayLanguage(l))),
94 ],
95 onChanged: (Locale? value) {
96 settings.language = value;
97 },
98 ),
99 SliderListTile(
100 title: Text(localizations.graphLineThickness),
101 leading: const Icon(Icons.line_weight),
102 onChanged: (double value) {
103 settings.graphLineThickness = value;
104 },
105 value: settings.graphLineThickness,
106 min: 1,
107 max: 5,
108 ),
109 SliderListTile(
110 title: Text(localizations.needlePinBarWidth),
111 subtitle: Text(localizations.needlePinBarWidthDesc),
112 leading: const Icon(Icons.line_weight),
113 onChanged: (double value) {
114 settings.needlePinBarWidth = value;
115 },
116 value: settings.needlePinBarWidth,
117 min: 1,
118 max: 20,
119 ),
120 SliderListTile(
121 title: Text(localizations.animationSpeed),
122 leading: const Icon(Icons.speed),
123 onChanged: (double value) {
124 settings.animationSpeed = value.toInt();
125 },
126 value: settings.animationSpeed.toDouble(),
127 min: 0,
128 max: 1000,
129 stepSize: 50,
130 ),
131 ColorSelectionListTile(
132 onMainColorChanged: (color) => settings.sysColor = color,
133 initialColor: settings.sysColor,
134 title: Text(localizations.sysColor),),
135 ColorSelectionListTile(
136 onMainColorChanged: (color) => settings.diaColor = color,
137 initialColor: settings.diaColor,
138 title: Text(localizations.diaColor),),
139 ColorSelectionListTile(
140 onMainColorChanged: (color) => settings.pulColor = color,
141 initialColor: settings.pulColor,
142 title: Text(localizations.pulColor),),
143 SwitchListTile(
144 value: settings.compactList,
145 onChanged: (value) {
146 settings.compactList = value;
147 },
148 secondary: const Icon(Icons.list_alt_outlined),
149 title: Text(localizations.compactList),),
150 ],),
151
152 TitledColumn(title: Text(localizations.behavior), children: [
153 ListTile(
154 onTap: () {
155 Navigator.push(context, MaterialPageRoute(builder:
156 (context) => const MedicineManagerScreen(),),);
157 },
158 leading: const Icon(Icons.medication),
159 title: Text(localizations.medications),
160 trailing: const Icon(Icons.arrow_forward_ios),
161 ),
162 DropDownListTile<BluetoothInputMode>(
163 title: Text(localizations.bluetoothInput),
164 subtitle: Text(localizations.bluetoothInputDesc),
165 leading: const Icon(Icons.bluetooth),
166 items: [
167 for (final e in BluetoothInputMode.values)
168 DropdownMenuItem(
169 value: e,
170 child: Text(e.localize(localizations)),
171 ),
172 ],
173 value: settings.bleInput,
174 onChanged: (value) => settings.bleInput = value ?? settings.bleInput,
175
176 ),
177 SwitchListTile(
178 value: settings.allowManualTimeInput,
179 onChanged: (value) {
180 settings.allowManualTimeInput = value;
181 },
182 secondary: const Icon(Icons.details),
183 title: Text(localizations.allowManualTimeInput),),
184 SwitchListTile(
185 value: settings.validateInputs,
186 title: Text(localizations.validateInputs),
187 secondary: const Icon(Icons.edit),
188 onChanged: settings.allowMissingValues ? null : (value) {
189 assert(!settings.allowMissingValues);
190 settings.validateInputs = value;
191 },),
192 SwitchListTile(
193 value: settings.allowMissingValues,
194 title: Text(localizations.allowMissingValues),
195 secondary: const Icon(Icons.report_off_outlined),
196 onChanged: (value) {
197 settings.allowMissingValues = value;
198 if (value) settings.validateInputs = false;
199 },),
200 SwitchListTile(
201 value: settings.confirmDeletion,
202 title: Text(localizations.confirmDeletion),
203 secondary: const Icon(Icons.check),
204 onChanged: (value) {
205 settings.confirmDeletion = value;
206 },),
207 ListTile(
208 leading: const Icon(Icons.warning_amber_outlined),
209 title: Text(localizations.determineWarnValues),
210 subtitle: Text(localizations.aboutWarnValuesScreenDesc),
211 trailing: const Icon(Icons.arrow_forward_ios),
212 onTap: () {
213 Navigator.push(
214 context,
215 MaterialPageRoute(builder: (context) => const ConfigureWarnValuesScreen()),
216 );
217 },
218 ),
219 ListTile(
220 title: Text(localizations.customGraphMarkings),
221 leading: const Icon(Icons.legend_toggle_outlined),
222 trailing: const Icon(Icons.arrow_forward_ios),
223 onTap: () {
224 Navigator.push(
225 context,
226 MaterialPageRoute(builder: (context) => const GraphMarkingsScreen()),
227 );
228 },
229 ),
230 SwitchListTile(
231 title: Text(localizations.drawRegressionLines),
232 secondary: const Icon(Icons.trending_down_outlined),
233 subtitle: Text(localizations.drawRegressionLinesDesc),
234 value: settings.drawRegressionLines,
235 onChanged: (value) {
236 settings.drawRegressionLines = value;
237 },
238 ),
239 SwitchListTile(
240 title: Text(localizations.startWithAddMeasurementPage),
241 subtitle: Text(localizations.startWithAddMeasurementPageDescription),
242 secondary: const Icon(Icons.electric_bolt_outlined),
243 value: settings.startWithAddMeasurementPage,
244 onChanged: (value) {
245 settings.startWithAddMeasurementPage = value;
246 },
247 ),
248 SwitchListTile(
249 title: Text(localizations.bottomAppBars),
250 secondary: const Icon(Icons.vertical_align_bottom),
251 value: settings.bottomAppBars,
252 onChanged: (value) {
253 settings.bottomAppBars = value;
254 },
255 ),
256 DropDownListTile<PressureUnit?>(
257 leading: const Icon(Icons.language),
258 title: Text(localizations.preferredPressureUnit),
259 value: settings.preferredPressureUnit,
260 items: [
261 for (final u in PressureUnit.values)
262 DropdownMenuItem(
263 value: u,
264 child: Text(u.name),
265 ),
266 ],
267 onChanged: (PressureUnit? value) {
268 if (value != null) settings.preferredPressureUnit = value;
269 },
270 ),
271 SwitchListTile(
272 value: settings.weightInput,
273 title: Text(localizations.activateWeightFeatures),
274 secondary: const Icon(Icons.scale),
275 onChanged: (value) {
276 settings.weightInput = value;
277 },),
278 if (settings.weightInput)
279 DropDownListTile<WeightUnit?>(
280 leading: const Icon(Icons.language),
281 title: Text(localizations.preferredWeightUnit),
282 value: settings.weightUnit,
283 items: [
284 for (final u in WeightUnit.values)
285 DropdownMenuItem(
286 value: u,
287 child: Text(u.name),
288 ),
289 ],
290 onChanged: (WeightUnit? value) {
291 if (value != null) settings.weightUnit = value;
292 },
293 ),
294 SwitchListTile(
295 value: settings.trustBLETime,
296 title: Text(localizations.trustBLETime),
297 secondary: const Icon(Icons.lock_clock_outlined),
298 onChanged: (value) {
299 settings.trustBLETime = value;
300 },
301 ),
302 ],),
303 TitledColumn(
304 title: Text(localizations.data),
305 children: [
306 ListTile(
307 title: Text(localizations.exportImport),
308 leading: const Icon(Icons.download),
309 trailing: const Icon(Icons.arrow_forward_ios),
310 onTap: () {
311 Navigator.push(
312 context,
313 MaterialPageRoute(builder: (context) => const ExportImportScreen()),
314 );
315 },
316 ),
317 ListTile(
318 title: Text(localizations.exportSettings),
319 leading: const Icon(Icons.tune),
320 onTap: () async {
321 final messenger = ScaffoldMessenger.of(context);
322 final loader = await FileSettingsLoader.load();
323 final archive = loader.createArchive();
324 if (archive == null) {
325 messenger.showSnackBar(SnackBar(content: Text(localizations.errCantCreateArchive)));
326 return;
327 }
328 final compressedArchive = ZipEncoder().encodeBytes(archive);
329 await FilePicker.platform.saveFile(
330 type: FileType.any, // application/zip
331 fileName: 'bloodPressureSettings.zip',
332 bytes: compressedArchive,
333 );
334 },
335 ),
336 ListTile(
337 title: Text(localizations.importSettings),
338 subtitle: Text(localizations.requiresAppRestart),
339 leading: const Icon(Icons.settings_backup_restore),
340 onTap: () async {
341 final messenger = ScaffoldMessenger.of(context);
342 final result = await FilePicker.platform.pickFiles();
343 if (result == null) {
344 messenger.showSnackBar(SnackBar(content: Text(localizations.errNoFileOpened)));
345 return;
346 }
347 final path = result.files.single.path;
348 if (path == null) {
349 messenger.showSnackBar(SnackBar(content: Text(localizations.errCantReadFile)));
350 return;
351 }
352
353 late SettingsLoader loader;
354 if (path.endsWith('db')) {
355 final configDB = await ConfigDB.open(dbPath: path, isFullPath: true);
356 if(configDB == null) return; // too old (doesn't contain settings yet)
357 loader = ConfigDao(configDB);
358 } else if (path.endsWith('zip')) {
359 try {
360 final decoded = ZipDecoder().decodeStream(InputFileStream(result.files.single.path!));
361 final dir = join(Directory.systemTemp.path, 'settingsBackup');
362 await extractArchiveToDisk(decoded, dir);
363 loader = await FileSettingsLoader.load(dir);
364 } on FormatException catch (e, stack) {
365 messenger.showSnackBar(SnackBar(content: Text(localizations.invalidZip)));
366 log.severe('invalid zip', e, stack);
367 return;
368 }
369 } else {
370 messenger.showSnackBar(SnackBar(content: Text(localizations.errNotImportable)));
371 return;
372 }
373 settings.copyFrom(await loader.loadSettings());
374 context.read<ExportSettings>().copyFrom(await loader.loadExportSettings());
375 context.read<CsvExportSettings>().copyFrom(await loader.loadCsvExportSettings());
376 context.read<PdfExportSettings>().copyFrom(await loader.loadPdfExportSettings());
377 context.read<IntervalStoreManager>().copyFrom(await loader.loadIntervalStorageManager());
378 context.read<ExportColumnsManager>().copyFrom(await loader.loadExportColumnsManager());
379 messenger.showSnackBar(SnackBar(content: Text(localizations.success(localizations.importSettings))));
380 },
381 ),
382 ListTile(
383 title: Text(localizations.delete),
384 leading: const Icon(Icons.delete),
385 trailing: const Icon(Icons.arrow_forward_ios),
386 onTap: () {
387 Navigator.push(
388 context,
389 MaterialPageRoute(builder: (context) => const DeleteDataScreen()),
390 );
391 },
392 ),
393 ],
394 ),
395 TitledColumn(title: Text(localizations.aboutWarnValuesScreen), children: [
396 ListTile(
397 title: Text(localizations.version),
398 leading: const Icon(Icons.info_outline),
399 trailing: const Icon(Icons.arrow_forward_ios),
400 subtitle: ConsistentFutureBuilder<PackageInfo>(
401 future: PackageInfo.fromPlatform(),
402 cacheFuture: true,
403 onData: (context, info) => Text(info.version),
404 ),
405 onTap: () {
406 Navigator.push(
407 context,
408 MaterialPageRoute(builder: (context) => const VersionScreen()),
409 );
410 },
411 ),
412 ListTile(
413 title: Text(localizations.sourceCode),
414 leading: const Icon(Icons.merge),
415 trailing: const Icon(Icons.open_in_new),
416 onTap: () async {
417 final scaffoldMessenger = ScaffoldMessenger.of(context);
418 final url = Uri.parse('https://github.com/derdilla/blood-pressure-monitor-fl');
419 if (await canLaunchUrl(url)) {
420 await launchUrl(url, mode: LaunchMode.externalApplication);
421 } else {
422 scaffoldMessenger.showSnackBar(SnackBar(
423 content: Text(localizations.errCantOpenURL(url.toString())),),);
424 }
425 },
426 ),
427 ListTile(
428 title: Text(localizations.licenses),
429 leading: const Icon(Icons.policy_outlined),
430 trailing: const Icon(Icons.arrow_forward_ios),
431 onTap: () {
432 showLicensePage(context: context);
433 },
434 ),
435 ],),
436 ],
437 ),),
438 );
439 }
440}