diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b9029..6ece916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Other changes: +- Add backup reminder, see settings for more options ## [0.7.2] - 2024-09-22 diff --git a/app/lib/core/backupInterval.dart b/app/lib/core/backupInterval.dart new file mode 100644 index 0000000..73de5f7 --- /dev/null +++ b/app/lib/core/backupInterval.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + + +/// Enum with all available backup intervals +enum BackupInterval { + /// never + never, + /// weekly + weekly, + /// bi-weekly + biweekly, + /// monthly + monthly, + /// quarterly + quarterly, +} + +/// extend interpolation strength +extension BackupIntervalExtension on BackupInterval { + /// get the length [days] + int get inDays => { + BackupInterval.never: -1, + BackupInterval.weekly: 7, + BackupInterval.biweekly: 14, + BackupInterval.monthly: 30, + BackupInterval.quarterly: 90, + }[this]!; + + /// get international name + String nameLong (BuildContext context) => { + BackupInterval.never: AppLocalizations.of(context)!.never, + BackupInterval.weekly: AppLocalizations.of(context)!.weekly, + BackupInterval.biweekly: AppLocalizations.of(context)!.biweekly, + BackupInterval.monthly: AppLocalizations.of(context)!.monthly, + BackupInterval.quarterly: AppLocalizations.of(context)!.quarterly, + }[this]!; + + /// get string expression + String get name => toString().split('.').last; +} + +/// convert string to interpolation strength +extension BackupIntervalParsing on String { + /// convert string to interpolation strength + BackupInterval? toBackupInterval() { + for (final BackupInterval interval in BackupInterval.values) { + if (this == interval.name) { + return interval; + } + } + return null; + } +} diff --git a/app/lib/core/preferences.dart b/app/lib/core/preferences.dart index 6c57e8c..3552ebd 100644 --- a/app/lib/core/preferences.dart +++ b/app/lib/core/preferences.dart @@ -1,4 +1,5 @@ import 'package:shared_preferences/shared_preferences.dart'; +import 'package:trale/core/backupInterval.dart'; import 'package:trale/core/interpolation.dart'; import 'package:trale/core/language.dart'; @@ -70,6 +71,14 @@ class Preferences { /// default zoomLevel final ZoomLevel defaultZoomLevel = ZoomLevel.all; + /// default backup interval + final BackupInterval defaultBackupInterval = BackupInterval.monthly; + + /// latest backup date + final DateTime defaultLatestBackupDate = DateTime.fromMillisecondsSinceEpoch( + 0, + ); + /// getter and setter for all preferences /// set user name set userName(String name) => prefs.setString('userName', name); @@ -149,6 +158,26 @@ class Preferences { 'interpolStrength', strength.name, ); + /// get backup frequency + BackupInterval get backupInterval => + prefs.getString('backupInterval')!.toBackupInterval()!; + + /// set backup frequency + set backupInterval(BackupInterval interval) => + prefs.setString( + 'backupInterval', interval.name, + ); + + /// get latest backup date + DateTime get latestBackupDate => + DateTime.parse(prefs.getString('latestBackupDate')!); + + /// set latest backup date + set latestBackupDate(DateTime date) => + prefs.setString( + 'latestBackupDate', date.toString(), + ); + /// get zoom level ZoomLevel get zoomLevel => prefs.getInt('zoomLevel')!.toZoomLevel()!; @@ -194,6 +223,12 @@ class Preferences { if (override || !prefs.containsKey('zoomLevel')) { zoomLevel = defaultZoomLevel; } + if (override || !prefs.containsKey('backupInterval')) { + backupInterval = defaultBackupInterval; + } + if (override || !prefs.containsKey('latestBackupDate')) { + latestBackupDate = defaultLatestBackupDate; + } } /// reset all settings diff --git a/app/lib/core/theme.dart b/app/lib/core/theme.dart index 8b8c3b2..4cc2528 100644 --- a/app/lib/core/theme.dart +++ b/app/lib/core/theme.dart @@ -102,9 +102,13 @@ class TraleTheme { /// Get border radius double get borderRadius => 16; + /// get transition durations final TransitionDuration transitionDuration = TransitionDuration(100, 200, 500); + /// get duration of snackbar + final Duration snackbarDuration = Duration(seconds: 5); + /// get background gradient LinearGradient get bgGradient => LinearGradient( begin: Alignment.topCenter, diff --git a/app/lib/core/traleNotifier.dart b/app/lib/core/traleNotifier.dart index 57b7c80..261ba0b 100644 --- a/app/lib/core/traleNotifier.dart +++ b/app/lib/core/traleNotifier.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:intl/date_time_patterns.dart'; import 'package:intl/intl.dart'; +import 'package:trale/core/backupInterval.dart'; import 'package:trale/core/interpolation.dart'; import 'package:trale/core/language.dart'; import 'package:trale/core/measurementDatabase.dart'; @@ -59,7 +60,36 @@ class TraleNotifier with ChangeNotifier { prefs.zoomLevel = newLevel; notifyListeners(); } -} + } + + /// get backup frequency + BackupInterval get backupInterval => prefs.backupInterval; + /// setter backup frequency + set backupInterval(BackupInterval newInterval) { + if (backupInterval != newInterval) { + prefs.backupInterval = newInterval; + notifyListeners(); + } + } + + /// get latest backup date + DateTime? get latestBackupDate { + if (prefs.defaultLatestBackupDate.sameDay(prefs.latestBackupDate)) { + return null; + } + return prefs.latestBackupDate; + } + + /// set latest backup date + set latestBackupDate(DateTime? newDate) { + if (latestBackupDate != newDate) { + if (newDate == null) { + prefs.latestBackupDate = prefs.defaultLatestBackupDate; + } else { + prefs.latestBackupDate = newDate; + } + } + } /// getter Language get language => prefs.language; diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 9ec1c59..7a2acae 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -4,6 +4,42 @@ "@achievements": { "description": "achievements" }, + "never": "never", + "@never": { + "description": "Frequency of backup interval" + }, + "weekly": "weekly", + "@weekly": { + "description": "Frequency of backup interval" + }, + "biweekly": "biweekly", + "@biweekly": { + "description": "Frequency of backup interval" + }, + "monthly": "monthly", + "@monthly": { + "description": "Frequency of backup interval" + }, + "quarterly": "quarterly", + "@quarterly": { + "description": "Frequency of backup interval" + }, + "backupReminder": "Please back up regularly", + "@backupReminder": { + "description": "Please remember to back up your data regularly." + }, + "backupReminderButton": "back up now", + "@backupReminderButton": { + "description": "Back up now" + }, + "backupSuccess": "Backup successfully exported", + "@backupSuccess": { + "description": "Back successfully exported" + }, + "backupInterval": "Backups", + "@backupInterval": { + "description": "Frequency of backup interval" + }, "stats": "Statistics", "@stats": { "description": "Header for statistics section" diff --git a/app/lib/pages/overview.dart b/app/lib/pages/overview.dart index 7b56bc0..764583f 100644 --- a/app/lib/pages/overview.dart +++ b/app/lib/pages/overview.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; +import 'package:trale/core/backupInterval.dart'; import 'package:trale/core/icons.dart'; import 'package:trale/core/measurement.dart'; import 'package:trale/core/measurementDatabase.dart'; import 'package:trale/core/theme.dart'; +import 'package:trale/core/traleNotifier.dart'; import 'package:trale/widget/animate_in_effect.dart'; +import 'package:trale/widget/backupDialog.dart'; import 'package:trale/widget/emptyChart.dart'; import 'package:trale/widget/fade_in_effect.dart'; import 'package:trale/widget/linechart.dart'; @@ -30,6 +34,29 @@ class _OverviewScreen extends State { WidgetsBinding.instance.addPostFrameCallback((_) { if (loadedFirst) { loadedFirst = false; + TraleNotifier traleNotifier = Provider.of( + context, listen: false, + ); + if ( + traleNotifier.latestBackupDate != null && + traleNotifier.backupInterval != BackupInterval.never && + traleNotifier.latestBackupDate!.difference( + DateTime.now() + ).inDays > traleNotifier.backupInterval.inDays + ) { + final ScaffoldMessengerState sm = ScaffoldMessenger.of(context); + sm.showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.backupReminder), + behavior: SnackBarBehavior.fixed, + duration: TraleTheme.of(context)!.snackbarDuration, + action: SnackBarAction( + label: AppLocalizations.of(context)!.backupReminderButton, + onPressed: () => backupDialog(context), + ), + ), + ); + } setState(() {}); } }); diff --git a/app/lib/pages/settings.dart b/app/lib/pages/settings.dart index 83b315f..0cfa7f0 100644 --- a/app/lib/pages/settings.dart +++ b/app/lib/pages/settings.dart @@ -4,10 +4,8 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:intl/intl.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; -import 'package:share_plus/share_plus.dart'; +import 'package:trale/core/backupInterval.dart'; import 'package:trale/core/icons.dart'; import 'package:trale/core/interpolation.dart'; @@ -18,6 +16,7 @@ import 'package:trale/core/stringExtension.dart'; import 'package:trale/core/theme.dart'; import 'package:trale/core/traleNotifier.dart'; import 'package:trale/core/units.dart'; +import 'package:trale/widget/backupDialog.dart'; import 'package:trale/widget/coloredContainer.dart'; import 'package:trale/widget/customSliverAppBar.dart'; @@ -29,7 +28,6 @@ class ExportListTile extends StatelessWidget { @override Widget build(BuildContext context) { final ScaffoldMessengerState sm = ScaffoldMessenger.of(context); - const Duration duration = Duration(seconds: 5); return ListTile( dense: true, title: AutoSizeText( @@ -46,95 +44,7 @@ class ExportListTile extends StatelessWidget { ), trailing: IconButton( icon: const Icon(CustomIcons.export_icon), - onPressed: () async { - final bool accepted = await showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: Text( - AppLocalizations.of(context)!.export, - style: Theme.of(context).textTheme.titleLarge, - ), - content: Text( - AppLocalizations.of(context)!.exportDialog, - style: Theme.of(context).textTheme.bodyLarge, - ), - actions: [ - TextButton( - style: ButtonStyle( - foregroundColor: WidgetStateProperty.all( - Theme.of(context).colorScheme.onSurface, - ), - ), - onPressed: () => Navigator.pop(context, false), - child: Container( - padding: EdgeInsets.symmetric( - vertical: TraleTheme.of(context)!.padding / 2, - horizontal: TraleTheme.of(context)!.padding, - ), - child: Text(AppLocalizations.of(context)!.abort) - ), - ), - TextButton( - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all( - Theme.of(context).colorScheme.primary, - ), - foregroundColor: WidgetStateProperty.all( - Theme.of(context).colorScheme.onPrimary, - ), - ), - onPressed: () => Navigator.pop(context, true), - child: Container( - padding: EdgeInsets.symmetric( - vertical: TraleTheme.of(context)!.padding / 2, - horizontal: TraleTheme.of(context)!.padding, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(CustomIcons.done), - SizedBox(width: TraleTheme.of(context)!.padding), - Text(AppLocalizations.of(context)!.yes), - ], - ) - ) - ), - ], - ), - ) ?? false; - if (accepted) { - final Directory localPath = await getTemporaryDirectory(); - final DateFormat formatter = DateFormat('yyyy-MM-dd'); - final String filename = - 'trale_${formatter.format(DateTime.now())}.txt'; - final String path = '${localPath.path}/$filename'; - final File file = File(path); - final MeasurementDatabase db = MeasurementDatabase(); - file.writeAsString(db.exportString, mode: FileMode.write); - final ShareResult sharingResult = await Share.shareXFiles( - [XFile(path)], - text: 'trale backup', - subject: 'trale backup', - ); - if (sharingResult.status == ShareResultStatus.success) { - sm.showSnackBar( - const SnackBar( - content: Text('File successfully exported'), - behavior: SnackBarBehavior.floating, - duration: duration, - ), - ); - } - await file.delete(); - //sm.showSnackBar( - // const SnackBar( - // content: Text('Missing write permission.'), - // behavior: SnackBarBehavior.floating, - // duration: duration, - // ), - //); - } - }, + onPressed: () => backupDialog(context), ), ); } @@ -150,7 +60,6 @@ class ImportListTile extends StatelessWidget { Widget build(BuildContext context) { final ScaffoldMessengerState sm = ScaffoldMessenger.of(context); final MeasurementDatabase db = MeasurementDatabase(); - const Duration duration = Duration(seconds: 5); return ListTile( dense: true, title: AutoSizeText( @@ -253,7 +162,7 @@ class ImportListTile extends StatelessWidget { SnackBar( content: Text('$measurementCounts measurements added'), behavior: SnackBarBehavior.floating, - duration: duration, + duration: TraleTheme.of(context)!.snackbarDuration, ), ); } else { @@ -484,6 +393,50 @@ class UnitsListTile extends StatelessWidget { } } +/// ListTile for changing units settings +class BackupIntervalListTile extends StatelessWidget { + /// constructor + const BackupIntervalListTile({super.key}); + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: EdgeInsets.symmetric( + horizontal: 2 * TraleTheme.of(context)!.padding, + vertical: 0.5 * TraleTheme.of(context)!.padding, + ), + title: AutoSizeText( + AppLocalizations.of(context)!.backupInterval, + style: Theme.of(context).textTheme.bodyLarge, + maxLines: 1, + ), + trailing: DropdownMenu( + initialSelection: Provider.of(context).backupInterval, + label: AutoSizeText( + AppLocalizations.of(context)!.backupInterval, + style: Theme.of(context).textTheme.bodyLarge, + maxLines: 1, + ), + dropdownMenuEntries: >[ + for (final BackupInterval interval in BackupInterval.values) + DropdownMenuEntry( + value: interval, + label: interval.name, + ) + ], + onSelected: (BackupInterval? newInterval) async { + if (newInterval != null) { + Provider.of( + context, listen: false + ).backupInterval = newInterval; + } + }, + ), + ); + } +} + + /// ListTile for changing dark mode settings class DarkModeListTile extends StatelessWidget { @@ -751,6 +704,7 @@ class _Settings extends State { const LanguageListTile(), const UnitsListTile(), const InterpolationListTile(), + const BackupIntervalListTile(), Divider( height: 2 * TraleTheme.of(context)!.padding, ), diff --git a/app/lib/widget/backupDialog.dart b/app/lib/widget/backupDialog.dart new file mode 100644 index 0000000..a0d1feb --- /dev/null +++ b/app/lib/widget/backupDialog.dart @@ -0,0 +1,105 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:intl/intl.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import 'package:trale/core/icons.dart'; +import 'package:trale/core/measurementDatabase.dart'; +import 'package:trale/core/theme.dart'; +import 'package:trale/core/traleNotifier.dart'; + + +/// Backup dialog +Future backupDialog(BuildContext context) async { + final ScaffoldMessengerState sm = ScaffoldMessenger.of(context); + final TraleNotifier traleNotifier = Provider.of( + context, listen: false, + ); + + final bool accepted = await showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: Text( + AppLocalizations.of(context)!.export, + style: Theme.of(context).textTheme.titleLarge, + ), + content: Text( + AppLocalizations.of(context)!.exportDialog, + style: Theme.of(context).textTheme.bodyLarge, + ), + actions: [ + TextButton( + style: ButtonStyle( + foregroundColor: WidgetStateProperty.all( + Theme.of(context).colorScheme.onSurface, + ), + ), + onPressed: () => Navigator.pop(context, false), + child: Container( + padding: EdgeInsets.symmetric( + vertical: TraleTheme.of(context)!.padding / 2, + horizontal: TraleTheme.of(context)!.padding, + ), + child: Text(AppLocalizations.of(context)!.abort) + ), + ), + TextButton( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + Theme.of(context).colorScheme.primary, + ), + foregroundColor: WidgetStateProperty.all( + Theme.of(context).colorScheme.onPrimary, + ), + ), + onPressed: () => Navigator.pop(context, true), + child: Container( + padding: EdgeInsets.symmetric( + vertical: TraleTheme.of(context)!.padding / 2, + horizontal: TraleTheme.of(context)!.padding, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(CustomIcons.done), + SizedBox(width: TraleTheme.of(context)!.padding), + Text(AppLocalizations.of(context)!.yes), + ], + ) + ) + ), + ], + ), + ) ?? false; + if (accepted) { + final Directory localPath = await getTemporaryDirectory(); + final DateFormat formatter = DateFormat('yyyy-MM-dd'); + final String filename = + 'trale_${formatter.format(DateTime.now())}.txt'; + final String path = '${localPath.path}/$filename'; + final File file = File(path); + final MeasurementDatabase db = MeasurementDatabase(); + file.writeAsString(db.exportString, mode: FileMode.write); + final ShareResult sharingResult = await Share.shareXFiles( + [XFile(path)], + text: 'trale backup', + subject: 'trale backup', + ); + if (sharingResult.status == ShareResultStatus.success) { + sm.showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.backupSuccess), + behavior: SnackBarBehavior.floating, + duration: TraleTheme.of(context)!.snackbarDuration, + ), + ); +// set latest backup date + traleNotifier.latestBackupDate = DateTime.now(); + } + await file.delete(); + } +} \ No newline at end of file