From b01c61a47bcd90831b476d8cc429b30bb48475ee Mon Sep 17 00:00:00 2001 From: Inex Code Date: Mon, 14 Aug 2023 07:10:15 +0300 Subject: [PATCH] feat(backups): Add snapshot restore modal --- lib/logic/cubit/backups/backups_cubit.dart | 16 +- lib/ui/components/cards/outlined_card.dart | 7 +- lib/ui/pages/backups/backup_details.dart | 62 ++++-- lib/ui/pages/backups/backups_list.dart | 35 +++- lib/ui/pages/backups/snapshot_modal.dart | 232 +++++++++++++++++++++ 5 files changed, 320 insertions(+), 32 deletions(-) create mode 100644 lib/ui/pages/backups/snapshot_modal.dart diff --git a/lib/logic/cubit/backups/backups_cubit.dart b/lib/logic/cubit/backups/backups_cubit.dart index 1d416033..938b606a 100644 --- a/lib/logic/cubit/backups/backups_cubit.dart +++ b/lib/logic/cubit/backups/backups_cubit.dart @@ -41,7 +41,6 @@ class BackupsCubit extends ServerInstallationDependendCubit { refreshing: false, ), ); - print(state); } } @@ -113,9 +112,7 @@ class BackupsCubit extends ServerInstallationDependendCubit { final BackblazeBucket? bucket = getIt().backblazeBucket; if (bucket == null) { emit(state.copyWith(isInitialized: false)); - print('bucket is null'); } else { - print('bucket is not null'); final GenericResult result = await api.initializeRepository( InitializeRepositoryInput( provider: BackupsProviderType.backblaze, @@ -125,7 +122,6 @@ class BackupsCubit extends ServerInstallationDependendCubit { password: bucket.applicationKey, ), ); - print('result is $result'); if (result.success == false) { getIt() .showSnackBar(result.message ?? 'Unknown error'); @@ -214,7 +210,6 @@ class BackupsCubit extends ServerInstallationDependendCubit { await updateBackups(); } - // TODO: inex Future forgetSnapshot(final String snapshotId) async { final result = await api.forgetSnapshot(snapshotId); if (!result.success) { @@ -226,6 +221,17 @@ class BackupsCubit extends ServerInstallationDependendCubit { getIt() .showSnackBar('backup.forget_snapshot_error'.tr()); } + + // Optimistic update + final backups = state.backups; + final index = + backups.indexWhere((final snapshot) => snapshot.id == snapshotId); + if (index != -1) { + backups.removeAt(index); + emit(state.copyWith(backups: backups)); + } + + await updateBackups(); } @override diff --git a/lib/ui/components/cards/outlined_card.dart b/lib/ui/components/cards/outlined_card.dart index 91f13b44..d60fa9f0 100644 --- a/lib/ui/components/cards/outlined_card.dart +++ b/lib/ui/components/cards/outlined_card.dart @@ -3,17 +3,22 @@ import 'package:flutter/material.dart'; class OutlinedCard extends StatelessWidget { const OutlinedCard({ required this.child, + this.borderColor, + this.borderWidth, super.key, }); final Widget child; + final Color? borderColor; + final double? borderWidth; @override Widget build(final BuildContext context) => Card( elevation: 0.0, shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(12)), side: BorderSide( - color: Theme.of(context).colorScheme.outline, + color: borderColor ?? Theme.of(context).colorScheme.outline, + width: borderWidth ?? 1.0, ), ), clipBehavior: Clip.antiAlias, diff --git a/lib/ui/pages/backups/backup_details.dart b/lib/ui/pages/backups/backup_details.dart index aa2ba335..1d08dfac 100644 --- a/lib/ui/pages/backups/backup_details.dart +++ b/lib/ui/pages/backups/backup_details.dart @@ -18,6 +18,7 @@ import 'package:selfprivacy/ui/helpers/modals.dart'; import 'package:selfprivacy/ui/pages/backups/change_period_modal.dart'; import 'package:selfprivacy/ui/pages/backups/copy_encryption_key_modal.dart'; import 'package:selfprivacy/ui/pages/backups/create_backups_modal.dart'; +import 'package:selfprivacy/ui/pages/backups/snapshot_modal.dart'; import 'package:selfprivacy/ui/router/router.dart'; import 'package:selfprivacy/utils/extensions/duration.dart'; @@ -69,14 +70,15 @@ class BackupDetailsPage extends StatelessWidget { child: CircularProgressIndicator(), ), ), - if (!preventActions) BrandButton.rised( - onPressed: preventActions - ? null - : () async { - await context.read().initializeBackups(); - }, - text: 'backup.initialize'.tr(), - ), + if (!preventActions) + BrandButton.rised( + onPressed: preventActions + ? null + : () async { + await context.read().initializeBackups(); + }, + text: 'backup.initialize'.tr(), + ), ], ); } @@ -183,7 +185,9 @@ class BackupDetailsPage extends StatelessWidget { 'backup.backups_encryption_key_subtitle'.tr(), ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), + const Divider(), + const SizedBox(height: 8), if (backupJobs.isNotEmpty) Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -209,7 +213,6 @@ class BackupDetailsPage extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Divider(), ListTile( title: Text( 'backup.latest_snapshots'.tr(), @@ -241,16 +244,39 @@ class BackupDetailsPage extends StatelessWidget { onTap: preventActions ? null : () { - showPopUpAlert( - alertTitle: 'backup.restoring'.tr(), - description: 'backup.restore_alert'.tr( - args: [backup.time.toString()], + showModalBottomSheet( + useRootNavigator: true, + context: context, + isScrollControlled: true, + builder: (final BuildContext context) => + DraggableScrollableSheet( + expand: false, + maxChildSize: 0.9, + minChildSize: 0.5, + initialChildSize: 0.7, + builder: ( + final context, + final scrollController, + ) => + SnapshotModal( + snapshot: backup, + scrollController: scrollController, + ), ), - actionButtonTitle: 'modals.yes'.tr(), + ); + }, + onLongPress: preventActions + ? null + : () { + showPopUpAlert( + alertTitle: 'backup.forget_snapshot'.tr(), + description: + 'backup.forget_snapshot_alert'.tr(), + actionButtonTitle: + 'backup.forget_snapshot'.tr(), actionButtonOnPressed: () => { - context.read().restoreBackup( - backup.id, // TODO: inex - BackupRestoreStrategy.unknown, + context.read().forgetSnapshot( + backup.id, ) }, ); diff --git a/lib/ui/pages/backups/backups_list.dart b/lib/ui/pages/backups/backups_list.dart index af652a9b..4af870ef 100644 --- a/lib/ui/pages/backups/backups_list.dart +++ b/lib/ui/pages/backups/backups_list.dart @@ -9,6 +9,7 @@ import 'package:selfprivacy/logic/models/backup.dart'; import 'package:selfprivacy/logic/models/service.dart'; import 'package:selfprivacy/ui/helpers/modals.dart'; import 'package:selfprivacy/ui/layouts/brand_hero_screen.dart'; +import 'package:selfprivacy/ui/pages/backups/snapshot_modal.dart'; @RoutePage() class BackupsListPage extends StatelessWidget { @@ -47,16 +48,34 @@ class BackupsListPage extends StatelessWidget { onTap: preventActions ? null : () { - showPopUpAlert( - alertTitle: 'backup.restoring'.tr(), - description: 'backup.restore_alert'.tr( - args: [backup.time.toString()], + showModalBottomSheet( + useRootNavigator: true, + context: context, + isScrollControlled: true, + builder: (final BuildContext context) => + DraggableScrollableSheet( + expand: false, + maxChildSize: 0.9, + minChildSize: 0.5, + initialChildSize: 0.7, + builder: (final context, final scrollController) => + SnapshotModal( + snapshot: backup, + scrollController: scrollController, + ), ), - actionButtonTitle: 'modals.yes'.tr(), + ); + }, + onLongPress: preventActions + ? null + : () { + showPopUpAlert( + alertTitle: 'backup.forget_snapshot'.tr(), + description: 'backup.forget_snapshot_alert'.tr(), + actionButtonTitle: 'backup.forget_snapshot'.tr(), actionButtonOnPressed: () => { - context.read().restoreBackup( - backup.id, // TODO: inex - BackupRestoreStrategy.unknown, + context.read().forgetSnapshot( + backup.id, ) }, ); diff --git a/lib/ui/pages/backups/snapshot_modal.dart b/lib/ui/pages/backups/snapshot_modal.dart new file mode 100644 index 00000000..147fe72a --- /dev/null +++ b/lib/ui/pages/backups/snapshot_modal.dart @@ -0,0 +1,232 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:selfprivacy/config/get_it_config.dart'; +import 'package:selfprivacy/logic/cubit/backups/backups_cubit.dart'; +import 'package:selfprivacy/logic/cubit/server_jobs/server_jobs_cubit.dart'; +import 'package:selfprivacy/logic/cubit/services/services_cubit.dart'; +import 'package:selfprivacy/logic/models/backup.dart'; +import 'package:selfprivacy/logic/models/json/server_job.dart'; +import 'package:selfprivacy/logic/models/service.dart'; +import 'package:selfprivacy/ui/components/buttons/brand_button.dart'; +import 'package:selfprivacy/ui/components/cards/outlined_card.dart'; +import 'package:selfprivacy/ui/components/info_box/info_box.dart'; + +class SnapshotModal extends StatefulWidget { + const SnapshotModal({ + required this.snapshot, + required this.scrollController, + super.key, + }); + + final Backup snapshot; + final ScrollController scrollController; + + @override + State createState() => _SnapshotModalState(); +} + +class _SnapshotModalState extends State { + BackupRestoreStrategy selectedStrategy = + BackupRestoreStrategy.downloadVerifyOverwrite; + + @override + Widget build(final BuildContext context) { + final List busyServices = context + .watch() + .state + .backupJobList + .where( + (final ServerJob job) => + job.status == JobStatusEnum.running || + job.status == JobStatusEnum.created, + ) + .map((final ServerJob job) => job.typeId.split('.')[1]) + .toList(); + + final bool isServiceBusy = busyServices.contains(widget.snapshot.serviceId); + + final Service? service = context + .read() + .state + .getServiceById(widget.snapshot.serviceId); + + return ListView( + controller: widget.scrollController, + padding: const EdgeInsets.all(16), + children: [ + const SizedBox(height: 16), + Text( + 'backup.snapshot_modal_heading'.tr(), + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ListTile( + leading: service != null + ? SvgPicture.string( + service.svgIcon, + height: 24, + width: 24, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.onSurface, + BlendMode.srcIn, + ), + ) + : const Icon( + Icons.question_mark_outlined, + ), + title: Text( + 'backup.snapshot_service_title'.tr(), + ), + subtitle: Text( + service?.displayName ?? widget.snapshot.fallbackServiceName, + ), + ), + ListTile( + leading: Icon( + Icons.access_time_outlined, + color: Theme.of(context).colorScheme.onSurface, + ), + title: Text( + 'backup.snapshot_creation_time_title'.tr(), + ), + subtitle: Text( + '${MaterialLocalizations.of(context).formatShortDate(widget.snapshot.time)} ${TimeOfDay.fromDateTime(widget.snapshot.time).format(context)}', + ), + ), + ListTile( + leading: Icon( + Icons.numbers_outlined, + color: Theme.of(context).colorScheme.onSurface, + ), + title: Text( + 'backup.snapshot_id_title'.tr(), + ), + subtitle: Text( + widget.snapshot.id, + ), + ), + if (service != null) + Column( + children: [ + const SizedBox(height: 8), + Text( + 'backup.snapshot_modal_select_strategy'.tr(), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + _BackupStrategySelectionCard( + isSelected: selectedStrategy == + BackupRestoreStrategy.downloadVerifyOverwrite, + onTap: () { + setState(() { + selectedStrategy = + BackupRestoreStrategy.downloadVerifyOverwrite; + }); + }, + title: + 'backup.snapshot_modal_download_verify_option_title'.tr(), + subtitle: + 'backup.snapshot_modal_download_verify_option_description' + .tr(), + ), + const SizedBox(height: 8), + _BackupStrategySelectionCard( + isSelected: selectedStrategy == BackupRestoreStrategy.inplace, + onTap: () { + setState(() { + selectedStrategy = BackupRestoreStrategy.inplace; + }); + }, + title: 'backup.snapshot_modal_inplace_option_title'.tr(), + subtitle: + 'backup.snapshot_modal_inplace_option_description'.tr(), + ), + const SizedBox(height: 8), + // Restore backup button + BrandButton.filled( + onPressed: isServiceBusy + ? null + : () { + context.read().restoreBackup( + widget.snapshot.id, + selectedStrategy, + ); + Navigator.of(context).pop(); + getIt() + .showSnackBar('backup.restore_started'.tr()); + }, + text: 'backup.restore'.tr(), + ), + ], + ) + else + Padding( + padding: const EdgeInsets.all(16.0), + child: InfoBox( + isWarning: true, + text: 'backup.snapshot_modal_service_not_found'.tr(), + ), + ) + ], + ); + } +} + +class _BackupStrategySelectionCard extends StatelessWidget { + const _BackupStrategySelectionCard({ + required this.isSelected, + required this.title, + required this.subtitle, + required this.onTap, + }); + + final bool isSelected; + final String title; + final String subtitle; + final void Function() onTap; + + @override + Widget build(final BuildContext context) => OutlinedCard( + borderColor: isSelected ? Theme.of(context).colorScheme.primary : null, + borderWidth: isSelected ? 3 : 1, + child: InkResponse( + highlightShape: BoxShape.rectangle, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + if (isSelected) + Icon( + Icons.radio_button_on_outlined, + color: Theme.of(context).colorScheme.primary, + ) + else + Icon( + Icons.radio_button_off_outlined, + color: Theme.of(context).colorScheme.outline, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyLarge, + ), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ], + ), + ), + ), + ); +}