From 75b77a41477a42b66198fb3e19b93db552784581 Mon Sep 17 00:00:00 2001 From: Efrain Bastidas Date: Wed, 25 Jan 2023 19:17:16 -0500 Subject: [PATCH] [WIP] [Presentation] Added initial backup page ui --- lib/presentation/backups/backups_page.dart | 170 ++++++++++++++++++ .../widgets/backup_details_dialog.dart | 55 ++++++ .../backups/widgets/backup_list_item.dart | 77 ++++++++ .../banner_history/banner_history_page.dart | 4 + .../settings/widgets/other_settings.dart | 42 ++--- pubspec.lock | 16 +- pubspec.yaml | 2 + 7 files changed, 341 insertions(+), 25 deletions(-) create mode 100644 lib/presentation/backups/backups_page.dart create mode 100644 lib/presentation/backups/widgets/backup_details_dialog.dart create mode 100644 lib/presentation/backups/widgets/backup_list_item.dart diff --git a/lib/presentation/backups/backups_page.dart b/lib/presentation/backups/backups_page.dart new file mode 100644 index 000000000..4f8055fca --- /dev/null +++ b/lib/presentation/backups/backups_page.dart @@ -0,0 +1,170 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shiori/application/bloc.dart'; +import 'package:shiori/domain/extensions/string_extensions.dart'; +import 'package:shiori/domain/models/models.dart'; +import 'package:shiori/generated/l10n.dart'; +import 'package:shiori/injection.dart'; +import 'package:shiori/presentation/backups/widgets/backup_list_item.dart'; +import 'package:shiori/presentation/shared/app_fab.dart'; +import 'package:shiori/presentation/shared/dialogs/confirm_dialog.dart'; +import 'package:shiori/presentation/shared/loading.dart'; +import 'package:shiori/presentation/shared/mixins/app_fab_mixin.dart'; +import 'package:shiori/presentation/shared/nothing_found_column.dart'; +import 'package:shiori/presentation/shared/styles.dart'; +import 'package:shiori/presentation/shared/utils/toast_utils.dart'; + +class BackupsPage extends StatefulWidget { + const BackupsPage({super.key}); + + @override + State createState() => _BackupsPageState(); +} + +class _BackupsPageState extends State with SingleTickerProviderStateMixin, AppFabMixin { + @override + bool get isInitiallyVisible => true; + + @override + bool get hideOnTop => false; + + @override + Widget build(BuildContext context) { + final s = S.of(context); + return BlocProvider( + create: (context) => Injection.backupRestoreBloc..add(const BackupRestoreEvent.init()), + child: Scaffold( + appBar: AppBar( + title: Text('Backups'), + actions: [ + BlocBuilder( + builder: (context, state) => state.maybeMap(loaded: (_) => false, orElse: () => true) + ? const SizedBox.shrink() + : Tooltip( + message: 'Import', + child: IconButton( + splashRadius: Styles.mediumButtonSplashRadius, + icon: const Icon(Icons.upload), + onPressed: () => FilePicker.platform + .pickFiles(dialogTitle: 'Choose a file', lockParentWindow: true) + .then((result) => _handlePickerResult(context, result)), + ), + ), + ), + ], + ), + body: SafeArea( + child: BlocConsumer( + listener: (context, state) { + state.maybeMap( + loaded: (state) { + if (state.createResult != null) { + _handleCreateResult(context, state.createResult); + } else if (state.restoreResult != null) { + _handleRestoreResult(context, state.restoreResult); + } else if (state.readResult != null) { + _handleReadResult(context, state.readResult); + } + }, + orElse: () {}, + ); + }, + builder: (context, state) => state.maybeMap( + loaded: (state) => state.backups.isEmpty + ? const NothingFoundColumn() + : ListView.builder( + itemCount: state.backups.length, + itemBuilder: (context, index) => BackupListItem(backup: state.backups[index]), + ), + orElse: () => const Loading(useScaffold: false), + ), + ), + ), + floatingActionButton: Builder( + builder: (context) => AppFab( + icon: const Icon(Icons.add), + hideFabAnimController: hideFabAnimController, + scrollController: scrollController, + mini: false, + onPressed: () => showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: s.confirm, + content: 'Would you like to create a backup of all your data and configuration ?', + ), + ).then((confirmed) { + if (confirmed == true) { + context.read().add(const BackupRestoreEvent.create()); + } + }), + ), + ), + ), + ); + } + + void _showToastMsg(String msg, bool succeed, BuildContext context) { + final toast = ToastUtils.of(context); + if (succeed) { + ToastUtils.showSucceedToast(toast, msg); + } else { + ToastUtils.showErrorToast(toast, msg, durationType: ToastDurationType.long); + } + } + + void _handleCreateResult(BuildContext context, BackupOperationResultModel? result) { + if (result == null) { + return; + } + + final s = S.of(context); + final msg = result.succeed ? 'Backup = ${result.name} was successfully created' : 'Could not create backup'; + _showToastMsg(msg, result.succeed, context); + } + + void _handleRestoreResult(BuildContext context, BackupOperationResultModel? result) { + if (result == null) { + return; + } + + final s = S.of(context); + final msg = result.succeed ? 'Backup = ${result.name} was successfully restored.\nRestarting...' : 'Could not restore file = ${result.name}'; + _showToastMsg(msg, result.succeed, context); + if (result.succeed) { + Future.delayed(const Duration(seconds: 1)).then((value) => context.read().add(const MainEvent.restart())); + } + } + + void _handleReadResult(BuildContext context, BackupOperationResultModel? result) { + if (result == null) { + return; + } + + final s = S.of(context); + if (result.succeed) { + showDialog( + context: context, + builder: (_) => ConfirmDialog(title: s.confirm, content: 'Would you like to restore backup = ${result.name} ?'), + ).then((confirmed) { + if (confirmed == true) { + context.read().add(BackupRestoreEvent.restore(result.path)); + } + }); + } else { + _showToastMsg('File = ${result.name} could not be read.\nMake sure you have selected the appropriate one.', false, context); + } + } + + void _handlePickerResult(BuildContext context, FilePickerResult? result) { + if (result == null) { + return; + } + final path = result.files.single.path; + if (path.isNullEmptyOrWhitespace) { + return; + } + + context.read().add(BackupRestoreEvent.read(path!)); + } +} diff --git a/lib/presentation/backups/widgets/backup_details_dialog.dart b/lib/presentation/backups/widgets/backup_details_dialog.dart new file mode 100644 index 000000000..da1989826 --- /dev/null +++ b/lib/presentation/backups/widgets/backup_details_dialog.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:shiori/application/backup_restore/backup_restore_bloc.dart'; +import 'package:shiori/domain/models/models.dart'; +import 'package:shiori/generated/l10n.dart'; + +class BackupDetailsDialog extends StatelessWidget { + final BackupFileItemModel backup; + + const BackupDetailsDialog({ + super.key, + required this.backup, + }); + + @override + Widget build(BuildContext context) { + final s = S.of(context); + final theme = Theme.of(context); + return AlertDialog( + title: Text(s.details), + scrollable: true, + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text(backup.filename, style: theme.textTheme.subtitle1), + Text(s.appVersion(backup.appVersion)), + Text('Date: ${DateFormat.yMd().add_Hm().format(backup.createdAt)}'), + Container( + margin: const EdgeInsets.only(top: 10), + child: Text( + 'Keep in mind that restoring a backup will replace all your existing data and configuration', + style: theme.textTheme.subtitle2!.copyWith(fontStyle: FontStyle.italic, color: theme.primaryColor), + ), + ), + ], + ), + actions: [ + OutlinedButton( + onPressed: () => Navigator.pop(context, false), + child: Text(s.cancel, style: TextStyle(color: theme.primaryColor)), + ), + ElevatedButton( + onPressed: () => context.read().add(BackupRestoreEvent.delete(backup.filePath)), + child: Text(s.delete), + ), + ElevatedButton( + onPressed: () => context.read().add(BackupRestoreEvent.restore(backup.filePath)), + child: Text(s.restore), + ), + ], + ); + } +} diff --git a/lib/presentation/backups/widgets/backup_list_item.dart b/lib/presentation/backups/widgets/backup_list_item.dart new file mode 100644 index 000000000..af440940d --- /dev/null +++ b/lib/presentation/backups/widgets/backup_list_item.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:shiori/application/bloc.dart'; +import 'package:shiori/domain/models/models.dart'; +import 'package:shiori/generated/l10n.dart'; +import 'package:shiori/presentation/backups/widgets/backup_details_dialog.dart'; +import 'package:shiori/presentation/shared/dialogs/confirm_dialog.dart'; +import 'package:shiori/presentation/shared/styles.dart'; + +class BackupListItem extends StatelessWidget { + final BackupFileItemModel backup; + + const BackupListItem({ + required this.backup, + }); + + @override + Widget build(BuildContext context) { + final s = S.of(context); + final theme = Theme.of(context); + return Card( + child: ListTile( + title: Tooltip( + message: backup.filename, + child: Text( + backup.filename, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.subtitle2, + ), + ), + subtitle: Text( + DateFormat.yMd().add_Hm().format(backup.createdAt), + style: theme.textTheme.caption, + ), + trailing: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + splashRadius: Styles.smallButtonSplashRadius, + icon: const Icon(Icons.settings_backup_restore, color: Colors.green), + visualDensity: VisualDensity.compact, + tooltip: s.restore, + onPressed: () => showDialog( + context: context, + builder: (_) => ConfirmDialog(title: s.confirm, content: 'Restore backup ${backup.filename} ?'), + ).then((confirmed) { + if (confirmed == true) { + context.read().add(BackupRestoreEvent.restore(backup.filePath)); + } + }), + ), + IconButton( + splashRadius: Styles.smallButtonSplashRadius, + icon: const Icon(Icons.delete, color: Colors.red), + visualDensity: VisualDensity.compact, + tooltip: s.delete, + onPressed: () => showDialog( + context: context, + builder: (_) => ConfirmDialog(title: s.confirm, content: 'Delete backup ${backup.filename} ?'), + ).then((confirmed) { + if (confirmed == true) { + context.read().add(BackupRestoreEvent.delete(backup.filePath)); + } + }), + ), + ], + ), + onTap: () => showDialog( + context: context, + builder: (_) => BackupDetailsDialog(backup: backup), + ), + ), + ); + } +} diff --git a/lib/presentation/banner_history/banner_history_page.dart b/lib/presentation/banner_history/banner_history_page.dart index 3c3f4a365..d32ace00a 100644 --- a/lib/presentation/banner_history/banner_history_page.dart +++ b/lib/presentation/banner_history/banner_history_page.dart @@ -16,6 +16,7 @@ import 'package:shiori/presentation/shared/extensions/i18n_extensions.dart'; import 'package:shiori/presentation/shared/item_popupmenu_filter.dart'; import 'package:shiori/presentation/shared/mixins/app_fab_mixin.dart'; import 'package:shiori/presentation/shared/nothing_found_column.dart'; +import 'package:shiori/presentation/shared/styles.dart'; const double _tabletFirstCellWidth = 150; const double _mobileFirstCellWidth = 120; @@ -147,6 +148,7 @@ class _AppBar extends StatelessWidget implements PreferredSizeWidget { actions: [ IconButton( icon: const Icon(Icons.search), + splashRadius: Styles.mediumButtonSplashRadius, onPressed: () => showSearch>( context: context, delegate: _AppBarSearchDelegate( @@ -167,6 +169,7 @@ class _AppBar extends StatelessWidget implements PreferredSizeWidget { onSelected: (val) => context.read().add(BannerHistoryEvent.typeChanged(type: val)), icon: const Icon(Icons.swap_horiz), itemText: (val, _) => s.translateBannerHistoryItemType(val), + splashRadius: Styles.mediumButtonSplashRadius, ), ItemPopupMenuFilter( tooltipText: s.sortType, @@ -175,6 +178,7 @@ class _AppBar extends StatelessWidget implements PreferredSizeWidget { onSelected: (val) => context.read().add(BannerHistoryEvent.sortTypeChanged(type: val)), icon: const Icon(Icons.sort), itemText: (val, _) => s.translateBannerHistorySortType(val), + splashRadius: Styles.mediumButtonSplashRadius, ), ], ), diff --git a/lib/presentation/settings/widgets/other_settings.dart b/lib/presentation/settings/widgets/other_settings.dart index 09f7050e0..bbdf0412a 100644 --- a/lib/presentation/settings/widgets/other_settings.dart +++ b/lib/presentation/settings/widgets/other_settings.dart @@ -6,11 +6,11 @@ import 'package:shiori/application/bloc.dart'; import 'package:shiori/application/settings/settings_bloc.dart'; import 'package:shiori/domain/enums/enums.dart'; import 'package:shiori/generated/l10n.dart'; +import 'package:shiori/presentation/backups/backups_page.dart'; import 'package:shiori/presentation/settings/widgets/settings_card.dart'; import 'package:shiori/presentation/shared/common_dropdown_button.dart'; import 'package:shiori/presentation/shared/extensions/i18n_extensions.dart'; import 'package:shiori/presentation/shared/loading.dart'; -import 'package:shiori/presentation/shared/styles.dart'; import 'package:shiori/presentation/shared/utils/enum_utils.dart'; class OtherSettings extends StatelessWidget { @@ -76,34 +76,28 @@ class OtherSettings extends StatelessWidget { onChanged: (newVal) => context.read().add(SettingsEvent.useTwentyFourHoursFormat(newValue: newVal)), ), ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - title: Padding( - padding: Styles.edgeInsetHorizontal16, - child: CommonDropdownButton( - hint: s.chooseServer, - currentValue: settingsState.serverResetTime, - values: EnumUtils.getTranslatedAndSortedEnum( - AppServerResetTimeType.values, - (val, _) => s.translateServerResetTimeType(val), - ), - onChanged: (v, context) => context.read().add(SettingsEvent.serverResetTimeChanged(newValue: v)), + title: CommonDropdownButton( + hint: s.chooseServer, + currentValue: settingsState.serverResetTime, + values: EnumUtils.getTranslatedAndSortedEnum( + AppServerResetTimeType.values, + (val, _) => s.translateServerResetTimeType(val), ), + onChanged: (v, context) => context.read().add(SettingsEvent.serverResetTimeChanged(newValue: v)), ), - subtitle: Container( - margin: const EdgeInsets.only(left: 25), - child: Transform.translate( - offset: const Offset(0, -10), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - s.serverWhereYouPlay, - style: const TextStyle(color: Colors.grey), - ), - ), + subtitle: Transform.translate( + offset: const Offset(0, -10), + child: Align( + alignment: Alignment.centerLeft, + child: Text(s.serverWhereYouPlay), ), ), ), + ListTile( + title: Text('Backup / Restore'), + subtitle: Text('Last backup: 01/07/2023 23:58:59'), + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const BackupsPage())), + ), ], ), ); diff --git a/pubspec.lock b/pubspec.lock index a8b313e9c..b85bcbd26 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -288,6 +288,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.1.4" + file_picker: + dependency: "direct main" + description: + name: file_picker + url: "https://pub.dartlang.org" + source: hosted + version: "5.2.5" fixnum: dependency: transitive description: @@ -389,6 +396,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.7" flutter_rating_bar: dependency: transitive description: @@ -554,7 +568,7 @@ packages: source: hosted version: "0.0.1+4" intl: - dependency: transitive + dependency: "direct main" description: name: intl url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index 92dde4e6e..3cff63d2f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,7 @@ dependencies: device_info_plus: ^8.0.0 devicelocale: ^0.5.5 enum_to_string: ^2.0.1 + file_picker: ^5.2.5 fl_chart: ^0.55.2 flutter: sdk: flutter @@ -50,6 +51,7 @@ dependencies: image_gallery_saver: ^1.7.1 infinite_listview: ^1.1.0 internet_connection_checker: ^0.0.1+4 + intl: ^0.17.0 json_annotation: ^4.7.0 linked_scroll_controller: ^0.2.0 logger: ^1.1.0