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}