diff --git a/.idea/libraries/Flutter_Plugins.xml b/.idea/libraries/Flutter_Plugins.xml index 5371787..da28e2f 100644 --- a/.idea/libraries/Flutter_Plugins.xml +++ b/.idea/libraries/Flutter_Plugins.xml @@ -13,22 +13,22 @@ - + + + + - + + + - - + - - - - - + diff --git a/app/assets/locales/app_en.arb b/app/assets/locales/app_en.arb index 28f1489..81d8549 100644 --- a/app/assets/locales/app_en.arb +++ b/app/assets/locales/app_en.arb @@ -14,11 +14,22 @@ "common_yesterday": "Yesterday", "common_tomorrow": "Tomorrow", + "common_upload":"Upload", + "common_download":"Download", + "common_delete": "Delete", + "common_delete_from_google_drive": "Delete from Google Drive", + "common_delete_from_device": "Delete from Device", + "common_cancel": "Cancel", + + "no_internet_connection_error": "No internet connection! Please check your network and try again.", "something_went_wrong_error": "Something went wrong! Please try again later.", "user_google_sign_in_account_not_found_error": "You haven't signed in with Google account yet. Please sign in with Google account and try again.", "back_up_folder_not_found_error": "Back up folder not found!", - "unable_to_open_attachment_error": "Unable to open attachment!", + + "unable_to_load_media_error": "Unable to load media!", + + "unable_to_load_media_message": "Oops! It looks like we're having trouble loading the media right now. Please try again later.", "on_board_description": "Effortlessly move, share, and organize your photos and videos in a breeze. Access all your clouds in one friendly place. Your moments, your way, simplified for you! 🚀", @@ -41,6 +52,25 @@ "hint_google_sign_in_message": "Sign in with Google and effortlessly link your Google Drive to your Cloud Gallery. Enjoy quick access to all your awesome content in one spot", "hint_google_auto_backup_message": "Enable Auto Back Up to Google Drive and never lose your precious memories. Your photos and videos will be automatically backed up to your Google Drive", - "hint_action_auto_backup": "Enable Auto Back Up" + "hint_action_auto_backup": "Enable Auto Back Up", + + "delete_media_from_device_confirmation_message": "Are you sure you want to delete this media? It will be permanently deleted from your device.", + "delete_media_from_google_drive_confirmation_message": "Are you sure you want to delete this media? It will be permanently deleted from your Google Drive.", + + "waiting_in_queue_text": "Waiting in Queue...", + "waiting_in_download_queue_message": "Your video download is in queue. We appreciate your patience!", + + "transfer_screen_title": "Transfer", + + "empty_upload_title":"No Files Being Uploads", + "empty_upload_message": "No uploads are happening right now. If you have files to upload, go ahead and start uploading.", + + "empty_download_title":"No Files Being Downloads", + "empty_download_message": "No downloads are happening right now. If you have files to download, go ahead and start downloading.", + + "download_in_progress_text": "Download in progress", + + "download_require_text": "Download required", + "download_require_message": "To watch the video, simply download it first. Tap the download button to begin downloading the video." } \ No newline at end of file diff --git a/app/lib/components/action_sheet.dart b/app/lib/components/action_sheet.dart index 872f817..4a0f359 100644 --- a/app/lib/components/action_sheet.dart +++ b/app/lib/components/action_sheet.dart @@ -22,7 +22,7 @@ class AppSheetAction extends StatelessWidget { return OnTapScale( onTap: onPressed, child: SizedBox( - height: 45, + height: 50, width: double.infinity, child: Row( children: [ diff --git a/app/lib/components/app_dialog.dart b/app/lib/components/app_dialog.dart new file mode 100644 index 0000000..c85af95 --- /dev/null +++ b/app/lib/components/app_dialog.dart @@ -0,0 +1,83 @@ +import 'dart:io'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/text/app_text_style.dart'; + +class AppAlertAction { + final String title; + final bool isDestructiveAction; + final VoidCallback onPressed; + + const AppAlertAction({ + required this.title, + this.isDestructiveAction = false, + required this.onPressed, + }); +} + +Future showAppAlertDialog({ + required BuildContext context, + required String title, + required String message, + required List actions, +}) { + return showAdaptiveDialog( + context: context, + builder: (context) { + if (Platform.isIOS || Platform.isMacOS) { + return CupertinoAlertDialog( + title: Text(title, + style: AppTextStyles.body + .copyWith(color: context.colorScheme.textPrimary)), + content: Text( + message, + style: AppTextStyles.caption + .copyWith(color: context.colorScheme.textSecondary), + ), + actions: [ + for (final action in actions) + CupertinoDialogAction( + onPressed: action.onPressed, + child: Text( + action.title, + style: AppTextStyles.button.copyWith( + color: action.isDestructiveAction + ? context.colorScheme.alert + : context.colorScheme.textPrimary), + ), + ), + ], + ); + } + + return AlertDialog( + backgroundColor: context.colorScheme.containerNormalOnSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + ), + title: Text(title, + style: AppTextStyles.body + .copyWith(color: context.colorScheme.textPrimary)), + content: Text( + message, + style: AppTextStyles.caption + .copyWith(color: context.colorScheme.textSecondary), + ), + actions: [ + for (final action in actions) + TextButton( + onPressed: action.onPressed, + child: Text( + action.title, + style: AppTextStyles.button.copyWith( + color: action.isDestructiveAction + ? context.colorScheme.alert + : context.colorScheme.textPrimary), + ), + ), + ], + ); + }, + ); +} diff --git a/app/lib/components/app_page.dart b/app/lib/components/app_page.dart index e91d72f..0c0c643 100644 --- a/app/lib/components/app_page.dart +++ b/app/lib/components/app_page.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:style/extensions/context_extensions.dart'; class AppPage extends StatelessWidget { final String? title; @@ -145,11 +146,16 @@ class AdaptiveAppBar extends StatelessWidget { children: actions!, ), ) - : AppBar( - leading: leading, - actions: actions, - automaticallyImplyLeading: automaticallyImplyLeading, - title: Text(text), - ); + : Column( + children: [ + AppBar( + backgroundColor: context.colorScheme.barColor, + leading: leading, + actions: actions, + automaticallyImplyLeading: automaticallyImplyLeading, + title: Text(text), + ), + ], + ); } } diff --git a/app/lib/components/error_view.dart b/app/lib/components/error_view.dart new file mode 100644 index 0000000..b6e3609 --- /dev/null +++ b/app/lib/components/error_view.dart @@ -0,0 +1,70 @@ +import 'package:flutter/cupertino.dart'; +import 'package:style/buttons/primary_button.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/text/app_text_style.dart'; + +class ErrorViewAction { + final String title; + final VoidCallback onPressed; + + const ErrorViewAction({required this.title, required this.onPressed}); +} + +class ErrorView extends StatelessWidget { + final Widget? icon; + final String title; + final String message; + final Color? foregroundColor; + final ErrorViewAction? action; + + const ErrorView({ + super.key, + this.icon, + required this.title, + this.foregroundColor, + this.message = '', + this.action, + }); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(30), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(width: double.infinity), + icon ?? + Icon( + CupertinoIcons.exclamationmark_circle, + color: context.colorScheme.containerHighOnSurface, + size: 100, + ), + const SizedBox(height: 40), + Text(title, + style: AppTextStyles.subtitle1.copyWith( + color: foregroundColor?? context.colorScheme.textPrimary, + )), + const SizedBox(height: 20), + Text( + message, + style: AppTextStyles.subtitle2.copyWith( + color: foregroundColor ??context.colorScheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + if (action != null) ...[ + const SizedBox(height: 20), + PrimaryButton( + onPressed: action!.onPressed, + child: Text(action!.title, style: AppTextStyles.button,), + ), + ], + ], + ), + ), + ); + } +} diff --git a/app/lib/domain/extensions/media_list_extension.dart b/app/lib/domain/extensions/media_list_extension.dart new file mode 100644 index 0000000..49900ce --- /dev/null +++ b/app/lib/domain/extensions/media_list_extension.dart @@ -0,0 +1,56 @@ +import 'package:data/extensions/iterable_extension.dart'; +import 'package:data/models/app_process/app_process.dart'; +import 'package:data/models/media/media.dart'; +import 'package:data/models/media/media_extension.dart'; + +extension MediaListExtension on List { + void removeGoogleDriveRefFromMedias({List? removeFromIds}) { + for (int index = 0; index < length; index++) { + if (this[index].isGoogleDriveStored && + (removeFromIds?.contains(this[index].id) ?? true)) { + removeAt(index); + } else if (this[index].isCommonStored && + (removeFromIds?.contains(this[index].id) ?? true)) { + this[index] = this[index].copyWith( + sources: this[index].sources.toList() + ..remove(AppMediaSource.googleDrive), + thumbnailLink: null, + driveMediaRefId: null, + ); + } + } + } + + void addGoogleDriveRefInMedias( + {required List process, List? processIds}) { + processIds ??= process.map((e) => e.id).toList(); + updateWhere( + where: (media) => processIds?.contains(media.id) ?? false, + update: (media) { + final res = process + .where((element) => element.id == media.id) + .first + .response as AppMedia?; + if (res == null) return media; + return media.mergeGoogleDriveMedia(res); + }, + ); + } + + void replaceMediaRefInMedias( + {required List process, List? processIds}) { + processIds ??= process.map((e) => e.id).toList(); + updateWhere( + where: (media) => processIds?.contains(media.id) ?? false, + update: (media) { + final res = process + .where((element) => element.id == media.id) + .first + .response as AppMedia?; + + if (res == null) return media; + return res; + }, + ); + } +} diff --git a/app/lib/domain/formatter/byte_formatter.dart b/app/lib/domain/formatter/byte_formatter.dart new file mode 100644 index 0000000..4b73b51 --- /dev/null +++ b/app/lib/domain/formatter/byte_formatter.dart @@ -0,0 +1,11 @@ +import 'dart:math'; + +extension BytesFormatter on int { + String get formatBytes { + const List suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + if (this == 0) return '0 ${suffixes[0]}'; + final i = (this == 0) ? 0 : (log(this) / log(1024)).floor(); + final formattedValue = (this / pow(1024, i)).toStringAsFixed(2); + return '${formattedValue.endsWith('.00') ? formattedValue.substring(0, formattedValue.length - 3) : formattedValue} ${suffixes[i]}'; + } +} diff --git a/app/lib/ui/flow/accounts/accounts_screen.dart b/app/lib/ui/flow/accounts/accounts_screen.dart index 3fdf06c..4b14557 100644 --- a/app/lib/ui/flow/accounts/accounts_screen.dart +++ b/app/lib/ui/flow/accounts/accounts_screen.dart @@ -13,6 +13,7 @@ import 'package:style/text/app_text_style.dart'; import 'package:style/theme/colors.dart'; import 'package:style/buttons/buttons_list.dart'; import 'package:style/buttons/switch.dart'; +import '../../../components/snack_bar.dart'; import 'components/account_tab.dart'; class AccountsScreen extends ConsumerStatefulWidget { @@ -32,11 +33,20 @@ class _AccountsScreenState extends ConsumerState { runPostFrame(() => notifier.init()); } + void _errorObserver() { + ref.listen(accountsStateNotifierProvider.select((value) => value.error), + (previous, next) { + if (next != null) { + showErrorSnackBar(context: context, error: next); + } + }); + } + @override Widget build(BuildContext context) { + _errorObserver(); final googleAccount = ref.watch( accountsStateNotifierProvider.select((value) => value.googleAccount)); - return AppPage( title: context.l10n.common_accounts, bodyBuilder: (context) { diff --git a/app/lib/ui/flow/home/components/app_media_item.dart b/app/lib/ui/flow/home/components/app_media_item.dart index c7e3b03..12efc36 100644 --- a/app/lib/ui/flow/home/components/app_media_item.dart +++ b/app/lib/ui/flow/home/components/app_media_item.dart @@ -2,9 +2,10 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:cloud_gallery/domain/formatter/duration_formatter.dart'; +import 'package:data/models/app_process/app_process.dart'; import 'package:data/models/media/media.dart'; +import 'package:data/models/media/media_extension.dart'; import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_svg/svg.dart'; import 'package:style/extensions/context_extensions.dart'; import 'package:style/indicators/circular_progress_indicator.dart'; @@ -17,7 +18,7 @@ class AppMediaItem extends StatefulWidget { final void Function()? onTap; final void Function()? onLongTap; final bool isSelected; - final UploadStatus? status; + final AppProcess? process; const AppMediaItem({ super.key, @@ -25,7 +26,7 @@ class AppMediaItem extends StatefulWidget { this.onTap, this.onLongTap, this.isSelected = false, - this.status, + this.process, }); @override @@ -39,15 +40,11 @@ class _AppMediaItemState extends State @override void initState() { if (widget.media.sources.contains(AppMediaSource.local)) { - _loadImage(); + thumbnailByte = widget.media.loadThumbnail(); } super.initState(); } - _loadImage() async { - thumbnailByte = widget.media.thumbnailDataWithSize(const Size(300, 300)); - } - @override Widget build(BuildContext context) { super.build(context); @@ -120,7 +117,7 @@ class _AppMediaItemState extends State return Hero( tag: widget.media, child: CachedNetworkImage( - imageUrl: widget.media.thumbnailLink!, + imageUrl: widget.media.thumbnailLink ?? '', width: constraints.maxWidth, height: constraints.maxHeight, fit: BoxFit.cover, @@ -171,14 +168,29 @@ class _AppMediaItemState extends State ], ), ), - if (widget.status == UploadStatus.uploading) + if (widget.process?.status.isProcessing ?? false) _BackgroundContainer( - child: AppCircularProgressIndicator( - size: 16, - color: context.colorScheme.surfaceInverse, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppCircularProgressIndicator( + size: 16, + value: widget.process?.progress?.percentageInPoint, + color: context.colorScheme.surfaceInverse, + ), + if (widget.process?.progress != null) ...[ + const SizedBox(width: 4), + Text( + '${widget.process?.progress?.percentage.toStringAsFixed(0)}%', + style: AppTextStyles.caption.copyWith( + color: context.colorScheme.surfaceInverse, + ), + ), + ] + ], ), ), - if (widget.status == UploadStatus.waiting) + if (widget.process?.status.isWaiting ?? false) _BackgroundContainer( child: Icon( CupertinoIcons.time, diff --git a/app/lib/ui/flow/home/components/hint_view.dart b/app/lib/ui/flow/home/components/hint_view.dart new file mode 100644 index 0000000..45b45c7 --- /dev/null +++ b/app/lib/ui/flow/home/components/hint_view.dart @@ -0,0 +1,88 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:style/buttons/action_button.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/text/app_text_style.dart'; + +class HintView extends StatelessWidget { + final String title; + final String hint; + final String actionTitle; + final VoidCallback? onActionTap; + final VoidCallback onClose; + + const HintView( + {super.key, + required this.hint, + required this.onClose, + this.actionTitle = '', + this.onActionTap, + required this.title}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.colorScheme.containerNormalOnSurface, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 16, left: 16), + child: Text( + title, + style: AppTextStyles.subtitle2.copyWith( + color: context.colorScheme.textPrimary, + ), + ), + ), + ), + ActionButton( + backgroundColor: context.colorScheme.containerNormal, + size: 28, + onPressed: onClose, + icon: Icon( + CupertinoIcons.xmark, + color: context.colorScheme.textSecondary, + size: 18, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(bottom: 16, left: 16, right: 16), + child: Text( + hint, + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textSecondary, + ), + ), + ), + if (onActionTap != null) + FilledButton( + onPressed: onActionTap, + style: FilledButton.styleFrom( + backgroundColor: context.colorScheme.containerNormal, + foregroundColor: context.colorScheme.textPrimary, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(double.maxFinite, 40), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + actionTitle, + style: AppTextStyles.button, + ), + ) + ], + ), + ); + } +} diff --git a/app/lib/ui/flow/home/components/hints.dart b/app/lib/ui/flow/home/components/hints.dart index 677338f..8970c6f 100644 --- a/app/lib/ui/flow/home/components/hints.dart +++ b/app/lib/ui/flow/home/components/hints.dart @@ -3,8 +3,7 @@ import 'package:cloud_gallery/ui/flow/home/home_screen_view_model.dart'; import 'package:data/storage/app_preferences.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:style/extensions/context_extensions.dart'; -import 'package:style/text/app_text_style.dart'; +import 'hint_view.dart'; class HomeScreenHints extends ConsumerWidget { const HomeScreenHints({super.key}); @@ -62,87 +61,3 @@ class HomeScreenHints extends ConsumerWidget { } } -class HintView extends StatelessWidget { - final String title; - final String hint; - final String actionTitle; - final VoidCallback? onActionTap; - final VoidCallback onClose; - - const HintView( - {super.key, - required this.hint, - required this.onClose, - this.actionTitle = '', - this.onActionTap, - required this.title}); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: context.colorScheme.containerNormalOnSurface, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 16, left: 16), - child: Text( - title, - style: AppTextStyles.subtitle2.copyWith( - color: context.colorScheme.textPrimary, - ), - ), - ), - ), - IconButton( - style: IconButton.styleFrom( - backgroundColor: context.colorScheme.containerNormal, - minimumSize: const Size(28, 28), - ), - onPressed: onClose, - icon: Icon( - Icons.close_rounded, - color: context.colorScheme.textSecondary, - size: 18, - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.only(bottom: 16, left: 16, right: 16), - child: Text( - hint, - style: AppTextStyles.body2.copyWith( - color: context.colorScheme.textSecondary, - ), - ), - ), - if (onActionTap != null) - FilledButton( - onPressed: onActionTap, - style: FilledButton.styleFrom( - backgroundColor: context.colorScheme.containerNormal, - foregroundColor: context.colorScheme.textPrimary, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(double.maxFinite, 40), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - actionTitle, - style: AppTextStyles.button, - ), - ) - ], - ), - ); - } -} diff --git a/app/lib/ui/flow/home/components/multi_selection_done_button.dart b/app/lib/ui/flow/home/components/multi_selection_done_button.dart index 90aab74..f806095 100644 --- a/app/lib/ui/flow/home/components/multi_selection_done_button.dart +++ b/app/lib/ui/flow/home/components/multi_selection_done_button.dart @@ -1,5 +1,7 @@ +import 'package:cloud_gallery/components/app_dialog.dart'; import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; import 'package:cloud_gallery/ui/flow/home/home_screen_view_model.dart'; +import 'package:data/models/media/media.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -16,6 +18,15 @@ class MultiSelectionDoneButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final notifier = ref.read(homeViewStateNotifier.notifier); + final selectedMedias = ref + .watch(homeViewStateNotifier.select((state) => state.selectedMedias)); + + final bool showDeleteFromDriveButton = selectedMedias + .any((element) => element.sources.contains(AppMediaSource.googleDrive)); + final bool showDeleteFromDeviceButton = selectedMedias + .any((element) => element.sources.contains(AppMediaSource.local)); + final bool showUploadToDriveButton = selectedMedias.any( + (element) => !element.sources.contains(AppMediaSource.googleDrive)); return FloatingActionButton( elevation: 3, backgroundColor: context.colorScheme.primary, @@ -25,18 +36,77 @@ class MultiSelectionDoneButton extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - AppSheetAction( - icon: SvgPicture.asset( - Assets.images.icons.googlePhotos, - height: 24, - width: 24, + if (showUploadToDriveButton) + AppSheetAction( + icon: SvgPicture.asset( + Assets.images.icons.googlePhotos, + height: 24, + width: 24, + ), + title: context.l10n.back_up_on_google_drive_text, + onPressed: () { + notifier.backUpMediaOnGoogleDrive(); + context.pop(); + }, + ), + if (showDeleteFromDeviceButton) + AppSheetAction( + icon: const Icon(CupertinoIcons.delete), + title: context.l10n.common_delete_from_device, + onPressed: () { + showAppAlertDialog( + context: context, + title: context.l10n.common_delete_from_device, + message: context + .l10n.delete_media_from_device_confirmation_message, + actions: [ + AppAlertAction( + title: context.l10n.common_cancel, + onPressed: () { + context.pop(); + }, + ), + AppAlertAction( + isDestructiveAction: true, + title: context.l10n.common_delete, + onPressed: () { + notifier.deleteMediasFromLocal(); + context.pop(); + }, + ), + ], + ); + }, + ), + if (showDeleteFromDriveButton) + AppSheetAction( + icon: const Icon(CupertinoIcons.delete), + title: context.l10n.common_delete_from_google_drive, + onPressed: () { + showAppAlertDialog( + context: context, + title: context.l10n.common_delete_from_google_drive, + message: context.l10n + .delete_media_from_google_drive_confirmation_message, + actions: [ + AppAlertAction( + title: context.l10n.common_cancel, + onPressed: () { + context.pop(); + }, + ), + AppAlertAction( + isDestructiveAction: true, + title: context.l10n.common_delete, + onPressed: () { + notifier.deleteMediasFromGoogleDrive(); + context.pop(); + }, + ), + ], + ); + }, ), - title: context.l10n.back_up_on_google_drive_text, - onPressed: () { - notifier.uploadMediaOnGoogleDrive(); - context.pop(); - }, - ), ], ), ); diff --git a/app/lib/ui/flow/home/components/no_local_medias_access_screen.dart b/app/lib/ui/flow/home/components/no_local_medias_access_screen.dart index 10b9ebe..4f7fd8b 100644 --- a/app/lib/ui/flow/home/components/no_local_medias_access_screen.dart +++ b/app/lib/ui/flow/home/components/no_local_medias_access_screen.dart @@ -1,11 +1,10 @@ +import 'package:cloud_gallery/components/error_view.dart'; import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; import 'package:cloud_gallery/ui/flow/home/home_screen_view_model.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:style/extensions/context_extensions.dart'; -import 'package:style/text/app_text_style.dart'; -import 'package:style/buttons/primary_button.dart'; class NoLocalMediasAccessScreen extends ConsumerWidget { const NoLocalMediasAccessScreen({super.key}); @@ -13,41 +12,20 @@ class NoLocalMediasAccessScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final notifier = ref.read(homeViewStateNotifier.notifier); - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(30), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - CupertinoIcons.photo, - color: context.colorScheme.containerHighOnSurface, - size: 100, - ), - const SizedBox(height: 20), - Text(context.l10n.cant_find_media_title, - style: AppTextStyles.subtitle2.copyWith( - color: context.colorScheme.textPrimary, - )), - const SizedBox(height: 20), - Text( - context.l10n.ask_for_media_permission_message, - style: AppTextStyles.body2.copyWith( - color: context.colorScheme.textSecondary, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 20), - PrimaryButton( - onPressed: () async { - await openAppSettings(); - await notifier.loadLocalMedia(); - }, - text: context.l10n.load_local_media_button_text, - ), - ], - ), + return ErrorView( + title: context.l10n.cant_find_media_title, + icon: Icon( + CupertinoIcons.photo, + color: context.colorScheme.containerHighOnSurface, + size: 100, + ), + message: context.l10n.ask_for_media_permission_message, + action: ErrorViewAction( + onPressed: () async { + await openAppSettings(); + await notifier.loadLocalMedia(); + }, + title: context.l10n.load_local_media_button_text, ), ); } diff --git a/app/lib/ui/flow/home/home_screen.dart b/app/lib/ui/flow/home/home_screen.dart index 7752fef..fdf9d6b 100644 --- a/app/lib/ui/flow/home/home_screen.dart +++ b/app/lib/ui/flow/home/home_screen.dart @@ -1,13 +1,12 @@ import 'dart:io'; - import 'package:cloud_gallery/components/app_page.dart'; import 'package:cloud_gallery/domain/extensions/widget_extensions.dart'; import 'package:cloud_gallery/domain/formatter/date_formatter.dart'; -import 'package:cloud_gallery/ui/flow/media_preview/media_preview.dart'; import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; import 'package:cloud_gallery/ui/flow/home/components/no_local_medias_access_screen.dart'; import 'package:cloud_gallery/ui/flow/home/home_screen_view_model.dart'; import 'package:collection/collection.dart'; +import 'package:data/models/app_process/app_process.dart'; import 'package:data/models/media/media.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -59,10 +58,35 @@ class _HomeScreenState extends ConsumerState { Widget build(BuildContext context) { _errorObserver(); return AppPage( - //barBackgroundColor: context.colorScheme.surface, titleWidget: _titleWidget(context: context), actions: [ + Consumer( + builder: (context, ref, child) { + final showTransferButton = ref.watch(homeViewStateNotifier.select( + (value) => value.showTransfer)); + return Visibility( + visible: showTransferButton, + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ActionButton( + size: 36, + backgroundColor: context.colorScheme.containerNormal, + onPressed: () { + AppRouter.mediaTransfer.push(context); + }, + icon: Icon( + CupertinoIcons.arrow_up_arrow_down, + color: context.colorScheme.textSecondary, + size: 18, + ), + ), + ), + ); + } + ), ActionButton( + size: 36, + backgroundColor: context.colorScheme.containerNormal, onPressed: () { AppRouter.accounts.push(context); }, @@ -72,6 +96,7 @@ class _HomeScreenState extends ConsumerState { size: 18, ), ), + if (!Platform.isIOS && !Platform.isMacOS) const SizedBox(width: 16), ], body: _body(context: context), ); @@ -81,14 +106,14 @@ class _HomeScreenState extends ConsumerState { //View State final ({ Map> medias, - List uploadingMedias, + List mediaProcesses, List selectedMedias, bool isLoading, bool hasLocalMediaAccess, String? lastLocalMediaId }) state = ref.watch(homeViewStateNotifier.select((value) => ( medias: value.medias, - uploadingMedias: value.uploadingMedias, + mediaProcesses: value.mediaProcesses, selectedMedias: value.selectedMedias, isLoading: value.loading, hasLocalMediaAccess: value.hasLocalMediaAccess, @@ -107,7 +132,7 @@ class _HomeScreenState extends ConsumerState { _buildMediaList( context: context, medias: state.medias, - uploadingMedias: state.uploadingMedias, + mediaProcesses: state.mediaProcesses, selectedMedias: state.selectedMedias, lastLocalMediaId: state.lastLocalMediaId, ), @@ -123,7 +148,7 @@ class _HomeScreenState extends ConsumerState { Widget _buildMediaList( {required BuildContext context, required Map> medias, - required List uploadingMedias, + required List mediaProcesses, required String? lastLocalMediaId, required List selectedMedias}) { return Scrollbar( @@ -179,20 +204,20 @@ class _HomeScreenState extends ConsumerState { if (selectedMedias.isNotEmpty) { notifier.toggleMediaSelection(media); } else { - AppMediaView.showPreview( - context: context, - media: media, - ); + AppRouter.preview( + medias: medias.values + .expand((element) => element) + .toList(), + startFrom: media.id) + .push(context); } }, onLongTap: () { notifier.toggleMediaSelection(media); }, isSelected: selectedMedias.contains(media), - status: uploadingMedias - .firstWhereOrNull( - (element) => element.mediaId == media.id) - ?.status, + process: mediaProcesses.firstWhereOrNull( + (process) => process.id == media.id), media: media, ); }, @@ -208,8 +233,7 @@ class _HomeScreenState extends ConsumerState { Widget _titleWidget({required BuildContext context}) { return Row( children: [ - if(Platform.isIOS) - const SizedBox(width: 10), + if (Platform.isIOS) const SizedBox(width: 10), Image.asset( Assets.images.appIcon, width: 28, diff --git a/app/lib/ui/flow/home/home_screen_view_model.dart b/app/lib/ui/flow/home/home_screen_view_model.dart index 85d0222..2994fb4 100644 --- a/app/lib/ui/flow/home/home_screen_view_model.dart +++ b/app/lib/ui/flow/home/home_screen_view_model.dart @@ -1,9 +1,10 @@ import 'dart:async'; import 'package:cloud_gallery/domain/extensions/map_extensions.dart'; -import 'package:cloud_gallery/domain/formatter/date_formatter.dart'; -import 'package:collection/collection.dart'; -import 'package:data/errors/app_error.dart'; +import 'package:cloud_gallery/domain/extensions/media_list_extension.dart'; +import 'package:data/models/app_process/app_process.dart'; import 'package:data/models/media/media.dart'; +import 'package:data/models/media/media_extension.dart'; +import 'package:data/repositories/google_drive_process_repo.dart'; import 'package:data/services/auth_service.dart'; import 'package:data/services/google_drive_service.dart'; import 'package:data/services/local_media_service.dart'; @@ -11,6 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:google_sign_in/google_sign_in.dart'; import 'package:style/extensions/list_extensions.dart'; +import 'home_view_model_helper_mixin.dart'; part 'home_screen_view_model.freezed.dart'; @@ -21,13 +23,17 @@ final homeViewStateNotifier = ref.read(localMediaServiceProvider), ref.read(googleDriveServiceProvider), ref.read(authServiceProvider), + ref.read(googleDriveProcessRepoProvider), ); }); -class HomeViewStateNotifier extends StateNotifier { - final GoogleDriveService _googleDriveService; +class HomeViewStateNotifier extends StateNotifier + with HomeViewModelHelperMixin { final AuthService _authService; + final GoogleDriveService _googleDriveService; + final GoogleDriveProcessRepo _googleDriveProcessRepo; final LocalMediaService _localMediaService; + StreamSubscription? _googleAccountSubscription; List _uploadedMedia = []; @@ -36,22 +42,77 @@ class HomeViewStateNotifier extends StateNotifier { bool _isLocalMediaLoading = false; bool _isMaxLocalMediaLoaded = false; - HomeViewStateNotifier( - this._localMediaService, this._googleDriveService, this._authService) + HomeViewStateNotifier(this._localMediaService, this._googleDriveService, + this._authService, this._googleDriveProcessRepo) : super(const HomeViewState()) { + _listenUserGoogleAccount(); + _googleDriveProcessRepo.setBackUpFolderId(_backUpFolderId); + _googleDriveProcessRepo.addListener(_listenGoogleDriveProcess); + _loadInitialMedia(); + } + + void _listenUserGoogleAccount() { _googleAccountSubscription = _authService.onGoogleAccountChange.listen((event) async { state = state.copyWith(googleAccount: event); - await loadGoogleDriveMedia(); - if (event == null) { + _googleDriveProcessRepo.clearAllQueue(); + if (event != null) { + _backUpFolderId = await _googleDriveService.getBackupFolderId(); + _googleDriveProcessRepo.setBackUpFolderId(_backUpFolderId); + await loadGoogleDriveMedia(); + } else { + _backUpFolderId = null; _uploadedMedia.clear(); state = state.copyWith( - medias: _sortMedias( - medias: _removeGoogleDriveRefFromMedias(state.medias)), + medias: removeGoogleDriveRefFromMediaMap(medias: state.medias), ); } }); - _loadInitialMedia(); + } + + void _listenGoogleDriveProcess() { + final successUploads = _googleDriveProcessRepo.uploadQueue + .where((element) => element.status.isSuccess); + + final successDeletes = _googleDriveProcessRepo.deleteQueue + .where((element) => element.status.isSuccess) + .map((e) => e.id); + + final successDownloads = _googleDriveProcessRepo.downloadQueue + .where((element) => element.status.isSuccess); + + if (successUploads.isNotEmpty) { + state = state.copyWith( + medias: addGoogleDriveRefInMediaMap( + medias: state.medias, + process: successUploads.toList(), + )); + } + + if (successDeletes.isNotEmpty) { + state = state.copyWith( + medias: removeGoogleDriveRefFromMediaMap( + medias: state.medias, + removeFromIds: successDeletes.toList(), + )); + } + + if (successDownloads.isNotEmpty) { + state = state.copyWith( + medias: replaceMediaRefInMediaMap( + medias: state.medias, + process: successDownloads.toList(), + )); + } + + state = state.copyWith( + mediaProcesses: [ + ..._googleDriveProcessRepo.uploadQueue, + ..._googleDriveProcessRepo.deleteQueue, + ..._googleDriveProcessRepo.downloadQueue, + ], + showTransfer: _googleDriveProcessRepo.uploadQueue.isNotEmpty || + _googleDriveProcessRepo.downloadQueue.isNotEmpty); } void _loadInitialMedia() async { @@ -100,19 +161,28 @@ class HomeViewStateNotifier extends StateNotifier { _isMaxLocalMediaLoaded = true; } - final mergedMedia = _mergeCommonMedia( + final mergedMedia = mergeCommonMedia( localMedias: localMedia, googleDriveMedias: _uploadedMedia, ); + List googleDriveMedia = []; + + if (!append) { + googleDriveMedia = state.medias.values + .expand((element) => element.where((element) => + element.sources.contains(AppMediaSource.googleDrive) && + element.sources.length == 1)) + .toList(); + } state = state.copyWith( - medias: _sortMedias( + medias: sortMedias( medias: append ? [ ...state.medias.values.expand((element) => element).toList(), ...mergedMedia ] - : mergedMedia, + : [...mergedMedia, ...googleDriveMedia], ), loading: false, lastLocalMediaId: mergedMedia.length > 10 @@ -138,7 +208,7 @@ class HomeViewStateNotifier extends StateNotifier { ); // Separate media by its local existence - List googleDriveMedia = []; + List googleDriveMedia = []; List uploadedMedia = []; for (var media in driveMedias) { if (await media.isExist) { @@ -151,9 +221,11 @@ class HomeViewStateNotifier extends StateNotifier { //override google drive media if exist. state = state.copyWith( - medias: _sortMedias(medias: [ - ..._mergeCommonMedia( - localMedias: _removeGoogleDriveRefFromMedias(state.medias), + medias: sortMedias(medias: [ + ...mergeCommonMedia( + localMedias: + state.medias.values.expand((element) => element).toList() + ..removeGoogleDriveRefFromMedias(), googleDriveMedias: uploadedMedia, ), ...googleDriveMedia @@ -173,131 +245,60 @@ class HomeViewStateNotifier extends StateNotifier { state = state.copyWith(selectedMedias: selectedMedias); } - Future uploadMediaOnGoogleDrive() async { + Future deleteMediasFromLocal() async { + try { + final medias = state.selectedMedias + .where((element) => element.sources.contains(AppMediaSource.local)) + .map((e) => e.id) + .toList(); + await _localMediaService.deleteMedias(medias); + state = state.copyWith(selectedMedias: []); + await loadLocalMedia(); + } catch (e) { + state = state.copyWith(error: e); + } + } + + Future deleteMediasFromGoogleDrive() async { + try { + final medias = state.selectedMedias.where( + (element) => + element.sources.contains(AppMediaSource.googleDrive) && + element.driveMediaRefId != null, + ); + + _googleDriveProcessRepo.deleteMediasFromGoogleDrive( + medias: medias.toList()); + state = state.copyWith(selectedMedias: []); + } catch (e) { + state = state.copyWith(error: e); + } + } + + Future backUpMediaOnGoogleDrive() async { try { if (!_authService.signedInWithGoogle) { await _authService.signInWithGoogle(); await loadGoogleDriveMedia(); } - - List uploadingMedias = state.selectedMedias + List medias = state.selectedMedias .where((element) => !element.sources.contains(AppMediaSource.googleDrive)) .toList(); - state = state.copyWith( - uploadingMedias: uploadingMedias - .map((e) => - UploadProgress(mediaId: e.id, status: UploadStatus.waiting)) - .toList(), - error: null, + _googleDriveProcessRepo.uploadMediasInGoogleDrive( + medias: medias, ); - - _backUpFolderId ??= await _googleDriveService.getBackupFolderId(); - - for (final media in uploadingMedias) { - state = state.copyWith( - uploadingMedias: state.uploadingMedias.toList() - ..updateElement( - newElement: UploadProgress( - mediaId: media.id, status: UploadStatus.uploading), - oldElement: UploadProgress( - mediaId: media.id, status: UploadStatus.waiting)), - ); - - await _googleDriveService.uploadInGoogleDrive( - media: media, - folderID: _backUpFolderId!, - ); - - state = state.copyWith( - medias: state.medias.map((key, value) { - value.updateElement( - newElement: media.copyWith( - sources: media.sources.toList() - ..add(AppMediaSource.googleDrive)), - oldElement: media); - return MapEntry(key, value); - }), - uploadingMedias: state.uploadingMedias.toList() - ..removeWhere((element) => element.mediaId == media.id), - ); - } - - state = state.copyWith(uploadingMedias: [], selectedMedias: []); + state = state.copyWith(selectedMedias: []); } catch (error) { - if (error is BackUpFolderNotFound) { - _backUpFolderId = await _googleDriveService.getBackupFolderId(); - uploadMediaOnGoogleDrive(); - return; - } - state = state.copyWith(error: error, uploadingMedias: []); - } - } - - //Helper functions - List _mergeCommonMedia({ - required List localMedias, - required List googleDriveMedias, - }) { - // If one of the lists is empty, return the other list. - if (googleDriveMedias.isEmpty) return localMedias; - if (localMedias.isEmpty) return []; - - // Convert the lists to mutable lists. - localMedias = localMedias.toList(); - googleDriveMedias = googleDriveMedias.toList(); - - final mergedMedias = []; - - // Add common media to mergedMedias and remove them from the lists. - for (AppMedia localMedia in localMedias.toList()) { - googleDriveMedias - .toList() - .where((googleDriveMedia) => googleDriveMedia.path == localMedia.path) - .forEach((googleDriveMedia) { - localMedias.removeWhere((media) => media.id == localMedia.id); - - mergedMedias.add(localMedia.copyWith( - sources: [AppMediaSource.local, AppMediaSource.googleDrive], - thumbnailLink: googleDriveMedia.thumbnailLink, - )); - }); - } - - return [...mergedMedias, ...localMedias]; - } - - Map> _sortMedias({required List medias}) { - medias.sort((a, b) => (b.createdTime ?? DateTime.now()) - .compareTo(a.createdTime ?? DateTime.now())); - return groupBy( - medias, - (AppMedia media) => - media.createdTime?.dateOnly ?? DateTime.now().dateOnly, - ); - } - - List _removeGoogleDriveRefFromMedias( - Map> medias) { - final allMedias = medias.values.expand((element) => element).toList(); - for (int index = 0; index < allMedias.length; index++) { - if (allMedias[index].sources.length > 1) { - allMedias[index] = allMedias[index].copyWith( - sources: allMedias[index].sources.toList() - ..remove(AppMediaSource.googleDrive), - thumbnailLink: null, - ); - } else if (allMedias.contains(AppMediaSource.googleDrive)) { - allMedias.removeAt(index); - } + state = state.copyWith(error: error); } - return allMedias; } @override Future dispose() async { await _googleAccountSubscription?.cancel(); + _googleDriveProcessRepo.removeListener(_listenGoogleDriveProcess); super.dispose(); } } @@ -309,9 +310,10 @@ class HomeViewState with _$HomeViewState { @Default(false) bool hasLocalMediaAccess, @Default(false) bool loading, GoogleSignInAccount? googleAccount, + @Default(false) bool showTransfer, String? lastLocalMediaId, @Default({}) Map> medias, @Default([]) List selectedMedias, - @Default([]) List uploadingMedias, + @Default([]) List mediaProcesses, }) = _HomeViewState; } diff --git a/app/lib/ui/flow/home/home_screen_view_model.freezed.dart b/app/lib/ui/flow/home/home_screen_view_model.freezed.dart index 74356fb..c03a474 100644 --- a/app/lib/ui/flow/home/home_screen_view_model.freezed.dart +++ b/app/lib/ui/flow/home/home_screen_view_model.freezed.dart @@ -20,12 +20,12 @@ mixin _$HomeViewState { bool get hasLocalMediaAccess => throw _privateConstructorUsedError; bool get loading => throw _privateConstructorUsedError; GoogleSignInAccount? get googleAccount => throw _privateConstructorUsedError; + bool get showTransfer => throw _privateConstructorUsedError; String? get lastLocalMediaId => throw _privateConstructorUsedError; Map> get medias => throw _privateConstructorUsedError; List get selectedMedias => throw _privateConstructorUsedError; - List get uploadingMedias => - throw _privateConstructorUsedError; + List get mediaProcesses => throw _privateConstructorUsedError; @JsonKey(ignore: true) $HomeViewStateCopyWith get copyWith => @@ -43,10 +43,11 @@ abstract class $HomeViewStateCopyWith<$Res> { bool hasLocalMediaAccess, bool loading, GoogleSignInAccount? googleAccount, + bool showTransfer, String? lastLocalMediaId, Map> medias, List selectedMedias, - List uploadingMedias}); + List mediaProcesses}); } /// @nodoc @@ -66,10 +67,11 @@ class _$HomeViewStateCopyWithImpl<$Res, $Val extends HomeViewState> Object? hasLocalMediaAccess = null, Object? loading = null, Object? googleAccount = freezed, + Object? showTransfer = null, Object? lastLocalMediaId = freezed, Object? medias = null, Object? selectedMedias = null, - Object? uploadingMedias = null, + Object? mediaProcesses = null, }) { return _then(_value.copyWith( error: freezed == error ? _value.error : error, @@ -85,6 +87,10 @@ class _$HomeViewStateCopyWithImpl<$Res, $Val extends HomeViewState> ? _value.googleAccount : googleAccount // ignore: cast_nullable_to_non_nullable as GoogleSignInAccount?, + showTransfer: null == showTransfer + ? _value.showTransfer + : showTransfer // ignore: cast_nullable_to_non_nullable + as bool, lastLocalMediaId: freezed == lastLocalMediaId ? _value.lastLocalMediaId : lastLocalMediaId // ignore: cast_nullable_to_non_nullable @@ -97,10 +103,10 @@ class _$HomeViewStateCopyWithImpl<$Res, $Val extends HomeViewState> ? _value.selectedMedias : selectedMedias // ignore: cast_nullable_to_non_nullable as List, - uploadingMedias: null == uploadingMedias - ? _value.uploadingMedias - : uploadingMedias // ignore: cast_nullable_to_non_nullable - as List, + mediaProcesses: null == mediaProcesses + ? _value.mediaProcesses + : mediaProcesses // ignore: cast_nullable_to_non_nullable + as List, ) as $Val); } } @@ -118,10 +124,11 @@ abstract class _$$HomeViewStateImplCopyWith<$Res> bool hasLocalMediaAccess, bool loading, GoogleSignInAccount? googleAccount, + bool showTransfer, String? lastLocalMediaId, Map> medias, List selectedMedias, - List uploadingMedias}); + List mediaProcesses}); } /// @nodoc @@ -139,10 +146,11 @@ class __$$HomeViewStateImplCopyWithImpl<$Res> Object? hasLocalMediaAccess = null, Object? loading = null, Object? googleAccount = freezed, + Object? showTransfer = null, Object? lastLocalMediaId = freezed, Object? medias = null, Object? selectedMedias = null, - Object? uploadingMedias = null, + Object? mediaProcesses = null, }) { return _then(_$HomeViewStateImpl( error: freezed == error ? _value.error : error, @@ -158,6 +166,10 @@ class __$$HomeViewStateImplCopyWithImpl<$Res> ? _value.googleAccount : googleAccount // ignore: cast_nullable_to_non_nullable as GoogleSignInAccount?, + showTransfer: null == showTransfer + ? _value.showTransfer + : showTransfer // ignore: cast_nullable_to_non_nullable + as bool, lastLocalMediaId: freezed == lastLocalMediaId ? _value.lastLocalMediaId : lastLocalMediaId // ignore: cast_nullable_to_non_nullable @@ -170,10 +182,10 @@ class __$$HomeViewStateImplCopyWithImpl<$Res> ? _value._selectedMedias : selectedMedias // ignore: cast_nullable_to_non_nullable as List, - uploadingMedias: null == uploadingMedias - ? _value._uploadingMedias - : uploadingMedias // ignore: cast_nullable_to_non_nullable - as List, + mediaProcesses: null == mediaProcesses + ? _value._mediaProcesses + : mediaProcesses // ignore: cast_nullable_to_non_nullable + as List, )); } } @@ -186,13 +198,14 @@ class _$HomeViewStateImpl implements _HomeViewState { this.hasLocalMediaAccess = false, this.loading = false, this.googleAccount, + this.showTransfer = false, this.lastLocalMediaId, final Map> medias = const {}, final List selectedMedias = const [], - final List uploadingMedias = const []}) + final List mediaProcesses = const []}) : _medias = medias, _selectedMedias = selectedMedias, - _uploadingMedias = uploadingMedias; + _mediaProcesses = mediaProcesses; @override final Object? error; @@ -205,6 +218,9 @@ class _$HomeViewStateImpl implements _HomeViewState { @override final GoogleSignInAccount? googleAccount; @override + @JsonKey() + final bool showTransfer; + @override final String? lastLocalMediaId; final Map> _medias; @override @@ -224,18 +240,18 @@ class _$HomeViewStateImpl implements _HomeViewState { return EqualUnmodifiableListView(_selectedMedias); } - final List _uploadingMedias; + final List _mediaProcesses; @override @JsonKey() - List get uploadingMedias { - if (_uploadingMedias is EqualUnmodifiableListView) return _uploadingMedias; + List get mediaProcesses { + if (_mediaProcesses is EqualUnmodifiableListView) return _mediaProcesses; // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_uploadingMedias); + return EqualUnmodifiableListView(_mediaProcesses); } @override String toString() { - return 'HomeViewState(error: $error, hasLocalMediaAccess: $hasLocalMediaAccess, loading: $loading, googleAccount: $googleAccount, lastLocalMediaId: $lastLocalMediaId, medias: $medias, selectedMedias: $selectedMedias, uploadingMedias: $uploadingMedias)'; + return 'HomeViewState(error: $error, hasLocalMediaAccess: $hasLocalMediaAccess, loading: $loading, googleAccount: $googleAccount, showTransfer: $showTransfer, lastLocalMediaId: $lastLocalMediaId, medias: $medias, selectedMedias: $selectedMedias, mediaProcesses: $mediaProcesses)'; } @override @@ -249,13 +265,15 @@ class _$HomeViewStateImpl implements _HomeViewState { (identical(other.loading, loading) || other.loading == loading) && (identical(other.googleAccount, googleAccount) || other.googleAccount == googleAccount) && + (identical(other.showTransfer, showTransfer) || + other.showTransfer == showTransfer) && (identical(other.lastLocalMediaId, lastLocalMediaId) || other.lastLocalMediaId == lastLocalMediaId) && const DeepCollectionEquality().equals(other._medias, _medias) && const DeepCollectionEquality() .equals(other._selectedMedias, _selectedMedias) && const DeepCollectionEquality() - .equals(other._uploadingMedias, _uploadingMedias)); + .equals(other._mediaProcesses, _mediaProcesses)); } @override @@ -265,10 +283,11 @@ class _$HomeViewStateImpl implements _HomeViewState { hasLocalMediaAccess, loading, googleAccount, + showTransfer, lastLocalMediaId, const DeepCollectionEquality().hash(_medias), const DeepCollectionEquality().hash(_selectedMedias), - const DeepCollectionEquality().hash(_uploadingMedias)); + const DeepCollectionEquality().hash(_mediaProcesses)); @JsonKey(ignore: true) @override @@ -283,10 +302,11 @@ abstract class _HomeViewState implements HomeViewState { final bool hasLocalMediaAccess, final bool loading, final GoogleSignInAccount? googleAccount, + final bool showTransfer, final String? lastLocalMediaId, final Map> medias, final List selectedMedias, - final List uploadingMedias}) = _$HomeViewStateImpl; + final List mediaProcesses}) = _$HomeViewStateImpl; @override Object? get error; @@ -297,13 +317,15 @@ abstract class _HomeViewState implements HomeViewState { @override GoogleSignInAccount? get googleAccount; @override + bool get showTransfer; + @override String? get lastLocalMediaId; @override Map> get medias; @override List get selectedMedias; @override - List get uploadingMedias; + List get mediaProcesses; @override @JsonKey(ignore: true) _$$HomeViewStateImplCopyWith<_$HomeViewStateImpl> get copyWith => diff --git a/app/lib/ui/flow/home/home_view_model_helper_mixin.dart b/app/lib/ui/flow/home/home_view_model_helper_mixin.dart new file mode 100644 index 0000000..b258dea --- /dev/null +++ b/app/lib/ui/flow/home/home_view_model_helper_mixin.dart @@ -0,0 +1,80 @@ +import 'package:cloud_gallery/domain/extensions/media_list_extension.dart'; +import 'package:cloud_gallery/domain/formatter/date_formatter.dart'; +import 'package:collection/collection.dart'; +import 'package:data/models/app_process/app_process.dart'; +import 'package:data/models/media/media.dart'; +import 'package:data/models/media/media_extension.dart'; + +mixin HomeViewModelHelperMixin { + List mergeCommonMedia({ + required List localMedias, + required List googleDriveMedias, + }) { + // If one of the lists is empty, return the other list. + if (googleDriveMedias.isEmpty) return localMedias; + if (localMedias.isEmpty) return []; + + // Convert the lists to mutable lists. + localMedias = localMedias.toList(); + googleDriveMedias = googleDriveMedias.toList(); + + final mergedMedias = []; + + // Add common media to mergedMedias and remove them from the lists. + for (AppMedia localMedia in localMedias.toList()) { + googleDriveMedias + .where((googleDriveMedia) => googleDriveMedia.path == localMedia.path) + .forEach((googleDriveMedia) { + localMedias.removeWhere((media) => media.id == localMedia.id); + + mergedMedias.add(localMedia.mergeGoogleDriveMedia(googleDriveMedia)); + }); + } + + return [...mergedMedias, ...localMedias]; + } + + Map> sortMedias({required List medias}) { + medias.sort((a, b) => (b.createdTime ?? DateTime.now()) + .compareTo(a.createdTime ?? DateTime.now())); + return groupBy( + medias, + (AppMedia media) => + media.createdTime?.dateOnly ?? DateTime.now().dateOnly, + ); + } + + Map> removeGoogleDriveRefFromMediaMap( + {required Map> medias, + List? removeFromIds}) { + return medias.map((key, value) { + return MapEntry(key, + value..removeGoogleDriveRefFromMedias(removeFromIds: removeFromIds)); + }); + } + + Map> addGoogleDriveRefInMediaMap({ + required Map> medias, + required List process, + }) { + final processIds = process.map((e) => e.id).toList(); + return medias.map((key, value) { + return MapEntry( + key, + value + ..addGoogleDriveRefInMedias( + process: process, processIds: processIds)); + }); + } + + Map> replaceMediaRefInMediaMap({ + required Map> medias, + required List process, + }) { + final processIds = process.map((e) => e.id).toList(); + return medias.map((key, value) { + return MapEntry(key, + value..replaceMediaRefInMedias(process: process, processIds: processIds)); + }); + } +} diff --git a/app/lib/ui/flow/media_preview/components/download_require_view.dart b/app/lib/ui/flow/media_preview/components/download_require_view.dart new file mode 100644 index 0000000..9dafe1e --- /dev/null +++ b/app/lib/ui/flow/media_preview/components/download_require_view.dart @@ -0,0 +1,95 @@ +import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; +import 'package:cloud_gallery/domain/formatter/byte_formatter.dart'; +import 'package:data/models/app_process/app_process.dart'; +import 'package:data/models/media/media.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/indicators/circular_progress_indicator.dart'; +import '../../../../components/error_view.dart'; + +class DownloadRequireView extends StatelessWidget { + final AppMedia media; + final AppProcess? downloadProcess; + final void Function() onDownload; + + const DownloadRequireView( + {super.key, + required this.media, + this.downloadProcess, + required this.onDownload}); + + @override + Widget build(BuildContext context) { + return Center( + child: Stack( + alignment: Alignment.center, + children: [ + Hero( + tag: media, + child: Image.network( + height: double.infinity, + width: double.infinity, + media.thumbnailLink!, + fit: BoxFit.cover, + ), + ), + Container( + height: double.infinity, + width: double.infinity, + color: Colors.black38, + ), + if (downloadProcess?.progress != null && + downloadProcess!.status.isProcessing) ...[ + ErrorView( + foregroundColor: context.colorScheme.onPrimary, + icon: Stack( + alignment: Alignment.center, + children: [ + AppCircularProgressIndicator( + backgroundColor: context.colorScheme.outline, + color: context.colorScheme.onPrimary, + strokeWidth: 6, + size: context.mediaQuerySize.width * 0.15, + value: downloadProcess?.progress?.percentageInPoint, + ), + Icon( + CupertinoIcons.cloud_download, + color: context.colorScheme.onPrimary, + size: context.mediaQuerySize.width * 0.08, + ), + ], + ), + title: + "${downloadProcess?.progress?.chunk.formatBytes ?? "0.0 B"} - ${downloadProcess?.progress?.total.formatBytes ?? "0.0 B"} ${downloadProcess?.progress?.percentage.toStringAsFixed(0) ?? "0.0"}%", + message: context.l10n.download_in_progress_text), + ], + if (downloadProcess?.status.isWaiting ?? false) ...[ + ErrorView( + foregroundColor: context.colorScheme.onPrimary, + icon: Icon( + CupertinoIcons.time, + size: context.mediaQuerySize.width * 0.15, + color: context.colorScheme.onPrimary, + ), + title: context.l10n.waiting_in_queue_text, + message: context.l10n.waiting_in_download_queue_message, + ), + ], + if (downloadProcess?.progress == null) + ErrorView( + foregroundColor: context.colorScheme.onPrimary, + icon: Icon(CupertinoIcons.cloud_download, + size: 68, color: context.colorScheme.onPrimary), + title: context.l10n.download_require_text, + message: context.l10n.download_require_message, + action: ErrorViewAction( + title: context.l10n.common_download, + onPressed: onDownload, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/ui/flow/media_preview/components/image_preview_screen.dart b/app/lib/ui/flow/media_preview/components/image_preview_screen.dart new file mode 100644 index 0000000..c66b552 --- /dev/null +++ b/app/lib/ui/flow/media_preview/components/image_preview_screen.dart @@ -0,0 +1,68 @@ +import 'dart:io'; +import 'package:cloud_gallery/components/app_page.dart'; +import 'package:cloud_gallery/components/error_view.dart'; +import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; +import 'package:data/models/media/media.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../domain/extensions/widget_extensions.dart'; +import 'network_image_preview/network_image_preview.dart'; +import 'network_image_preview/network_image_preview_view_model.dart'; + +class ImagePreview extends ConsumerStatefulWidget { + final AppMedia media; + + const ImagePreview({ + super.key, + required this.media, + }); + + @override + ConsumerState createState() => _ImagePreviewScreenState(); +} + +class _ImagePreviewScreenState extends ConsumerState { + late NetworkImagePreviewStateNotifier notifier; + + @override + void initState() { + if (!widget.media.sources.contains(AppMediaSource.local)) { + notifier = ref.read(networkImagePreviewStateNotifierProvider.notifier); + runPostFrame(() { + notifier.loadImage(widget.media.id); + }); + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + return InteractiveViewer( + maxScale: 100, + child: Center( + child: SizedBox( + width: double.infinity, + child: widget.media.sources.contains(AppMediaSource.local) + ? _displayLocalImage(context: context) + : NetworkImagePreview( + media: widget.media, + ), + ), + ), + ); + } + + Widget _displayLocalImage({required BuildContext context}) { + return Hero( + tag: widget.media, + child: Image.file(File(widget.media.path), fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return AppPage( + body: ErrorView( + title: context.l10n.unable_to_load_media_error, + message: context.l10n.unable_to_load_media_message, + )); + }), + ); + } +} diff --git a/app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview.dart b/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview.dart similarity index 70% rename from app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview.dart rename to app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview.dart index c949a09..b794206 100644 --- a/app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview.dart +++ b/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview.dart @@ -1,8 +1,10 @@ import 'dart:typed_data'; +import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; import 'package:data/models/media/media.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:style/indicators/circular_progress_indicator.dart'; +import '../../../../../components/error_view.dart'; import 'network_image_preview_view_model.dart'; class NetworkImagePreview extends ConsumerWidget { @@ -15,7 +17,7 @@ class NetworkImagePreview extends ConsumerWidget { final state = ref.watch(networkImagePreviewStateNotifierProvider); if (state.loading) { - return const Center(child: AppCircularProgressIndicator()); + return Center(child: AppCircularProgressIndicator(value: state.progress)); } else if (state.mediaBytes != null) { return Hero( tag: media, @@ -23,7 +25,10 @@ class NetworkImagePreview extends ConsumerWidget { fit: BoxFit.fitWidth), ); } else if (state.error != null) { - return const Center(child: Text('Error')); + return ErrorView( + title: context.l10n.unable_to_load_media_error, + message: context.l10n.unable_to_load_media_message, + ); } return const Placeholder(); } diff --git a/app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview_view_model.dart b/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview_view_model.dart similarity index 59% rename from app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview_view_model.dart rename to app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview_view_model.dart index 571b17f..dbff95e 100644 --- a/app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview_view_model.dart +++ b/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview_view_model.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:data/models/media_content/media_content.dart'; import 'package:data/services/google_drive_service.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -13,6 +15,7 @@ final networkImagePreviewStateNotifierProvider = StateNotifierProvider< class NetworkImagePreviewStateNotifier extends StateNotifier { final GoogleDriveService _googleDriveServices; + late StreamSubscription _subscription; NetworkImagePreviewStateNotifier(this._googleDriveServices) : super(const NetworkImagePreviewState()); @@ -22,15 +25,43 @@ class NetworkImagePreviewStateNotifier state = state.copyWith(loading: true, error: null); final mediaContent = await _googleDriveServices.fetchMediaBytes(mediaId); final mediaByte = []; - await for (var mediaBytes in mediaContent.stream) { - mediaByte.addAll(mediaBytes); - } - state = state.copyWith( - mediaContent: mediaContent, mediaBytes: mediaByte, loading: false); + final length = mediaContent.length ?? 0; + + _subscription = mediaContent.stream.listen( + (byteChunk) { + mediaByte.addAll(byteChunk); + state = state.copyWith( + progress: length <= 0 ? 0 : mediaByte.length / length); + }, + onDone: () { + state = state.copyWith( + mediaContent: mediaContent, + mediaBytes: mediaByte, + loading: false, + ); + _subscription.cancel(); + }, + onError: (error) { + state = state.copyWith( + error: error, + loading: false, + ); + _subscription.cancel(); + }, + ); } catch (error) { - state = state.copyWith(error: error, loading: false); + state = state.copyWith( + error: error, + loading: false, + ); } } + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } } @freezed @@ -39,6 +70,7 @@ class NetworkImagePreviewState with _$NetworkImagePreviewState { @Default(false) bool loading, AppMediaContent? mediaContent, List? mediaBytes, + @Default(0.0) double progress, Object? error, }) = _NetworkImagePreviewState; } diff --git a/app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview_view_model.freezed.dart b/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview_view_model.freezed.dart similarity index 90% rename from app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview_view_model.freezed.dart rename to app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview_view_model.freezed.dart index b953966..a1a47a9 100644 --- a/app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview_view_model.freezed.dart +++ b/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview_view_model.freezed.dart @@ -19,6 +19,7 @@ mixin _$NetworkImagePreviewState { bool get loading => throw _privateConstructorUsedError; AppMediaContent? get mediaContent => throw _privateConstructorUsedError; List? get mediaBytes => throw _privateConstructorUsedError; + double get progress => throw _privateConstructorUsedError; Object? get error => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -36,6 +37,7 @@ abstract class $NetworkImagePreviewStateCopyWith<$Res> { {bool loading, AppMediaContent? mediaContent, List? mediaBytes, + double progress, Object? error}); $AppMediaContentCopyWith<$Res>? get mediaContent; @@ -58,6 +60,7 @@ class _$NetworkImagePreviewStateCopyWithImpl<$Res, Object? loading = null, Object? mediaContent = freezed, Object? mediaBytes = freezed, + Object? progress = null, Object? error = freezed, }) { return _then(_value.copyWith( @@ -73,6 +76,10 @@ class _$NetworkImagePreviewStateCopyWithImpl<$Res, ? _value.mediaBytes : mediaBytes // ignore: cast_nullable_to_non_nullable as List?, + progress: null == progress + ? _value.progress + : progress // ignore: cast_nullable_to_non_nullable + as double, error: freezed == error ? _value.error : error, ) as $Val); } @@ -103,6 +110,7 @@ abstract class _$$NetworkImagePreviewStateImplCopyWith<$Res> {bool loading, AppMediaContent? mediaContent, List? mediaBytes, + double progress, Object? error}); @override @@ -125,6 +133,7 @@ class __$$NetworkImagePreviewStateImplCopyWithImpl<$Res> Object? loading = null, Object? mediaContent = freezed, Object? mediaBytes = freezed, + Object? progress = null, Object? error = freezed, }) { return _then(_$NetworkImagePreviewStateImpl( @@ -140,6 +149,10 @@ class __$$NetworkImagePreviewStateImplCopyWithImpl<$Res> ? _value._mediaBytes : mediaBytes // ignore: cast_nullable_to_non_nullable as List?, + progress: null == progress + ? _value.progress + : progress // ignore: cast_nullable_to_non_nullable + as double, error: freezed == error ? _value.error : error, )); } @@ -152,6 +165,7 @@ class _$NetworkImagePreviewStateImpl implements _NetworkImagePreviewState { {this.loading = false, this.mediaContent, final List? mediaBytes, + this.progress = 0.0, this.error}) : _mediaBytes = mediaBytes; @@ -170,12 +184,15 @@ class _$NetworkImagePreviewStateImpl implements _NetworkImagePreviewState { return EqualUnmodifiableListView(value); } + @override + @JsonKey() + final double progress; @override final Object? error; @override String toString() { - return 'NetworkImagePreviewState(loading: $loading, mediaContent: $mediaContent, mediaBytes: $mediaBytes, error: $error)'; + return 'NetworkImagePreviewState(loading: $loading, mediaContent: $mediaContent, mediaBytes: $mediaBytes, progress: $progress, error: $error)'; } @override @@ -188,6 +205,8 @@ class _$NetworkImagePreviewStateImpl implements _NetworkImagePreviewState { other.mediaContent == mediaContent) && const DeepCollectionEquality() .equals(other._mediaBytes, _mediaBytes) && + (identical(other.progress, progress) || + other.progress == progress) && const DeepCollectionEquality().equals(other.error, error)); } @@ -197,6 +216,7 @@ class _$NetworkImagePreviewStateImpl implements _NetworkImagePreviewState { loading, mediaContent, const DeepCollectionEquality().hash(_mediaBytes), + progress, const DeepCollectionEquality().hash(error)); @JsonKey(ignore: true) @@ -212,6 +232,7 @@ abstract class _NetworkImagePreviewState implements NetworkImagePreviewState { {final bool loading, final AppMediaContent? mediaContent, final List? mediaBytes, + final double progress, final Object? error}) = _$NetworkImagePreviewStateImpl; @override @@ -221,6 +242,8 @@ abstract class _NetworkImagePreviewState implements NetworkImagePreviewState { @override List? get mediaBytes; @override + double get progress; + @override Object? get error; @override @JsonKey(ignore: true) diff --git a/app/lib/ui/flow/media_preview/components/top_bar.dart b/app/lib/ui/flow/media_preview/components/top_bar.dart new file mode 100644 index 0000000..2c5cc58 --- /dev/null +++ b/app/lib/ui/flow/media_preview/components/top_bar.dart @@ -0,0 +1,196 @@ +import 'dart:io'; +import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; +import 'package:data/models/media/media_extension.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:style/animations/cross_fade_animation.dart'; +import 'package:style/buttons/action_button.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/text/app_text_style.dart'; +import '../../../../components/app_dialog.dart'; +import '../../../../components/app_page.dart'; +import '../../../../domain/assets/assets_paths.dart'; +import '../../../../domain/formatter/date_formatter.dart'; +import '../media_preview_view_model.dart'; + +class PreviewTopBar extends StatelessWidget { + final AutoDisposeStateNotifierProvider provider; + + const PreviewTopBar({super.key, required this.provider}); + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, ref, child) { + final notifier = ref.read(provider.notifier); + final media = ref + .watch(provider.select((state) => state.medias[state.currentIndex])); + final showManu = ref.watch(provider.select((state) => state.showActions)); + + return CrossFadeAnimation( + showChild: showManu, + child: AdaptiveAppBar( + iosTransitionBetweenRoutes: false, + text: + media.createdTime?.format(context, DateFormatType.relative) ?? '', + actions: [ + ActionButton( + onPressed: () { + ///TODO: media details + }, + icon: Icon( + CupertinoIcons.info, + color: context.colorScheme.textSecondary, + size: 22, + ), + ), + ActionButton( + onPressed: () async { + if (media.isCommonStored && media.driveMediaRefId != null) { + showMenu( + context: context, + position: RelativeRect.fromLTRB( + double.infinity, + kToolbarHeight + MediaQuery.of(context).padding.top, + 0, + 0), + surfaceTintColor: context.colorScheme.surface, + color: context.colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + items: [ + PopupMenuItem( + onTap: () async { + _showDeleteFromDriveDialog( + context: context, + onDelete: () { + notifier.deleteMediaFromGoogleDrive( + media.driveMediaRefId); + context.pop(); + }); + }, + child: Row( + children: [ + SvgPicture.asset( + Assets.images.icons.googlePhotos, + width: 20, + height: 20, + ), + const SizedBox(width: 16), + Text(context.l10n.common_delete_from_google_drive, + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textPrimary, + )), + ], + ), + ), + PopupMenuItem( + onTap: () async { + _showDeleteFromDeviceDialog( + context: context, + onDelete: () { + notifier.deleteMediaFromLocal(media.id); + context.pop(); + }); + }, + child: Row( + children: [ + Icon(CupertinoIcons.trash, + color: context.colorScheme.textSecondary, + size: 22), + const SizedBox(width: 16), + Text( + context.l10n.common_delete_from_device, + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textPrimary, + ), + ), + ], + ), + ), + ], + ); + } else if (media.isGoogleDriveStored && + media.driveMediaRefId != null) { + _showDeleteFromDriveDialog( + context: context, + onDelete: () { + notifier + .deleteMediaFromGoogleDrive(media.driveMediaRefId); + context.pop(); + }); + } else if (media.isLocalStored) { + _showDeleteFromDeviceDialog( + context: context, + onDelete: () { + notifier.deleteMediaFromLocal(media.id); + context.pop(); + }); + } + }, + icon: Padding( + padding: const EdgeInsets.all(4.0), + child: Icon( + CupertinoIcons.delete, + color: context.colorScheme.textSecondary, + size: 22, + ), + ), + ), + if (!Platform.isIOS && !Platform.isMacOS) const SizedBox(width: 8), + ], + ), + ); + }); + } + + Future _showDeleteFromDriveDialog( + {required BuildContext context, + required void Function() onDelete}) async { + await showAppAlertDialog( + context: context, + title: context.l10n.common_delete_from_google_drive, + message: context.l10n.delete_media_from_google_drive_confirmation_message, + actions: [ + AppAlertAction( + title: context.l10n.common_cancel, + onPressed: () { + context.pop(); + }, + ), + AppAlertAction( + isDestructiveAction: true, + title: context.l10n.common_delete, + onPressed: onDelete, + ), + ], + ); + } + + Future _showDeleteFromDeviceDialog( + {required BuildContext context, + required void Function() onDelete}) async { + await showAppAlertDialog( + context: context, + title: context.l10n.common_delete_from_device, + message: context.l10n.delete_media_from_device_confirmation_message, + actions: [ + AppAlertAction( + title: context.l10n.common_cancel, + onPressed: () { + context.pop(); + }, + ), + AppAlertAction( + isDestructiveAction: true, + title: context.l10n.common_delete, + onPressed: onDelete, + ), + ], + ); + } +} diff --git a/app/lib/ui/flow/media_preview/components/video_player_components/video_actions.dart b/app/lib/ui/flow/media_preview/components/video_player_components/video_actions.dart new file mode 100644 index 0000000..8cb19e2 --- /dev/null +++ b/app/lib/ui/flow/media_preview/components/video_player_components/video_actions.dart @@ -0,0 +1,69 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:style/animations/cross_fade_animation.dart'; +import 'package:style/animations/on_tap_scale.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/animations/animated_icon.dart'; + +class VideoActions extends StatelessWidget { + final bool showActions; + final void Function()? onBackward; + final void Function()? onForward; + final void Function()? onPlayPause; + final bool isPlaying; + + const VideoActions( + {super.key, + required this.showActions, + this.onBackward, + this.onForward, + this.onPlayPause, + this.isPlaying = false}); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.center, + child: CrossFadeAnimation( + alignment: Alignment.center, + replacement: const SizedBox(), + showChild: showActions, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + OnTapScale( + onTap: onBackward, + child: Padding( + padding: const EdgeInsets.only( + left: 22, right: 18, top: 18, bottom: 18), + child: Icon( + CupertinoIcons.gobackward_10, + color: context.colorScheme.onPrimary, + size: 32, + ), + ), + ), + OnTapScale( + onTap: onPlayPause, + child: AnimatedIconAnimation( + value: isPlaying, icon: AnimatedIcons.play_pause, size: 64), + ), + OnTapScale( + onTap: onForward, + child: const Padding( + padding: + EdgeInsets.only(left: 18, right: 22, top: 18, bottom: 18), + child: Icon( + CupertinoIcons.goforward_10, + color: Colors.white, + size: 32, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/ui/flow/media_preview/components/video_player_components/video_duration_slider.dart b/app/lib/ui/flow/media_preview/components/video_player_components/video_duration_slider.dart new file mode 100644 index 0000000..205b8cd --- /dev/null +++ b/app/lib/ui/flow/media_preview/components/video_player_components/video_duration_slider.dart @@ -0,0 +1,80 @@ +import 'dart:ui'; +import 'package:cloud_gallery/domain/formatter/duration_formatter.dart'; +import 'package:flutter/material.dart'; +import 'package:style/animations/cross_fade_animation.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/text/app_text_style.dart'; + +class VideoDurationSlider extends StatelessWidget { + final bool showSlider; + final Duration duration; + final void Function(Duration duration) onChanged; + final void Function(Duration duration) onChangeEnd; + final Duration position; + + const VideoDurationSlider( + {super.key, + required this.showSlider, + required this.duration, + required this.position, + required this.onChangeEnd, + required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.bottomCenter, + child: CrossFadeAnimation( + showChild: showSlider, + child: Container( + padding: EdgeInsets.only( + bottom: context.systemPadding.bottom + 8, + top: 8, + left: 16, + right: 16), + color: context.colorScheme.barColor, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(position.format, + style: AppTextStyles.caption + .copyWith(color: context.colorScheme.textPrimary)), + Expanded( + child: SizedBox( + height: 30, + child: Material( + color: Colors.transparent, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 4, + trackShape: const RoundedRectSliderTrackShape(), + rangeTrackShape: + const RoundedRectRangeSliderTrackShape(), + thumbShape: SliderComponentShape.noThumb, + ), + child: Slider( + value: position.inSeconds.toDouble(), + max: duration.inSeconds.toDouble(), + min: 0, + activeColor: context.colorScheme.primary, + inactiveColor: context.colorScheme.outline, + onChangeEnd: (value) => onChangeEnd.call(Duration(seconds: value.toInt())), + onChanged: (double value) => onChanged.call(Duration(seconds: value.toInt())), + ), + ), + ), + ), + ), + Text(duration.format, + style: AppTextStyles.caption + .copyWith(color: context.colorScheme.textPrimary)), + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/ui/flow/media_preview/image_preview/image_preview_screen.dart b/app/lib/ui/flow/media_preview/image_preview/image_preview_screen.dart deleted file mode 100644 index b075768..0000000 --- a/app/lib/ui/flow/media_preview/image_preview/image_preview_screen.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'dart:io'; -import 'dart:math'; -import 'package:data/models/media/media.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:style/extensions/context_extensions.dart'; -import '../../../../components/app_page.dart'; -import '../../../../domain/extensions/widget_extensions.dart'; -import 'components/network_image_preview/network_image_preview.dart'; -import 'components/network_image_preview/network_image_preview_view_model.dart'; - -class ImagePreviewScreen extends ConsumerStatefulWidget { - final AppMedia media; - - const ImagePreviewScreen({ - super.key, - required this.media, - }); - - @override - ConsumerState createState() => _ImagePreviewScreenState(); -} - -class _ImagePreviewScreenState extends ConsumerState { - final _transformationController = TransformationController(); - double _translateY = 0; - double _scale = 1; - - late NetworkImagePreviewStateNotifier notifier; - - @override - void initState() { - if (!widget.media.sources.contains(AppMediaSource.local)) { - notifier = ref.read(networkImagePreviewStateNotifierProvider.notifier); - runPostFrame(() { - notifier.loadImage(widget.media.id); - }); - } - super.initState(); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onVerticalDragStart: (details) { - _translateY = 0; - _scale = 1; - }, - onVerticalDragUpdate: (details) { - if (_transformationController.value.getMaxScaleOnAxis() > 1) { - return; - } - - setState(() { - _translateY = max(0, _translateY + (details.primaryDelta ?? 0)); - _scale = 1 - (_translateY / 1000); - }); - }, - onVerticalDragEnd: (details) { - if (_transformationController.value.getMaxScaleOnAxis() > 1 || - _translateY == 0) { - return; - } - - final velocity = details.primaryVelocity ?? 0; - if (velocity > 1000) { - Navigator.of(context).pop(); - } else if (_translateY > 100) { - Navigator.of(context).pop(); - } else { - setState(() { - _translateY = 0; - _scale = 1; - }); - } - }, - child: AppPage( - backgroundColor: _scale == 1 - ? context.colorScheme.surface - : context.colorScheme.surface.withOpacity(_scale / 2), - body: Stack( - children: [ - Transform.translate( - offset: Offset(0, _translateY), - child: Transform.scale( - scale: _scale, - child: InteractiveViewer( - transformationController: _transformationController, - maxScale: 100, - child: Center( - child: SizedBox( - width: double.infinity, - child: widget.media.sources.contains(AppMediaSource.local) - ? _displayLocalImage(context: context) - : NetworkImagePreview( - media: widget.media, - ), - ), - ), - ), - ), - ), - if (_scale == 1) - Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - AdaptiveAppBar( - iosTransitionBetweenRoutes: false, - text: widget.media.name ?? '', - ), - ], - ), - ], - ), - ), - ); - } - - Widget _displayLocalImage({required BuildContext context}) { - return Hero( - tag: widget.media, - child: Image.file( - File(widget.media.path), - fit: BoxFit.contain, - ), - ); - } -} diff --git a/app/lib/ui/flow/media_preview/media_preview.dart b/app/lib/ui/flow/media_preview/media_preview.dart deleted file mode 100644 index 2513eb1..0000000 --- a/app/lib/ui/flow/media_preview/media_preview.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:cloud_gallery/components/snack_bar.dart'; -import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; -import 'package:cloud_gallery/ui/navigation/app_router.dart'; -import 'package:data/models/media/media.dart'; -import 'package:flutter/cupertino.dart'; - -class AppMediaView { - static void showPreview( - {required BuildContext context, - required AppMedia media}) { - if (media.type.isImage) { - AppRouter.imagePreview(media: media).push(context); - } else if (media.type.isVideo) { - AppRouter.videoPreview( - path: media.path, - isLocal: media.sources.contains(AppMediaSource.local)) - .push(context); - } else { - showErrorSnackBar(context: context, error: context.l10n.unable_to_open_attachment_error); - } - } -} diff --git a/app/lib/ui/flow/media_preview/media_preview_screen.dart b/app/lib/ui/flow/media_preview/media_preview_screen.dart new file mode 100644 index 0000000..5e70c34 --- /dev/null +++ b/app/lib/ui/flow/media_preview/media_preview_screen.dart @@ -0,0 +1,283 @@ +import 'dart:io'; +import 'package:cloud_gallery/components/app_page.dart'; +import 'package:cloud_gallery/components/error_view.dart'; +import 'package:cloud_gallery/components/snack_bar.dart'; +import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; +import 'package:cloud_gallery/domain/extensions/widget_extensions.dart'; +import 'package:cloud_gallery/ui/flow/media_preview/components/download_require_view.dart'; +import 'package:cloud_gallery/ui/flow/media_preview/components/image_preview_screen.dart'; +import 'package:cloud_gallery/ui/flow/media_preview/components/top_bar.dart'; +import 'package:cloud_gallery/ui/flow/media_preview/components/video_player_components/video_actions.dart'; +import 'package:cloud_gallery/ui/flow/media_preview/media_preview_view_model.dart'; +import 'package:data/models/media/media.dart'; +import 'package:data/models/media/media_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/indicators/circular_progress_indicator.dart'; +import 'package:video_player/video_player.dart'; +import 'components/video_player_components/video_duration_slider.dart'; +import 'package:style/animations/dismissible_page.dart'; + +class MediaPreview extends ConsumerStatefulWidget { + final List medias; + final String startFrom; + + const MediaPreview( + {super.key, required this.medias, required this.startFrom}); + + @override + ConsumerState createState() => _MediaPreviewState(); +} + +class _MediaPreviewState extends ConsumerState { + late AutoDisposeStateNotifierProvider _provider; + late PageController _pageController; + late MediaPreviewStateNotifier notifier; + + VideoPlayerController? _videoPlayerController; + + @override + void initState() { + final currentIndex = + widget.medias.indexWhere((element) => element.id == widget.startFrom); + + //initialize view notifier with initial state + _provider = mediaPreviewStateNotifierProvider( + MediaPreviewState(currentIndex: currentIndex, medias: widget.medias)); + notifier = ref.read(_provider.notifier); + + _pageController = PageController(initialPage: currentIndex, keepPage: true); + + if (widget.medias[currentIndex].type.isVideo && + widget.medias[currentIndex].sources.contains(AppMediaSource.local)) { + runPostFrame(() => _initializeVideoControllerWithListener( + path: widget.medias[currentIndex].path)); + } else if (widget.medias[currentIndex].type.isVideo && + widget.medias[currentIndex].isGoogleDriveStored) {} + super.initState(); + } + + Future _initializeVideoControllerWithListener( + {required String path}) async { + _videoPlayerController = VideoPlayerController.file(File(path)); + _videoPlayerController?.addListener(_observeVideoController); + await _videoPlayerController?.initialize(); + notifier.updateVideoInitialized( + _videoPlayerController?.value.isInitialized ?? false); + await _videoPlayerController?.play(); + } + + _observeVideoController() { + notifier.updateVideoInitialized( + _videoPlayerController?.value.isInitialized ?? false); + notifier.updateVideoBuffering( + _videoPlayerController?.value.isBuffering ?? false); + notifier.updateVideoPosition( + _videoPlayerController?.value.position ?? Duration.zero); + notifier.updateVideoMaxDuration( + _videoPlayerController?.value.duration ?? Duration.zero); + notifier + .updateVideoPlaying(_videoPlayerController?.value.isPlaying ?? false); + } + + void _observeError() { + ref.listen( + _provider.select((value) => value.error), + (previous, next) { + if (next != null) { + showErrorSnackBar(context: context, error: next); + } + }, + ); + } + + void _updateVideoControllerOnMediaChange() { + ref.listen(_provider.select((value) => value.medias[value.currentIndex]), + (previous, next) { + if (_videoPlayerController != null) { + _videoPlayerController?.removeListener(_observeVideoController); + notifier.updateVideoInitialized(false); + _videoPlayerController?.dispose(); + _videoPlayerController = null; + } + if (next.type.isVideo && next.sources.contains(AppMediaSource.local)) { + _initializeVideoControllerWithListener(path: next.path); + } + }); + } + + @override + void dispose() { + _videoPlayerController?.removeListener(_observeVideoController); + _videoPlayerController?.dispose(); + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + _observeError(); + _updateVideoControllerOnMediaChange(); + final medias = ref.watch(_provider.select((state) => state.medias)); + final showActions = + ref.watch(_provider.select((state) => state.showActions)); + + return DismissiblePage( + backgroundColor: context.colorScheme.surface, + onProgress: (progress) { + if (progress > 0 && showActions) { + notifier.toggleActionVisibility(); + } + }, + onDismiss: () { + context.pop(); + }, + child: (progress) => AppPage( + backgroundColor: + progress == 0 ? context.colorScheme.surface : Colors.transparent, + body: Stack( + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: notifier.toggleActionVisibility, + child: PageView.builder( + onPageChanged: notifier.changeVisibleMediaIndex, + controller: _pageController, + itemCount: medias.length, + itemBuilder: (context, index) => + _preview(context: context, media: medias[index]), + ), + ), + PreviewTopBar(provider: _provider), + _videoActions(context), + _videoDurationSlider(context), + ], + ), + ), + ); + } + + Widget _preview({required BuildContext context, required AppMedia media}) { + if (media.type.isVideo && media.sources.contains(AppMediaSource.local)) { + return Center( + child: Consumer(builder: (context, ref, child) { + ({bool initialized, bool buffring}) state = ref.watch( + _provider.select((state) => ( + initialized: state.isVideoInitialized, + buffring: state.isVideoBuffering + ))); + + if (!state.initialized || state.buffring) { + return AppCircularProgressIndicator( + color: context.colorScheme.onPrimary, + ); + } else { + return Hero( + tag: media, + child: AspectRatio( + aspectRatio: _videoPlayerController!.value.aspectRatio, + child: VideoPlayer(_videoPlayerController!), + ), + ); + } + }), + ); + } else if (media.type.isVideo && media.isGoogleDriveStored) { + return _googleDriveVideoView(context: context, media: media); + } else if (media.type.isImage) { + return ImagePreview(media: media); + } else { + return ErrorView( + title: context.l10n.unable_to_load_media_error, + message: context.l10n.unable_to_load_media_message, + ); + } + } + + Widget _googleDriveVideoView( + {required BuildContext context, required AppMedia media}) { + return Consumer( + builder: (context, ref, child) { + final process = ref.watch(_provider.select((value) => value + .downloadProcess + .where((element) => element.id == media.id) + .firstOrNull)); + return DownloadRequireView( + media: media, + downloadProcess: process, + onDownload: () { + notifier.downloadMediaFromGoogleDrive(media: media); + }); + }, + ); + } + + Widget _videoActions(BuildContext context) => Consumer( + builder: (context, ref, child) { + final ({ + bool showActions, + bool isPlaying, + Duration position, + }) state = ref.watch(_provider.select((state) => ( + showActions: state.showActions && + state.medias[state.currentIndex].type.isVideo && + state.isVideoInitialized, + isPlaying: state.isVideoPlaying, + position: state.videoPosition, + ))); + + return VideoActions( + showActions: state.showActions, + isPlaying: state.isPlaying, + onBackward: () { + notifier.updateVideoPosition( + state.position - const Duration(seconds: 10)); + _videoPlayerController + ?.seekTo(state.position - const Duration(seconds: 10)); + }, + onForward: () { + notifier.updateVideoPosition( + state.position + const Duration(seconds: 10)); + _videoPlayerController + ?.seekTo(state.position + const Duration(seconds: 10)); + }, + onPlayPause: () { + if (state.isPlaying) { + _videoPlayerController?.pause(); + } else { + _videoPlayerController?.play(); + } + }, + ); + }, + ); + + Widget _videoDurationSlider(BuildContext context) => + Consumer(builder: (context, ref, child) { + final ({ + bool showDurationSlider, + Duration duration, + Duration position + }) state = ref.watch(_provider.select((state) => ( + showDurationSlider: state.showActions && + state.medias[state.currentIndex].type.isVideo && + state.isVideoInitialized, + duration: state.videoMaxDuration, + position: state.videoPosition + ))); + return VideoDurationSlider( + showSlider: state.showDurationSlider, + duration: state.duration, + position: state.position, + onChangeEnd: (duration) { + _videoPlayerController?.seekTo(duration); + }, + onChanged: (duration) { + notifier.updateVideoPosition(duration); + }, + ); + }); +} diff --git a/app/lib/ui/flow/media_preview/media_preview_view_model.dart b/app/lib/ui/flow/media_preview/media_preview_view_model.dart new file mode 100644 index 0000000..efef08f --- /dev/null +++ b/app/lib/ui/flow/media_preview/media_preview_view_model.dart @@ -0,0 +1,154 @@ +import 'package:cloud_gallery/domain/extensions/media_list_extension.dart'; +import 'package:data/models/app_process/app_process.dart'; +import 'package:data/models/media/media.dart'; +import 'package:data/repositories/google_drive_process_repo.dart'; +import 'package:data/services/google_drive_service.dart'; +import 'package:data/services/local_media_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'media_preview_view_model.freezed.dart'; + +final mediaPreviewStateNotifierProvider = StateNotifierProvider.family + .autoDispose( + (ref, initial) => MediaPreviewStateNotifier( + ref.read(localMediaServiceProvider), + ref.read(googleDriveServiceProvider), + ref.read(googleDriveProcessRepoProvider), + initial, + ), +); + +class MediaPreviewStateNotifier extends StateNotifier { + final LocalMediaService _localMediaService; + final GoogleDriveService _googleDriveService; + final GoogleDriveProcessRepo _googleDriveProcessRepo; + + MediaPreviewStateNotifier(this._localMediaService, this._googleDriveService, + this._googleDriveProcessRepo, MediaPreviewState initialState) + : super(initialState) { + _googleDriveProcessRepo.addListener(_listenGoogleDriveProcessUpdates); + } + + void _listenGoogleDriveProcessUpdates() { + final successUploads = _googleDriveProcessRepo.uploadQueue + .where((element) => element.status.isSuccess); + + final successDeletes = _googleDriveProcessRepo.deleteQueue + .where((element) => element.status.isSuccess) + .map((e) => e.id); + + final successDownloads = _googleDriveProcessRepo.downloadQueue + .where((element) => element.status.isSuccess); + + if (successUploads.isNotEmpty) { + state = state.copyWith( + medias: state.medias.toList() + ..addGoogleDriveRefInMedias( + process: successUploads.toList(), + ), + ); + } + + if (successDeletes.isNotEmpty) { + state = state.copyWith( + medias: state.medias.toList() + ..removeGoogleDriveRefFromMedias( + removeFromIds: successDeletes.toList(), + ), + ); + } + if (successDownloads.isNotEmpty) { + state = state.copyWith( + medias: state.medias.toList() + ..replaceMediaRefInMedias( + process: successDownloads.toList(), + ), + ); + } + + state = state.copyWith( + uploadProcess: _googleDriveProcessRepo.uploadQueue, + downloadProcess: _googleDriveProcessRepo.downloadQueue, + deleteProcess: _googleDriveProcessRepo.deleteQueue, + ); + } + + void changeVisibleMediaIndex(int index) { + state = state.copyWith(currentIndex: index); + } + + void toggleActionVisibility() { + state = state.copyWith(showActions: !state.showActions); + } + + Future deleteMediaFromLocal(String id) async { + try { + await _localMediaService.deleteMedias([id]); + } catch (error) { + state = state.copyWith(error: error); + } + } + + Future deleteMediaFromGoogleDrive(String? id) async { + try { + await _googleDriveService.deleteMedia(id!); + } catch (error) { + state = state.copyWith(error: error); + } + } + + Future downloadMediaFromGoogleDrive({required AppMedia media})async { + _googleDriveProcessRepo.downloadMediasFromGoogleDrive(medias: [media]); + } + + void updateVideoPosition(Duration position) { + if (state.videoPosition == position) return; + state = state.copyWith(videoPosition: position); + } + + void updateVideoPlaying(bool isPlaying) { + if (state.isVideoPlaying == isPlaying) return; + state = state.copyWith(isVideoPlaying: isPlaying); + } + + void updateVideoBuffering(bool isBuffering) { + if (state.isVideoBuffering == isBuffering) return; + state = state.copyWith(isVideoBuffering: isBuffering); + } + + void updateVideoInitialized(bool isInitialized) { + if (state.isVideoInitialized == isInitialized) return; + state = state.copyWith(isVideoInitialized: isInitialized); + } + + void updateVideoMaxDuration(Duration maxDuration) { + if (state.videoMaxDuration == maxDuration) return; + state = state.copyWith(videoMaxDuration: maxDuration); + } + + @override + void dispose() { + _googleDriveProcessRepo.removeListener(_listenGoogleDriveProcessUpdates); + super.dispose(); + } +} + +@freezed +class MediaPreviewState with _$MediaPreviewState { + const factory MediaPreviewState({ + Object? error, + @Default([]) List medias, + @Default(0) int currentIndex, + @Default(true) bool showActions, + @Default(false) bool isVideoInitialized, + @Default(false) bool isVideoBuffering, + @Default(Duration.zero) Duration videoPosition, + @Default(Duration.zero) Duration videoMaxDuration, + @Default(false) bool isVideoPlaying, + @Default([]) List uploadProcess, + @Default([]) List downloadProcess, + @Default([]) List deleteProcess, + }) = _MediaPreviewState; +} diff --git a/app/lib/ui/flow/media_preview/media_preview_view_model.freezed.dart b/app/lib/ui/flow/media_preview/media_preview_view_model.freezed.dart new file mode 100644 index 0000000..d55aae7 --- /dev/null +++ b/app/lib/ui/flow/media_preview/media_preview_view_model.freezed.dart @@ -0,0 +1,412 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'media_preview_view_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$MediaPreviewState { + Object? get error => throw _privateConstructorUsedError; + List get medias => throw _privateConstructorUsedError; + int get currentIndex => throw _privateConstructorUsedError; + bool get showActions => throw _privateConstructorUsedError; + bool get isVideoInitialized => throw _privateConstructorUsedError; + bool get isVideoBuffering => throw _privateConstructorUsedError; + Duration get videoPosition => throw _privateConstructorUsedError; + Duration get videoMaxDuration => throw _privateConstructorUsedError; + bool get isVideoPlaying => throw _privateConstructorUsedError; + List get uploadProcess => throw _privateConstructorUsedError; + List get downloadProcess => throw _privateConstructorUsedError; + List get deleteProcess => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $MediaPreviewStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $MediaPreviewStateCopyWith<$Res> { + factory $MediaPreviewStateCopyWith( + MediaPreviewState value, $Res Function(MediaPreviewState) then) = + _$MediaPreviewStateCopyWithImpl<$Res, MediaPreviewState>; + @useResult + $Res call( + {Object? error, + List medias, + int currentIndex, + bool showActions, + bool isVideoInitialized, + bool isVideoBuffering, + Duration videoPosition, + Duration videoMaxDuration, + bool isVideoPlaying, + List uploadProcess, + List downloadProcess, + List deleteProcess}); +} + +/// @nodoc +class _$MediaPreviewStateCopyWithImpl<$Res, $Val extends MediaPreviewState> + implements $MediaPreviewStateCopyWith<$Res> { + _$MediaPreviewStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? error = freezed, + Object? medias = null, + Object? currentIndex = null, + Object? showActions = null, + Object? isVideoInitialized = null, + Object? isVideoBuffering = null, + Object? videoPosition = null, + Object? videoMaxDuration = null, + Object? isVideoPlaying = null, + Object? uploadProcess = null, + Object? downloadProcess = null, + Object? deleteProcess = null, + }) { + return _then(_value.copyWith( + error: freezed == error ? _value.error : error, + medias: null == medias + ? _value.medias + : medias // ignore: cast_nullable_to_non_nullable + as List, + currentIndex: null == currentIndex + ? _value.currentIndex + : currentIndex // ignore: cast_nullable_to_non_nullable + as int, + showActions: null == showActions + ? _value.showActions + : showActions // ignore: cast_nullable_to_non_nullable + as bool, + isVideoInitialized: null == isVideoInitialized + ? _value.isVideoInitialized + : isVideoInitialized // ignore: cast_nullable_to_non_nullable + as bool, + isVideoBuffering: null == isVideoBuffering + ? _value.isVideoBuffering + : isVideoBuffering // ignore: cast_nullable_to_non_nullable + as bool, + videoPosition: null == videoPosition + ? _value.videoPosition + : videoPosition // ignore: cast_nullable_to_non_nullable + as Duration, + videoMaxDuration: null == videoMaxDuration + ? _value.videoMaxDuration + : videoMaxDuration // ignore: cast_nullable_to_non_nullable + as Duration, + isVideoPlaying: null == isVideoPlaying + ? _value.isVideoPlaying + : isVideoPlaying // ignore: cast_nullable_to_non_nullable + as bool, + uploadProcess: null == uploadProcess + ? _value.uploadProcess + : uploadProcess // ignore: cast_nullable_to_non_nullable + as List, + downloadProcess: null == downloadProcess + ? _value.downloadProcess + : downloadProcess // ignore: cast_nullable_to_non_nullable + as List, + deleteProcess: null == deleteProcess + ? _value.deleteProcess + : deleteProcess // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$MediaPreviewStateImplCopyWith<$Res> + implements $MediaPreviewStateCopyWith<$Res> { + factory _$$MediaPreviewStateImplCopyWith(_$MediaPreviewStateImpl value, + $Res Function(_$MediaPreviewStateImpl) then) = + __$$MediaPreviewStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {Object? error, + List medias, + int currentIndex, + bool showActions, + bool isVideoInitialized, + bool isVideoBuffering, + Duration videoPosition, + Duration videoMaxDuration, + bool isVideoPlaying, + List uploadProcess, + List downloadProcess, + List deleteProcess}); +} + +/// @nodoc +class __$$MediaPreviewStateImplCopyWithImpl<$Res> + extends _$MediaPreviewStateCopyWithImpl<$Res, _$MediaPreviewStateImpl> + implements _$$MediaPreviewStateImplCopyWith<$Res> { + __$$MediaPreviewStateImplCopyWithImpl(_$MediaPreviewStateImpl _value, + $Res Function(_$MediaPreviewStateImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? error = freezed, + Object? medias = null, + Object? currentIndex = null, + Object? showActions = null, + Object? isVideoInitialized = null, + Object? isVideoBuffering = null, + Object? videoPosition = null, + Object? videoMaxDuration = null, + Object? isVideoPlaying = null, + Object? uploadProcess = null, + Object? downloadProcess = null, + Object? deleteProcess = null, + }) { + return _then(_$MediaPreviewStateImpl( + error: freezed == error ? _value.error : error, + medias: null == medias + ? _value._medias + : medias // ignore: cast_nullable_to_non_nullable + as List, + currentIndex: null == currentIndex + ? _value.currentIndex + : currentIndex // ignore: cast_nullable_to_non_nullable + as int, + showActions: null == showActions + ? _value.showActions + : showActions // ignore: cast_nullable_to_non_nullable + as bool, + isVideoInitialized: null == isVideoInitialized + ? _value.isVideoInitialized + : isVideoInitialized // ignore: cast_nullable_to_non_nullable + as bool, + isVideoBuffering: null == isVideoBuffering + ? _value.isVideoBuffering + : isVideoBuffering // ignore: cast_nullable_to_non_nullable + as bool, + videoPosition: null == videoPosition + ? _value.videoPosition + : videoPosition // ignore: cast_nullable_to_non_nullable + as Duration, + videoMaxDuration: null == videoMaxDuration + ? _value.videoMaxDuration + : videoMaxDuration // ignore: cast_nullable_to_non_nullable + as Duration, + isVideoPlaying: null == isVideoPlaying + ? _value.isVideoPlaying + : isVideoPlaying // ignore: cast_nullable_to_non_nullable + as bool, + uploadProcess: null == uploadProcess + ? _value._uploadProcess + : uploadProcess // ignore: cast_nullable_to_non_nullable + as List, + downloadProcess: null == downloadProcess + ? _value._downloadProcess + : downloadProcess // ignore: cast_nullable_to_non_nullable + as List, + deleteProcess: null == deleteProcess + ? _value._deleteProcess + : deleteProcess // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc + +class _$MediaPreviewStateImpl implements _MediaPreviewState { + const _$MediaPreviewStateImpl( + {this.error, + final List medias = const [], + this.currentIndex = 0, + this.showActions = true, + this.isVideoInitialized = false, + this.isVideoBuffering = false, + this.videoPosition = Duration.zero, + this.videoMaxDuration = Duration.zero, + this.isVideoPlaying = false, + final List uploadProcess = const [], + final List downloadProcess = const [], + final List deleteProcess = const []}) + : _medias = medias, + _uploadProcess = uploadProcess, + _downloadProcess = downloadProcess, + _deleteProcess = deleteProcess; + + @override + final Object? error; + final List _medias; + @override + @JsonKey() + List get medias { + if (_medias is EqualUnmodifiableListView) return _medias; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_medias); + } + + @override + @JsonKey() + final int currentIndex; + @override + @JsonKey() + final bool showActions; + @override + @JsonKey() + final bool isVideoInitialized; + @override + @JsonKey() + final bool isVideoBuffering; + @override + @JsonKey() + final Duration videoPosition; + @override + @JsonKey() + final Duration videoMaxDuration; + @override + @JsonKey() + final bool isVideoPlaying; + final List _uploadProcess; + @override + @JsonKey() + List get uploadProcess { + if (_uploadProcess is EqualUnmodifiableListView) return _uploadProcess; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_uploadProcess); + } + + final List _downloadProcess; + @override + @JsonKey() + List get downloadProcess { + if (_downloadProcess is EqualUnmodifiableListView) return _downloadProcess; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_downloadProcess); + } + + final List _deleteProcess; + @override + @JsonKey() + List get deleteProcess { + if (_deleteProcess is EqualUnmodifiableListView) return _deleteProcess; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_deleteProcess); + } + + @override + String toString() { + return 'MediaPreviewState(error: $error, medias: $medias, currentIndex: $currentIndex, showActions: $showActions, isVideoInitialized: $isVideoInitialized, isVideoBuffering: $isVideoBuffering, videoPosition: $videoPosition, videoMaxDuration: $videoMaxDuration, isVideoPlaying: $isVideoPlaying, uploadProcess: $uploadProcess, downloadProcess: $downloadProcess, deleteProcess: $deleteProcess)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$MediaPreviewStateImpl && + const DeepCollectionEquality().equals(other.error, error) && + const DeepCollectionEquality().equals(other._medias, _medias) && + (identical(other.currentIndex, currentIndex) || + other.currentIndex == currentIndex) && + (identical(other.showActions, showActions) || + other.showActions == showActions) && + (identical(other.isVideoInitialized, isVideoInitialized) || + other.isVideoInitialized == isVideoInitialized) && + (identical(other.isVideoBuffering, isVideoBuffering) || + other.isVideoBuffering == isVideoBuffering) && + (identical(other.videoPosition, videoPosition) || + other.videoPosition == videoPosition) && + (identical(other.videoMaxDuration, videoMaxDuration) || + other.videoMaxDuration == videoMaxDuration) && + (identical(other.isVideoPlaying, isVideoPlaying) || + other.isVideoPlaying == isVideoPlaying) && + const DeepCollectionEquality() + .equals(other._uploadProcess, _uploadProcess) && + const DeepCollectionEquality() + .equals(other._downloadProcess, _downloadProcess) && + const DeepCollectionEquality() + .equals(other._deleteProcess, _deleteProcess)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(error), + const DeepCollectionEquality().hash(_medias), + currentIndex, + showActions, + isVideoInitialized, + isVideoBuffering, + videoPosition, + videoMaxDuration, + isVideoPlaying, + const DeepCollectionEquality().hash(_uploadProcess), + const DeepCollectionEquality().hash(_downloadProcess), + const DeepCollectionEquality().hash(_deleteProcess)); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$MediaPreviewStateImplCopyWith<_$MediaPreviewStateImpl> get copyWith => + __$$MediaPreviewStateImplCopyWithImpl<_$MediaPreviewStateImpl>( + this, _$identity); +} + +abstract class _MediaPreviewState implements MediaPreviewState { + const factory _MediaPreviewState( + {final Object? error, + final List medias, + final int currentIndex, + final bool showActions, + final bool isVideoInitialized, + final bool isVideoBuffering, + final Duration videoPosition, + final Duration videoMaxDuration, + final bool isVideoPlaying, + final List uploadProcess, + final List downloadProcess, + final List deleteProcess}) = _$MediaPreviewStateImpl; + + @override + Object? get error; + @override + List get medias; + @override + int get currentIndex; + @override + bool get showActions; + @override + bool get isVideoInitialized; + @override + bool get isVideoBuffering; + @override + Duration get videoPosition; + @override + Duration get videoMaxDuration; + @override + bool get isVideoPlaying; + @override + List get uploadProcess; + @override + List get downloadProcess; + @override + List get deleteProcess; + @override + @JsonKey(ignore: true) + _$$MediaPreviewStateImplCopyWith<_$MediaPreviewStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/app/lib/ui/flow/media_preview/video_preview_screen.dart b/app/lib/ui/flow/media_preview/video_preview_screen.dart deleted file mode 100644 index fa98229..0000000 --- a/app/lib/ui/flow/media_preview/video_preview_screen.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:cloud_gallery/components/app_page.dart'; -import 'package:flutter/cupertino.dart'; - -class VideoPreviewScreen extends StatefulWidget { - const VideoPreviewScreen({super.key}); - - @override - State createState() => _VideoPreviewScreenState(); -} - -class _VideoPreviewScreenState extends State { - @override - Widget build(BuildContext context) { - return const AppPage( - title: '', - body: Center( - child: Text('Video Preview Screen'), - ), - ); - } -} diff --git a/app/lib/ui/flow/media_transfer/components/transfer_item.dart b/app/lib/ui/flow/media_transfer/components/transfer_item.dart new file mode 100644 index 0000000..aff7a6a --- /dev/null +++ b/app/lib/ui/flow/media_transfer/components/transfer_item.dart @@ -0,0 +1,195 @@ +import 'dart:typed_data'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; +import 'package:cloud_gallery/domain/formatter/byte_formatter.dart'; +import 'package:data/models/app_process/app_process.dart'; +import 'package:data/models/media/media.dart'; +import 'package:data/models/media/media_extension.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:style/buttons/action_button.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/indicators/circular_progress_indicator.dart'; +import 'package:style/text/app_text_style.dart'; + +class ProcessItem extends StatefulWidget { + final AppProcess process; + final void Function() onCancelTap; + + const ProcessItem( + {super.key, required this.process, required this.onCancelTap}); + + @override + State createState() => _ProcessItemState(); +} + +class _ProcessItemState extends State { + + @override + Widget build(BuildContext context) { + return Row( + children: [ + _buildThumbnailView(context: context), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.process.media.name != null && + widget.process.media.name!.trim().isNotEmpty + ? widget.process.media.name! + : widget.process.media.path, + style: AppTextStyles.body.copyWith( + color: context.colorScheme.textPrimary, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + const SizedBox(height: 8), + if (widget.process.status.isWaiting) + Text( + context.l10n.waiting_in_queue_text, + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textSecondary, + ), + ), + if (widget.process.progress != null && widget.process.status.isProcessing) ...[ + LinearProgressIndicator( + value: widget.process.progress?.percentageInPoint, + backgroundColor: context.colorScheme.outline, + borderRadius: BorderRadius.circular(4), + valueColor: AlwaysStoppedAnimation( + context.colorScheme.primary), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${widget.process.progress?.chunk.formatBytes} ${widget.process.progress?.percentage.toStringAsFixed(0)}%', + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textSecondary, + ), + ), + Text( + '${widget.process.progress?.total.formatBytes}', + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textSecondary, + ), + ), + ], + ) + ] + ], + ), + ), + if (widget.process.status.isWaiting) + ActionButton( + onPressed: widget.onCancelTap, + icon: const Icon(CupertinoIcons.xmark), + ) + ], + ); + } + + Widget _buildThumbnailView({required BuildContext context}) { + if (widget.process.media.sources.contains(AppMediaSource.local)) { + return FutureByteLoader( + bytes: widget.process.media.loadThumbnail(size: const Size(100, 100)), + builder: (context, bytes) => Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: context.colorScheme.containerHighOnSurface, + borderRadius: BorderRadius.circular(4), + image: DecorationImage( + image: MemoryImage(bytes!), + fit: BoxFit.cover, + ), + ), + ), + errorWidget: (context, error) => _buildErrorWidget(context), + placeholder: (context) => _buildPlaceholder(context: context), + ); + } else { + return CachedNetworkImage( + imageUrl: widget.process.media.thumbnailLink!, + width: 80, + height: 80, + fit: BoxFit.cover, + errorWidget: (context, url, error) => _buildErrorWidget(context), + progressIndicatorBuilder: (context, url, progress) => + _buildPlaceholder( + context: context, + value: progress.progress, + )); + } + } + + Widget _buildPlaceholder( + {required BuildContext context, + double? value, + bool showLoader = true}) => + Container( + color: context.colorScheme.containerHighOnSurface, + alignment: Alignment.center, + child: showLoader ? AppCircularProgressIndicator(value: value) : null, + ); + + Widget _buildErrorWidget(BuildContext context) => Container( + color: context.colorScheme.containerNormalOnSurface, + alignment: Alignment.center, + child: Icon( + CupertinoIcons.exclamationmark_circle, + color: context.colorScheme.onPrimary, + size: 32, + ), + ); +} + +class FutureByteLoader extends StatefulWidget { + final Future bytes; + final Widget Function(BuildContext context, Uint8List? bytes) builder; + final Widget Function(BuildContext context) placeholder; + final Widget Function(BuildContext context, Object? error) errorWidget; + + const FutureByteLoader( + {super.key, + required this.bytes, + required this.builder, + required this.placeholder, + required this.errorWidget}); + + @override + State createState() => _FutureByteLoaderState(); +} + +class _FutureByteLoaderState extends State { + late Future bytes; + + @override + void initState() { + bytes = widget.bytes; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: bytes, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done && + snapshot.hasData) { + return widget.builder(context, snapshot.data); + } else if (snapshot.hasError) { + return widget.errorWidget(context, snapshot.error); + } else { + return widget.placeholder(context); + } + }, + ); + } +} diff --git a/app/lib/ui/flow/media_transfer/media_transfer_screen.dart b/app/lib/ui/flow/media_transfer/media_transfer_screen.dart new file mode 100644 index 0000000..77a9597 --- /dev/null +++ b/app/lib/ui/flow/media_transfer/media_transfer_screen.dart @@ -0,0 +1,149 @@ +import 'package:cloud_gallery/components/error_view.dart'; +import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; +import 'package:cloud_gallery/ui/flow/media_transfer/components/transfer_item.dart'; +import 'package:cloud_gallery/ui/flow/media_transfer/media_transfer_view_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:style/buttons/segmented_button.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/text/app_text_style.dart'; +import '../../../components/app_page.dart'; + +class MediaTransferScreen extends ConsumerStatefulWidget { + const MediaTransferScreen({super.key}); + + @override + ConsumerState createState() => _MediaTransferScreenState(); +} + +class _MediaTransferScreenState extends ConsumerState { + late MediaTransferStateNotifier notifier; + late PageController pageController; + + @override + void initState() { + notifier = ref.read(mediaTransferStateNotifierProvider.notifier); + pageController = PageController(); + super.initState(); + } + + @override + void dispose() { + pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final page = ref.watch( + mediaTransferStateNotifierProvider.select((value) => value.page)); + return AppPage( + title: context.l10n.transfer_screen_title, + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SizedBox( + width: context.mediaQuerySize.width, + child: AppSegmentedButton( + segmentTextStyle: AppTextStyles.body, + tapTargetSize: MaterialTapTargetSize.padded, + segments: [ + AppButtonSegment( + value: 0, label: context.l10n.common_upload), + AppButtonSegment( + value: 1, label: context.l10n.common_download), + ], + selected: page, + onSelectionChanged: (value) { + notifier.onPageChange(value); + pageController.animateToPage( + value, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + ), + ), + ), + Divider(color: context.colorScheme.outline, height: 0.8), + Expanded( + child: PageView( + controller: pageController, + onPageChanged: (value) { + notifier.onPageChange(value); + }, + children: [ + _uploadList(), + _downloadList(), + ], + )), + ], + ), + ), + ); + } + + Widget _uploadList() { + return Consumer(builder: (context, ref, child) { + final upload = ref.watch( + mediaTransferStateNotifierProvider.select((value) => value.upload)); + + if (upload.isEmpty) { + return ErrorView( + title: context.l10n.empty_upload_title, + message: context.l10n.empty_upload_message, + icon: Icon(Icons.cloud_upload_outlined, + size: 100, color: context.colorScheme.containerNormal), + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: upload.length, + separatorBuilder: (context, index) => const SizedBox( + height: 8, + ), + itemBuilder: (context, index) => ProcessItem( + key: ValueKey(upload[index].id), + process: upload[index], + onCancelTap: () { + notifier.onTerminateProcess(upload[index].id, true); + }), + ); + }); + } + + Widget _downloadList() { + return Consumer(builder: (context, ref, child) { + final download = ref.watch( + mediaTransferStateNotifierProvider.select((value) => value.download), + ); + + if (download.isEmpty) { + return ErrorView( + title: context.l10n.empty_download_title, + message: context.l10n.empty_download_message, + icon: Icon(Icons.cloud_download_outlined, + size: 100, color: context.colorScheme.containerNormal), + ); + } + + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: download.length, + separatorBuilder: (context, index) => const SizedBox( + height: 8, + ), + itemBuilder: (context, index) => ProcessItem( + key: ValueKey(download[index].id), + process: download[index], + onCancelTap: () { + notifier.onTerminateProcess(download[index].id, false); + }, + ), + ); + }); + } +} diff --git a/app/lib/ui/flow/media_transfer/media_transfer_view_model.dart b/app/lib/ui/flow/media_transfer/media_transfer_view_model.dart new file mode 100644 index 0000000..c8885c1 --- /dev/null +++ b/app/lib/ui/flow/media_transfer/media_transfer_view_model.dart @@ -0,0 +1,55 @@ +import 'package:data/models/app_process/app_process.dart'; +import 'package:data/repositories/google_drive_process_repo.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'media_transfer_view_model.freezed.dart'; + +final mediaTransferStateNotifierProvider = StateNotifierProvider.autoDispose< + MediaTransferStateNotifier, MediaTransferState>( + (ref) => MediaTransferStateNotifier(ref.read(googleDriveProcessRepoProvider)), +); + +class MediaTransferStateNotifier extends StateNotifier { + final GoogleDriveProcessRepo _googleDriveProcessRepo; + + MediaTransferStateNotifier(this._googleDriveProcessRepo) + : super(const MediaTransferState()) { + _googleDriveProcessRepo.addListener(_listenGoogleDriveProcess); + } + + void _listenGoogleDriveProcess() { + state = state.copyWith( + download: _googleDriveProcessRepo.downloadQueue.toList(), + upload: _googleDriveProcessRepo.uploadQueue.toList(), + ); + } + + void onPageChange(int value) { + state = state.copyWith(page: value); + } + + void onTerminateProcess(String id, bool isUploading) { + if (isUploading) { + _googleDriveProcessRepo.terminateUploadProcess(id); + } else { + _googleDriveProcessRepo.terminateDownloadProcess(id); + } + } + + @override + void dispose() { + _googleDriveProcessRepo.removeListener(_listenGoogleDriveProcess); + super.dispose(); + } +} + +@freezed +class MediaTransferState with _$MediaTransferState { + const factory MediaTransferState({ + Object? error, + @Default([]) List upload, + @Default([]) List download, + @Default(0) int page, + }) = _MediaTransferState; +} diff --git a/app/lib/ui/flow/media_transfer/media_transfer_view_model.freezed.dart b/app/lib/ui/flow/media_transfer/media_transfer_view_model.freezed.dart new file mode 100644 index 0000000..39a57d2 --- /dev/null +++ b/app/lib/ui/flow/media_transfer/media_transfer_view_model.freezed.dart @@ -0,0 +1,213 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'media_transfer_view_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$MediaTransferState { + Object? get error => throw _privateConstructorUsedError; + List get upload => throw _privateConstructorUsedError; + List get download => throw _privateConstructorUsedError; + int get page => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $MediaTransferStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $MediaTransferStateCopyWith<$Res> { + factory $MediaTransferStateCopyWith( + MediaTransferState value, $Res Function(MediaTransferState) then) = + _$MediaTransferStateCopyWithImpl<$Res, MediaTransferState>; + @useResult + $Res call( + {Object? error, + List upload, + List download, + int page}); +} + +/// @nodoc +class _$MediaTransferStateCopyWithImpl<$Res, $Val extends MediaTransferState> + implements $MediaTransferStateCopyWith<$Res> { + _$MediaTransferStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? error = freezed, + Object? upload = null, + Object? download = null, + Object? page = null, + }) { + return _then(_value.copyWith( + error: freezed == error ? _value.error : error, + upload: null == upload + ? _value.upload + : upload // ignore: cast_nullable_to_non_nullable + as List, + download: null == download + ? _value.download + : download // ignore: cast_nullable_to_non_nullable + as List, + page: null == page + ? _value.page + : page // ignore: cast_nullable_to_non_nullable + as int, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$MediaTransferStateImplCopyWith<$Res> + implements $MediaTransferStateCopyWith<$Res> { + factory _$$MediaTransferStateImplCopyWith(_$MediaTransferStateImpl value, + $Res Function(_$MediaTransferStateImpl) then) = + __$$MediaTransferStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {Object? error, + List upload, + List download, + int page}); +} + +/// @nodoc +class __$$MediaTransferStateImplCopyWithImpl<$Res> + extends _$MediaTransferStateCopyWithImpl<$Res, _$MediaTransferStateImpl> + implements _$$MediaTransferStateImplCopyWith<$Res> { + __$$MediaTransferStateImplCopyWithImpl(_$MediaTransferStateImpl _value, + $Res Function(_$MediaTransferStateImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? error = freezed, + Object? upload = null, + Object? download = null, + Object? page = null, + }) { + return _then(_$MediaTransferStateImpl( + error: freezed == error ? _value.error : error, + upload: null == upload + ? _value._upload + : upload // ignore: cast_nullable_to_non_nullable + as List, + download: null == download + ? _value._download + : download // ignore: cast_nullable_to_non_nullable + as List, + page: null == page + ? _value.page + : page // ignore: cast_nullable_to_non_nullable + as int, + )); + } +} + +/// @nodoc + +class _$MediaTransferStateImpl implements _MediaTransferState { + const _$MediaTransferStateImpl( + {this.error, + final List upload = const [], + final List download = const [], + this.page = 0}) + : _upload = upload, + _download = download; + + @override + final Object? error; + final List _upload; + @override + @JsonKey() + List get upload { + if (_upload is EqualUnmodifiableListView) return _upload; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_upload); + } + + final List _download; + @override + @JsonKey() + List get download { + if (_download is EqualUnmodifiableListView) return _download; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_download); + } + + @override + @JsonKey() + final int page; + + @override + String toString() { + return 'MediaTransferState(error: $error, upload: $upload, download: $download, page: $page)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$MediaTransferStateImpl && + const DeepCollectionEquality().equals(other.error, error) && + const DeepCollectionEquality().equals(other._upload, _upload) && + const DeepCollectionEquality().equals(other._download, _download) && + (identical(other.page, page) || other.page == page)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(error), + const DeepCollectionEquality().hash(_upload), + const DeepCollectionEquality().hash(_download), + page); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$MediaTransferStateImplCopyWith<_$MediaTransferStateImpl> get copyWith => + __$$MediaTransferStateImplCopyWithImpl<_$MediaTransferStateImpl>( + this, _$identity); +} + +abstract class _MediaTransferState implements MediaTransferState { + const factory _MediaTransferState( + {final Object? error, + final List upload, + final List download, + final int page}) = _$MediaTransferStateImpl; + + @override + Object? get error; + @override + List get upload; + @override + List get download; + @override + int get page; + @override + @JsonKey(ignore: true) + _$$MediaTransferStateImplCopyWith<_$MediaTransferStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/app/lib/ui/navigation/app_router.dart b/app/lib/ui/navigation/app_router.dart index dcc76b7..4fc3f77 100644 --- a/app/lib/ui/navigation/app_router.dart +++ b/app/lib/ui/navigation/app_router.dart @@ -1,11 +1,11 @@ import 'package:cloud_gallery/ui/flow/accounts/accounts_screen.dart'; -import 'package:cloud_gallery/ui/flow/media_preview/image_preview/image_preview_screen.dart'; -import 'package:cloud_gallery/ui/flow/media_preview/video_preview_screen.dart'; +import 'package:cloud_gallery/ui/flow/media_transfer/media_transfer_screen.dart'; import 'package:cloud_gallery/ui/flow/onboard/onboard_screen.dart'; import 'package:data/models/media/media.dart'; import 'package:flutter/cupertino.dart'; import 'package:go_router/go_router.dart'; import '../flow/home/home_screen.dart'; +import '../flow/media_preview/media_preview_screen.dart'; import 'app_route.dart'; class AppRouter { @@ -24,23 +24,28 @@ class AppRouter { builder: (context) => const AccountsScreen(), ); - static AppRoute imagePreview({required AppMedia media}) => AppRoute( - AppRoutePath.imagePreview, - builder: (context) => ImagePreviewScreen(media: media), - ); + static AppRoute get mediaTransfer => AppRoute( + AppRoutePath.transfer, + builder: (context) => const MediaTransferScreen(), + ); - static AppRoute videoPreview({required String path, required bool isLocal}) => + static AppRoute preview( + {required List medias, required String startFrom}) => AppRoute( - AppRoutePath.videoPreview, - builder: (context) => const VideoPreviewScreen(), + AppRoutePath.preview, + builder: (context) => MediaPreview( + medias: medias, + startFrom: startFrom, + ), ); static final routes = [ home.goRoute, onBoard.goRoute, accounts.goRoute, + mediaTransfer.goRoute, GoRoute( - path: AppRoutePath.imagePreview, + path: AppRoutePath.preview, pageBuilder: (context, state) { return CustomTransitionPage( opaque: false, @@ -52,10 +57,6 @@ class AppRouter { ); }, ), - GoRoute( - path: AppRoutePath.videoPreview, - builder: (context, state) => state.widget(context), - ), ]; } @@ -63,6 +64,6 @@ class AppRoutePath { static const home = '/'; static const onBoard = '/on-board'; static const accounts = '/accounts'; - static const imagePreview = '/image_preview'; - static const videoPreview = '/video_preview'; + static const preview = '/preview'; + static const transfer = '/transfer'; } diff --git a/data/.flutter-plugins b/data/.flutter-plugins index 5cde211..58d16bc 100644 --- a/data/.flutter-plugins +++ b/data/.flutter-plugins @@ -4,6 +4,9 @@ google_sign_in_android=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sig google_sign_in_ios=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_ios-5.7.4/ google_sign_in_web=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_web-0.12.3+3/ package_info_plus=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-5.0.1/ +path_provider=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider-2.1.2/ +path_provider_android=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_android-2.2.2/ +path_provider_foundation=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_foundation-2.3.2/ path_provider_linux=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/ path_provider_windows=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_windows-2.2.1/ photo_manager=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/photo_manager-3.0.0/ diff --git a/data/.flutter-plugins-dependencies b/data/.flutter-plugins-dependencies index 1ebfe74..52c68b3 100644 --- a/data/.flutter-plugins-dependencies +++ b/data/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"google_sign_in_ios","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_ios-5.7.4/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-5.0.1/","native_build":true,"dependencies":[]},{"name":"photo_manager","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/photo_manager-3.0.0/","native_build":true,"dependencies":[]},{"name":"shared_preferences_foundation","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.3.5/","shared_darwin_source":true,"native_build":true,"dependencies":[]}],"android":[{"name":"google_sign_in_android","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_android-6.1.21/","native_build":true,"dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-5.0.1/","native_build":true,"dependencies":[]},{"name":"photo_manager","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/photo_manager-3.0.0/","native_build":true,"dependencies":[]},{"name":"shared_preferences_android","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_android-2.2.1/","native_build":true,"dependencies":[]}],"macos":[{"name":"google_sign_in_ios","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_ios-5.7.4/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-5.0.1/","native_build":true,"dependencies":[]},{"name":"photo_manager","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/photo_manager-3.0.0/","native_build":true,"dependencies":[]},{"name":"shared_preferences_foundation","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.3.5/","shared_darwin_source":true,"native_build":true,"dependencies":[]}],"linux":[{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-5.0.1/","native_build":false,"dependencies":[]},{"name":"path_provider_linux","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[]},{"name":"shared_preferences_linux","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.3.2/","native_build":false,"dependencies":["path_provider_linux"]}],"windows":[{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-5.0.1/","native_build":false,"dependencies":[]},{"name":"path_provider_windows","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_windows-2.2.1/","native_build":false,"dependencies":[]},{"name":"shared_preferences_windows","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.3.2/","native_build":false,"dependencies":["path_provider_windows"]}],"web":[{"name":"google_sign_in_web","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_web-0.12.3+3/","dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-5.0.1/","dependencies":[]},{"name":"shared_preferences_web","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_web-2.2.2/","dependencies":[]}]},"dependencyGraph":[{"name":"google_sign_in","dependencies":["google_sign_in_android","google_sign_in_ios","google_sign_in_web"]},{"name":"google_sign_in_android","dependencies":[]},{"name":"google_sign_in_ios","dependencies":[]},{"name":"google_sign_in_web","dependencies":[]},{"name":"package_info_plus","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"photo_manager","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]}],"date_created":"2024-03-26 16:38:34.855593","version":"3.19.3"} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"google_sign_in_ios","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_ios-5.7.4/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-5.0.1/","native_build":true,"dependencies":[]},{"name":"path_provider_foundation","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_foundation-2.3.2/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"photo_manager","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/photo_manager-3.0.0/","native_build":true,"dependencies":[]},{"name":"shared_preferences_foundation","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.3.5/","shared_darwin_source":true,"native_build":true,"dependencies":[]}],"android":[{"name":"google_sign_in_android","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_android-6.1.21/","native_build":true,"dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-5.0.1/","native_build":true,"dependencies":[]},{"name":"path_provider_android","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_android-2.2.2/","native_build":true,"dependencies":[]},{"name":"photo_manager","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/photo_manager-3.0.0/","native_build":true,"dependencies":[]},{"name":"shared_preferences_android","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_android-2.2.1/","native_build":true,"dependencies":[]}],"macos":[{"name":"google_sign_in_ios","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_ios-5.7.4/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-5.0.1/","native_build":true,"dependencies":[]},{"name":"path_provider_foundation","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_foundation-2.3.2/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"photo_manager","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/photo_manager-3.0.0/","native_build":true,"dependencies":[]},{"name":"shared_preferences_foundation","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.3.5/","shared_darwin_source":true,"native_build":true,"dependencies":[]}],"linux":[{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-5.0.1/","native_build":false,"dependencies":[]},{"name":"path_provider_linux","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[]},{"name":"shared_preferences_linux","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.3.2/","native_build":false,"dependencies":["path_provider_linux"]}],"windows":[{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-5.0.1/","native_build":false,"dependencies":[]},{"name":"path_provider_windows","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_windows-2.2.1/","native_build":false,"dependencies":[]},{"name":"shared_preferences_windows","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.3.2/","native_build":false,"dependencies":["path_provider_windows"]}],"web":[{"name":"google_sign_in_web","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_web-0.12.3+3/","dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-5.0.1/","dependencies":[]},{"name":"shared_preferences_web","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_web-2.2.2/","dependencies":[]}]},"dependencyGraph":[{"name":"google_sign_in","dependencies":["google_sign_in_android","google_sign_in_ios","google_sign_in_web"]},{"name":"google_sign_in_android","dependencies":[]},{"name":"google_sign_in_ios","dependencies":[]},{"name":"google_sign_in_web","dependencies":[]},{"name":"package_info_plus","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"photo_manager","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]}],"date_created":"2024-04-04 11:45:07.647168","version":"3.19.3"} \ No newline at end of file diff --git a/data/lib/extensions/iterable_extension.dart b/data/lib/extensions/iterable_extension.dart new file mode 100644 index 0000000..9b91d16 --- /dev/null +++ b/data/lib/extensions/iterable_extension.dart @@ -0,0 +1,11 @@ +extension ListExtension on List { + void updateWhere( + {required bool Function(E element) where, + required E Function(E element) update}) { + for (var i = 0; i < length; i++) { + if (where(elementAt(i))) { + this[i] = update(elementAt(i)); + } + } + } +} \ No newline at end of file diff --git a/data/lib/models/app_process/app_process.dart b/data/lib/models/app_process/app_process.dart new file mode 100644 index 0000000..8ae5c4f --- /dev/null +++ b/data/lib/models/app_process/app_process.dart @@ -0,0 +1,51 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../media/media.dart'; + +part 'app_process.freezed.dart'; + +enum AppProcessStatus { + waiting, + uploading, + deleting, + downloading, + success, + failed; + + bool get isProcessing => + this == AppProcessStatus.uploading || + this == AppProcessStatus.deleting || + this == AppProcessStatus.downloading; + + bool get isWaiting => this == AppProcessStatus.waiting; + + bool get isSuccess => this == AppProcessStatus.success; + + bool get isFailed => this == AppProcessStatus.failed; +} + +@freezed +class AppProcess with _$AppProcess { + const factory AppProcess({ + required String id, + required AppMedia media, + required AppProcessStatus status, + Object? response, + @Default(null) + AppProcessProgress? progress, + }) = _AppProcess; +} + +@freezed +class AppProcessProgress with _$AppProcessProgress { + const factory AppProcessProgress({required int total, required int chunk}) = + _AppProcessProgress; +} + +extension AppProcessProgressExtension on AppProcessProgress { + /// Get the percentage of the progress 0.0 - 1.0 + double get percentageInPoint => total == 0 ? 0 : chunk / total; + + /// Get the percentage of the progress 0 - 100 + double get percentage => percentageInPoint*100; +} \ No newline at end of file diff --git a/data/lib/models/app_process/app_process.freezed.dart b/data/lib/models/app_process/app_process.freezed.dart new file mode 100644 index 0000000..d7055ed --- /dev/null +++ b/data/lib/models/app_process/app_process.freezed.dart @@ -0,0 +1,377 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'app_process.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$AppProcess { + String get id => throw _privateConstructorUsedError; + AppMedia get media => throw _privateConstructorUsedError; + AppProcessStatus get status => throw _privateConstructorUsedError; + Object? get response => throw _privateConstructorUsedError; + AppProcessProgress? get progress => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $AppProcessCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AppProcessCopyWith<$Res> { + factory $AppProcessCopyWith( + AppProcess value, $Res Function(AppProcess) then) = + _$AppProcessCopyWithImpl<$Res, AppProcess>; + @useResult + $Res call( + {String id, + AppMedia media, + AppProcessStatus status, + Object? response, + AppProcessProgress? progress}); + + $AppMediaCopyWith<$Res> get media; + $AppProcessProgressCopyWith<$Res>? get progress; +} + +/// @nodoc +class _$AppProcessCopyWithImpl<$Res, $Val extends AppProcess> + implements $AppProcessCopyWith<$Res> { + _$AppProcessCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? media = null, + Object? status = null, + Object? response = freezed, + Object? progress = freezed, + }) { + return _then(_value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + media: null == media + ? _value.media + : media // ignore: cast_nullable_to_non_nullable + as AppMedia, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as AppProcessStatus, + response: freezed == response ? _value.response : response, + progress: freezed == progress + ? _value.progress + : progress // ignore: cast_nullable_to_non_nullable + as AppProcessProgress?, + ) as $Val); + } + + @override + @pragma('vm:prefer-inline') + $AppMediaCopyWith<$Res> get media { + return $AppMediaCopyWith<$Res>(_value.media, (value) { + return _then(_value.copyWith(media: value) as $Val); + }); + } + + @override + @pragma('vm:prefer-inline') + $AppProcessProgressCopyWith<$Res>? get progress { + if (_value.progress == null) { + return null; + } + + return $AppProcessProgressCopyWith<$Res>(_value.progress!, (value) { + return _then(_value.copyWith(progress: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$AppProcessImplCopyWith<$Res> + implements $AppProcessCopyWith<$Res> { + factory _$$AppProcessImplCopyWith( + _$AppProcessImpl value, $Res Function(_$AppProcessImpl) then) = + __$$AppProcessImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String id, + AppMedia media, + AppProcessStatus status, + Object? response, + AppProcessProgress? progress}); + + @override + $AppMediaCopyWith<$Res> get media; + @override + $AppProcessProgressCopyWith<$Res>? get progress; +} + +/// @nodoc +class __$$AppProcessImplCopyWithImpl<$Res> + extends _$AppProcessCopyWithImpl<$Res, _$AppProcessImpl> + implements _$$AppProcessImplCopyWith<$Res> { + __$$AppProcessImplCopyWithImpl( + _$AppProcessImpl _value, $Res Function(_$AppProcessImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? media = null, + Object? status = null, + Object? response = freezed, + Object? progress = freezed, + }) { + return _then(_$AppProcessImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + media: null == media + ? _value.media + : media // ignore: cast_nullable_to_non_nullable + as AppMedia, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as AppProcessStatus, + response: freezed == response ? _value.response : response, + progress: freezed == progress + ? _value.progress + : progress // ignore: cast_nullable_to_non_nullable + as AppProcessProgress?, + )); + } +} + +/// @nodoc + +class _$AppProcessImpl implements _AppProcess { + const _$AppProcessImpl( + {required this.id, + required this.media, + required this.status, + this.response, + this.progress = null}); + + @override + final String id; + @override + final AppMedia media; + @override + final AppProcessStatus status; + @override + final Object? response; + @override + @JsonKey() + final AppProcessProgress? progress; + + @override + String toString() { + return 'AppProcess(id: $id, media: $media, status: $status, response: $response, progress: $progress)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AppProcessImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.media, media) || other.media == media) && + (identical(other.status, status) || other.status == status) && + const DeepCollectionEquality().equals(other.response, response) && + (identical(other.progress, progress) || + other.progress == progress)); + } + + @override + int get hashCode => Object.hash(runtimeType, id, media, status, + const DeepCollectionEquality().hash(response), progress); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$AppProcessImplCopyWith<_$AppProcessImpl> get copyWith => + __$$AppProcessImplCopyWithImpl<_$AppProcessImpl>(this, _$identity); +} + +abstract class _AppProcess implements AppProcess { + const factory _AppProcess( + {required final String id, + required final AppMedia media, + required final AppProcessStatus status, + final Object? response, + final AppProcessProgress? progress}) = _$AppProcessImpl; + + @override + String get id; + @override + AppMedia get media; + @override + AppProcessStatus get status; + @override + Object? get response; + @override + AppProcessProgress? get progress; + @override + @JsonKey(ignore: true) + _$$AppProcessImplCopyWith<_$AppProcessImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +mixin _$AppProcessProgress { + int get total => throw _privateConstructorUsedError; + int get chunk => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $AppProcessProgressCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AppProcessProgressCopyWith<$Res> { + factory $AppProcessProgressCopyWith( + AppProcessProgress value, $Res Function(AppProcessProgress) then) = + _$AppProcessProgressCopyWithImpl<$Res, AppProcessProgress>; + @useResult + $Res call({int total, int chunk}); +} + +/// @nodoc +class _$AppProcessProgressCopyWithImpl<$Res, $Val extends AppProcessProgress> + implements $AppProcessProgressCopyWith<$Res> { + _$AppProcessProgressCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? total = null, + Object? chunk = null, + }) { + return _then(_value.copyWith( + total: null == total + ? _value.total + : total // ignore: cast_nullable_to_non_nullable + as int, + chunk: null == chunk + ? _value.chunk + : chunk // ignore: cast_nullable_to_non_nullable + as int, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$AppProcessProgressImplCopyWith<$Res> + implements $AppProcessProgressCopyWith<$Res> { + factory _$$AppProcessProgressImplCopyWith(_$AppProcessProgressImpl value, + $Res Function(_$AppProcessProgressImpl) then) = + __$$AppProcessProgressImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({int total, int chunk}); +} + +/// @nodoc +class __$$AppProcessProgressImplCopyWithImpl<$Res> + extends _$AppProcessProgressCopyWithImpl<$Res, _$AppProcessProgressImpl> + implements _$$AppProcessProgressImplCopyWith<$Res> { + __$$AppProcessProgressImplCopyWithImpl(_$AppProcessProgressImpl _value, + $Res Function(_$AppProcessProgressImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? total = null, + Object? chunk = null, + }) { + return _then(_$AppProcessProgressImpl( + total: null == total + ? _value.total + : total // ignore: cast_nullable_to_non_nullable + as int, + chunk: null == chunk + ? _value.chunk + : chunk // ignore: cast_nullable_to_non_nullable + as int, + )); + } +} + +/// @nodoc + +class _$AppProcessProgressImpl implements _AppProcessProgress { + const _$AppProcessProgressImpl({required this.total, required this.chunk}); + + @override + final int total; + @override + final int chunk; + + @override + String toString() { + return 'AppProcessProgress(total: $total, chunk: $chunk)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AppProcessProgressImpl && + (identical(other.total, total) || other.total == total) && + (identical(other.chunk, chunk) || other.chunk == chunk)); + } + + @override + int get hashCode => Object.hash(runtimeType, total, chunk); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$AppProcessProgressImplCopyWith<_$AppProcessProgressImpl> get copyWith => + __$$AppProcessProgressImplCopyWithImpl<_$AppProcessProgressImpl>( + this, _$identity); +} + +abstract class _AppProcessProgress implements AppProcessProgress { + const factory _AppProcessProgress( + {required final int total, + required final int chunk}) = _$AppProcessProgressImpl; + + @override + int get total; + @override + int get chunk; + @override + @JsonKey(ignore: true) + _$$AppProcessProgressImplCopyWith<_$AppProcessProgressImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/data/lib/models/media/media.dart b/data/lib/models/media/media.dart index 48b7175..f7d629c 100644 --- a/data/lib/models/media/media.dart +++ b/data/lib/models/media/media.dart @@ -1,34 +1,13 @@ -import 'dart:io'; -import 'dart:typed_data'; -import 'dart:ui' show Size; +import 'dart:async'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:googleapis/drive/v3.dart' as drive show File; import 'package:photo_manager/photo_manager.dart' - show AssetEntity, ThumbnailFormat, ThumbnailSize; + show AssetEntity; part 'media.freezed.dart'; part 'media.g.dart'; -enum UploadStatus { uploading, waiting, none, failed, success } - -class UploadProgress { - final String mediaId; - final UploadStatus status; - - UploadProgress({required this.mediaId, required this.status}); - - @override - bool operator ==(Object other) { - return other is UploadProgress && - other.mediaId == mediaId && - other.status == status; - } - - @override - int get hashCode => mediaId.hashCode ^ status.hashCode; -} - enum AppMediaType { other, image, @@ -98,6 +77,7 @@ enum AppMediaSource { class AppMedia with _$AppMedia { const factory AppMedia({ required String id, + String? driveMediaRefId, String? name, required String path, String? thumbnailLink, @@ -147,6 +127,7 @@ class AppMedia with _$AppMedia { path: file.description ?? file.thumbnailLink ?? '', thumbnailLink: file.thumbnailLink, name: file.name, + driveMediaRefId: file.id, createdTime: file.createdTime, modifiedTime: file.modifiedTime, mimeType: file.mimeType, @@ -191,17 +172,3 @@ class AppMedia with _$AppMedia { } } -extension AppMediaExtension on AppMedia { - Future get isExist async { - return await File(path).exists(); - } - - Future thumbnailDataWithSize(Size size) async { - return await AssetEntity(id: id, typeInt: type.index, width: 0, height: 0) - .thumbnailDataWithSize( - ThumbnailSize(size.width.toInt(), size.height.toInt()), - format: ThumbnailFormat.jpeg, - quality: 70, - ); - } -} diff --git a/data/lib/models/media/media.freezed.dart b/data/lib/models/media/media.freezed.dart index a5ee01b..15b6e14 100644 --- a/data/lib/models/media/media.freezed.dart +++ b/data/lib/models/media/media.freezed.dart @@ -21,6 +21,7 @@ AppMedia _$AppMediaFromJson(Map json) { /// @nodoc mixin _$AppMedia { String get id => throw _privateConstructorUsedError; + String? get driveMediaRefId => throw _privateConstructorUsedError; String? get name => throw _privateConstructorUsedError; String get path => throw _privateConstructorUsedError; String? get thumbnailLink => throw _privateConstructorUsedError; @@ -50,6 +51,7 @@ abstract class $AppMediaCopyWith<$Res> { @useResult $Res call( {String id, + String? driveMediaRefId, String? name, String path, String? thumbnailLink, @@ -81,6 +83,7 @@ class _$AppMediaCopyWithImpl<$Res, $Val extends AppMedia> @override $Res call({ Object? id = null, + Object? driveMediaRefId = freezed, Object? name = freezed, Object? path = null, Object? thumbnailLink = freezed, @@ -102,6 +105,10 @@ class _$AppMediaCopyWithImpl<$Res, $Val extends AppMedia> ? _value.id : id // ignore: cast_nullable_to_non_nullable as String, + driveMediaRefId: freezed == driveMediaRefId + ? _value.driveMediaRefId + : driveMediaRefId // ignore: cast_nullable_to_non_nullable + as String?, name: freezed == name ? _value.name : name // ignore: cast_nullable_to_non_nullable @@ -176,6 +183,7 @@ abstract class _$$AppMediaImplCopyWith<$Res> @useResult $Res call( {String id, + String? driveMediaRefId, String? name, String path, String? thumbnailLink, @@ -205,6 +213,7 @@ class __$$AppMediaImplCopyWithImpl<$Res> @override $Res call({ Object? id = null, + Object? driveMediaRefId = freezed, Object? name = freezed, Object? path = null, Object? thumbnailLink = freezed, @@ -226,6 +235,10 @@ class __$$AppMediaImplCopyWithImpl<$Res> ? _value.id : id // ignore: cast_nullable_to_non_nullable as String, + driveMediaRefId: freezed == driveMediaRefId + ? _value.driveMediaRefId + : driveMediaRefId // ignore: cast_nullable_to_non_nullable + as String?, name: freezed == name ? _value.name : name // ignore: cast_nullable_to_non_nullable @@ -295,6 +308,7 @@ class __$$AppMediaImplCopyWithImpl<$Res> class _$AppMediaImpl implements _AppMedia { const _$AppMediaImpl( {required this.id, + this.driveMediaRefId, this.name, required this.path, this.thumbnailLink, @@ -318,6 +332,8 @@ class _$AppMediaImpl implements _AppMedia { @override final String id; @override + final String? driveMediaRefId; + @override final String? name; @override final String path; @@ -356,7 +372,7 @@ class _$AppMediaImpl implements _AppMedia { @override String toString() { - return 'AppMedia(id: $id, name: $name, path: $path, thumbnailLink: $thumbnailLink, displayHeight: $displayHeight, displayWidth: $displayWidth, type: $type, mimeType: $mimeType, createdTime: $createdTime, modifiedTime: $modifiedTime, orientation: $orientation, size: $size, videoDuration: $videoDuration, latitude: $latitude, longitude: $longitude, sources: $sources)'; + return 'AppMedia(id: $id, driveMediaRefId: $driveMediaRefId, name: $name, path: $path, thumbnailLink: $thumbnailLink, displayHeight: $displayHeight, displayWidth: $displayWidth, type: $type, mimeType: $mimeType, createdTime: $createdTime, modifiedTime: $modifiedTime, orientation: $orientation, size: $size, videoDuration: $videoDuration, latitude: $latitude, longitude: $longitude, sources: $sources)'; } @override @@ -365,6 +381,8 @@ class _$AppMediaImpl implements _AppMedia { (other.runtimeType == runtimeType && other is _$AppMediaImpl && (identical(other.id, id) || other.id == id) && + (identical(other.driveMediaRefId, driveMediaRefId) || + other.driveMediaRefId == driveMediaRefId) && (identical(other.name, name) || other.name == name) && (identical(other.path, path) || other.path == path) && (identical(other.thumbnailLink, thumbnailLink) || @@ -397,6 +415,7 @@ class _$AppMediaImpl implements _AppMedia { int get hashCode => Object.hash( runtimeType, id, + driveMediaRefId, name, path, thumbnailLink, @@ -430,6 +449,7 @@ class _$AppMediaImpl implements _AppMedia { abstract class _AppMedia implements AppMedia { const factory _AppMedia( {required final String id, + final String? driveMediaRefId, final String? name, required final String path, final String? thumbnailLink, @@ -452,6 +472,8 @@ abstract class _AppMedia implements AppMedia { @override String get id; @override + String? get driveMediaRefId; + @override String? get name; @override String get path; diff --git a/data/lib/models/media/media.g.dart b/data/lib/models/media/media.g.dart index ef59dee..5d4b1dc 100644 --- a/data/lib/models/media/media.g.dart +++ b/data/lib/models/media/media.g.dart @@ -9,6 +9,7 @@ part of 'media.dart'; _$AppMediaImpl _$$AppMediaImplFromJson(Map json) => _$AppMediaImpl( id: json['id'] as String, + driveMediaRefId: json['driveMediaRefId'] as String?, name: json['name'] as String?, path: json['path'] as String, thumbnailLink: json['thumbnailLink'] as String?, @@ -39,6 +40,7 @@ _$AppMediaImpl _$$AppMediaImplFromJson(Map json) => Map _$$AppMediaImplToJson(_$AppMediaImpl instance) => { 'id': instance.id, + 'driveMediaRefId': instance.driveMediaRefId, 'name': instance.name, 'path': instance.path, 'thumbnailLink': instance.thumbnailLink, diff --git a/data/lib/models/media/media_extension.dart b/data/lib/models/media/media_extension.dart new file mode 100644 index 0000000..be7857a --- /dev/null +++ b/data/lib/models/media/media_extension.dart @@ -0,0 +1,63 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:photo_manager/photo_manager.dart'; +import 'media.dart'; + +extension AppMediaExtension on AppMedia { + Future get isExist async { + return await File(path).exists(); + } + + Future loadThumbnail({Size size = const Size(300, 300)}) async { + var rootToken = RootIsolateToken.instance!; + final ThumbNailParameter thumbNailParameter = + ThumbNailParameter(rootToken, size, id, type); + final bytes = await compute(_loadThumbnailInBackground, thumbNailParameter); + return bytes; + } + + FutureOr _loadThumbnailInBackground( + ThumbNailParameter parameters) async { + BackgroundIsolateBinaryMessenger.ensureInitialized(parameters.token); + return await AssetEntity( + id: parameters.id, + typeInt: parameters.type.index, + width: 0, + height: 0, + ).thumbnailDataWithSize( + ThumbnailSize( + parameters.size.width.toInt(), + parameters.size.height.toInt(), + ), + format: ThumbnailFormat.png, + quality: 70, + ); + } + + AppMedia mergeGoogleDriveMedia(AppMedia media){ + return copyWith( + thumbnailLink: media.thumbnailLink, + driveMediaRefId: media.driveMediaRefId, + sources: sources.toList()..add(AppMediaSource.googleDrive), + ); + } + + bool get isGoogleDriveStored => + sources.contains(AppMediaSource.googleDrive) && sources.length == 1; + + bool get isLocalStored => + sources.contains(AppMediaSource.local) && sources.length == 1; + + bool get isCommonStored => sources.length > 1; +} + +class ThumbNailParameter { + final RootIsolateToken token; + final Size size; + final String id; + final AppMediaType type; + + ThumbNailParameter(this.token, this.size, this.id, this.type); +} diff --git a/data/lib/repositories/google_drive_process_repo.dart b/data/lib/repositories/google_drive_process_repo.dart new file mode 100644 index 0000000..3431d77 --- /dev/null +++ b/data/lib/repositories/google_drive_process_repo.dart @@ -0,0 +1,231 @@ +import 'dart:async'; +import 'package:collection/collection.dart'; +import 'package:data/extensions/iterable_extension.dart'; +import 'package:data/models/app_process/app_process.dart'; +import 'package:data/models/media/media_extension.dart'; +import 'package:data/services/google_drive_service.dart'; +import 'package:data/services/local_media_service.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../errors/app_error.dart'; +import '../models/media/media.dart'; + +final googleDriveProcessRepoProvider = Provider((ref) { + return GoogleDriveProcessRepo( + ref.read(googleDriveServiceProvider), + ref.read(localMediaServiceProvider), + ); +}); + +class GoogleDriveProcessRepo extends ChangeNotifier { + final GoogleDriveService _googleDriveService; + final LocalMediaService _localMediaService; + + final List _uploadQueue = []; + final List _deleteQueue = []; + final List _downloadQueue = []; + + bool _uploadQueueRunning = false; + bool _deleteQueueRunning = false; + bool _downloadQueueRunning = false; + + List get uploadQueue => _uploadQueue; + + List get deleteQueue => _deleteQueue; + + List get downloadQueue => _downloadQueue; + + String? _backUpFolderID; + + GoogleDriveProcessRepo(this._googleDriveService, this._localMediaService); + + void setBackUpFolderId(String? backUpFolderId) async { + _backUpFolderID = backUpFolderId; + } + + void uploadMediasInGoogleDrive({required List medias}) { + _uploadQueue.addAll(medias.map((media) => AppProcess( + id: media.id, media: media, status: AppProcessStatus.waiting))); + notifyListeners(); + if (!_uploadQueueRunning) _startUploadQueueLoop(); + } + + Future _startUploadQueueLoop() async { + _uploadQueueRunning = true; + while (_uploadQueue.firstOrNull != null) { + await _uploadInGoogleDrive(_uploadQueue[0]); + _uploadQueue.removeAt(0); + notifyListeners(); + } + _uploadQueueRunning = false; + } + + Future _uploadInGoogleDrive(AppProcess process) async { + try { + _uploadQueue.updateWhere( + where: (element) => element.id == process.id, + update: (element) => + element.copyWith(status: AppProcessStatus.uploading), + ); + notifyListeners(); + + _backUpFolderID ??= await _googleDriveService.getBackupFolderId(); + + final res = await _googleDriveService.uploadInGoogleDrive( + folderID: _backUpFolderID!, + media: process.media, + onProgress: (total, chunk) { + _uploadQueue.updateWhere( + where: (element) => element.id == process.id, + update: (element) => element.copyWith( + progress: AppProcessProgress(total: total, chunk: chunk)), + ); + notifyListeners(); + }, + ); + _uploadQueue.updateWhere( + where: (element) => element.id == process.id, + update: (element) => element.copyWith( + status: AppProcessStatus.success, + response: res, + ), + ); + } catch (error) { + if (error is BackUpFolderNotFound) { + _backUpFolderID = await _googleDriveService.getBackupFolderId(); + _uploadInGoogleDrive(process); + return; + } + _uploadQueue.updateWhere( + where: (element) => element.id == process.id, + update: (element) => element.copyWith(status: AppProcessStatus.failed), + ); + } finally { + notifyListeners(); + } + } + + void deleteMediasFromGoogleDrive({required List medias}) { + _deleteQueue.addAll(medias.map((media) => AppProcess( + id: media.id, media: media, status: AppProcessStatus.waiting))); + notifyListeners(); + if (!_deleteQueueRunning) _startDeleteQueueLoop(); + } + + Future _startDeleteQueueLoop() async { + _deleteQueueRunning = true; + while (_deleteQueue.firstOrNull != null) { + await _deleteFromGoogleDrive(_deleteQueue[0]); + _deleteQueue.removeAt(0); + notifyListeners(); + } + _deleteQueueRunning = false; + } + + Future _deleteFromGoogleDrive(AppProcess process) async { + try { + _deleteQueue.updateWhere( + where: (element) => element.id == process.id, + update: (element) => + element.copyWith(status: AppProcessStatus.deleting), + ); + notifyListeners(); + await _googleDriveService.deleteMedia(process.media.driveMediaRefId!); + _deleteQueue.updateWhere( + where: (element) => element.id == process.id, + update: (element) => element.copyWith(status: AppProcessStatus.success), + ); + } catch (error) { + _deleteQueue.updateWhere( + where: (element) => element.id == process.id, + update: (element) => element.copyWith(status: AppProcessStatus.failed), + ); + } finally { + notifyListeners(); + } + } + + void downloadMediasFromGoogleDrive({required List medias}) { + _downloadQueue.addAll(medias.map((media) => AppProcess( + id: media.id, media: media, status: AppProcessStatus.waiting))); + notifyListeners(); + if (!_downloadQueueRunning) _startDownloadQueueLoop(); + } + + Future _startDownloadQueueLoop() async { + _downloadQueueRunning = true; + while (_downloadQueue.firstOrNull != null) { + await _downloadFromGoogleDrive(_downloadQueue[0]); + _downloadQueue.removeAt(0); + notifyListeners(); + } + _downloadQueueRunning = false; + } + + Future _downloadFromGoogleDrive(AppProcess process) async { + try { + _downloadQueue.updateWhere( + where: (element) => element.id == process.id, + update: (element) => + element.copyWith(status: AppProcessStatus.downloading), + ); + notifyListeners(); + + final mediaContent = await _googleDriveService + .fetchMediaBytes(process.media.driveMediaRefId!); + + final localMedia = await _localMediaService.saveMedia( + content: mediaContent, + onProgress: (total, chunk) { + _downloadQueue.updateWhere( + where: (element) => element.id == process.id, + update: (element) => element.copyWith( + progress: AppProcessProgress(total: total, chunk: chunk)), + ); + notifyListeners(); + }, + mimeType: process.media.mimeType, + type: process.media.type, + ); + + final updatedMedia = await _googleDriveService.updateMediaDescription( + process.media.id, localMedia?.path ?? ""); + + _downloadQueue.updateWhere( + where: (element) => element.id == process.id, + update: (element) => element.copyWith( + status: AppProcessStatus.success, + response: localMedia?.mergeGoogleDriveMedia(updatedMedia)), + ); + } catch (error) { + _downloadQueue.updateWhere( + where: (element) => element.id == process.id, + update: (element) => element.copyWith(status: AppProcessStatus.failed), + ); + } finally { + notifyListeners(); + } + } + + void clearAllQueue() { + _uploadQueue.clear(); + _deleteQueue.clear(); + _downloadQueue.clear(); + notifyListeners(); + } + + void terminateUploadProcess(String id) { + _uploadQueue.removeWhere((element) => element.id == id); + notifyListeners(); + } + + void terminateDeleteProcess(String id) { + _deleteQueue.removeWhere((element) => element.id == id); + notifyListeners(); + } + + void terminateDownloadProcess(String id) { + _downloadQueue.removeWhere((element) => element.id == id); + notifyListeners(); + } +} diff --git a/data/lib/services/google_drive_service.dart b/data/lib/services/google_drive_service.dart index db8ebcb..6191b62 100644 --- a/data/lib/services/google_drive_service.dart +++ b/data/lib/services/google_drive_service.dart @@ -60,7 +60,7 @@ class GoogleDriveService { final response = await driveApi.files.list( q: "'$backUpFolderId' in parents and trashed=false", $fields: - "files(id, name, description, mimeType, thumbnailLink, createdTime, modifiedTime, size, imageMediaMetadata, videoMediaMetadata)", + "files(id, name, description, mimeType, thumbnailLink, webContentLink, createdTime, modifiedTime, size, imageMediaMetadata, videoMediaMetadata)", ); return (response.files ?? []) @@ -73,8 +73,30 @@ class GoogleDriveService { } } - Future uploadInGoogleDrive( - {required String folderID, required AppMedia media}) async { + Future updateMediaDescription(String id, String description) async { + try { + final driveApi = await _getGoogleDriveAPI(); + final file = drive.File(description: description); + final updatedFile = await driveApi.files.update(file, id); + return AppMedia.fromGoogleDriveFile(updatedFile); + } catch (e) { + throw AppError.fromError(e); + } + } + + Future deleteMedia(String id) async { + try { + final driveApi = await _getGoogleDriveAPI(); + await driveApi.files.delete(id); + } catch (e) { + throw AppError.fromError(e); + } + } + + Future uploadInGoogleDrive( + {required String folderID, + required AppMedia media, + void Function(int total, int chunk)? onProgress}) async { final localFile = File(media.path); try { final driveApi = await _getGoogleDriveAPI(); @@ -84,10 +106,19 @@ class GoogleDriveService { description: media.path, parents: [folderID], ); - await driveApi.files.create( + final fileLength = localFile.lengthSync(); + int chunk = 0; + final googleDriveFile = await driveApi.files.create( file, - uploadMedia: drive.Media(localFile.openRead(), localFile.lengthSync()), + uploadMedia: drive.Media( + localFile.openRead().map((event) { + chunk += event.length; + onProgress?.call(fileLength, chunk); + return event; + }), + fileLength), ); + return AppMedia.fromGoogleDriveFile(googleDriveFile); } catch (error) { if (error is drive.DetailedApiRequestError && error.status == 404) { throw const BackUpFolderNotFound(); diff --git a/data/lib/services/local_media_service.dart b/data/lib/services/local_media_service.dart index 982d3e5..501c3d2 100644 --- a/data/lib/services/local_media_service.dart +++ b/data/lib/services/local_media_service.dart @@ -1,8 +1,11 @@ +import 'dart:async'; +import 'dart:io'; import 'package:collection/collection.dart'; import 'package:data/models/media/media.dart'; +import 'package:data/models/media_content/media_content.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:photo_manager/photo_manager.dart'; - import '../errors/app_error.dart'; final localMediaServiceProvider = Provider( @@ -45,4 +48,61 @@ class LocalMediaService { throw AppError.fromError(e); } } + + Future> deleteMedias(List medias) async { + try { + return await PhotoManager.editor.deleteWithIds(medias); + } catch (e) { + throw AppError.fromError(e); + } + } + + Future saveMedia({ + required AppMediaType type, + required String? mimeType, + required AppMediaContent content, + required void Function(int total, int chunk) onProgress, + }) async { + try { + final extension = mimeType?.trim().isNotEmpty ?? false + ? mimeType!.split('/').last + : type.isVideo + ? 'mp4' + : 'jpg'; + + AssetEntity? asset; + + final tempDir = await getTemporaryDirectory(); + final tempFile = File( + '${tempDir.path}${DateTime.now()}_gd_cloud_gallery_temp.$extension'); + await tempFile.create(); + + int chunkLength = 0; + + StreamSubscription> subscription = + content.stream.listen((chunk) { + chunkLength += chunk.length; + onProgress(content.length ?? 0, chunkLength); + tempFile.writeAsBytesSync(chunk, mode: FileMode.append); + }); + await subscription.asFuture(); + subscription.cancel(); + + if (type.isVideo) { + asset = await PhotoManager.editor.saveVideo( + tempFile, + title: "${DateTime.now()}_gd_cloud_gallery.$extension", + ); + } else if (type.isImage) { + asset = await PhotoManager.editor.saveImageWithPath( + tempFile.path, + title: "${DateTime.now()}_gd_cloud_gallery.$extension", + ); + } + await tempFile.delete(); + return asset != null ? AppMedia.fromAssetEntity(asset) : null; + } catch (e) { + throw AppError.fromError(e); + } + } } diff --git a/data/pubspec.yaml b/data/pubspec.yaml index 2a1abda..b429eb3 100644 --- a/data/pubspec.yaml +++ b/data/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: http: ^1.2.0 photo_manager: ^3.0.0-dev.5 package_info_plus: ^5.0.1 + path_provider: ^2.1.2 # authentication google_sign_in: ^6.2.1 diff --git a/style/lib/animations/animated_icon.dart b/style/lib/animations/animated_icon.dart new file mode 100644 index 0000000..bfecd80 --- /dev/null +++ b/style/lib/animations/animated_icon.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +class AnimatedIconAnimation extends StatefulWidget { + final Duration duration; + final Curve curve; + final Curve reverseCurve; + final Duration reverseDuration; + final AnimatedIconData icon; + final double size; + final Color color; + final bool value; + + const AnimatedIconAnimation({ + Key? key, + this.duration = const Duration(milliseconds: 300), + this.reverseDuration = const Duration(milliseconds: 300), + this.curve = Curves.easeInOut, + this.reverseCurve = Curves.easeInOut, + required this.icon, + this.size = 24, + this.color = const Color(0xffffffff), + required this.value, + }) : super(key: key); + + @override + State createState() => _AnimatedIconAnimationState(); +} + +class _AnimatedIconAnimationState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animatedIconController; + + @override + void initState() { + super.initState(); + _animatedIconController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + + if (widget.value) { + _animatedIconController.value = 1.0; + } else { + _animatedIconController.value = 0.0; + } + } + + @override + void didUpdateWidget(AnimatedIconAnimation oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value) { + if (widget.value) { + _animatedIconController.forward(); + } else { + _animatedIconController.reverse(); + } + } + } + + @override + void dispose() { + _animatedIconController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedIcon( + icon: widget.icon, + progress: CurvedAnimation( + parent: _animatedIconController, + curve: widget.curve, + reverseCurve: widget.reverseCurve, + ), + color: widget.color, + size: widget.size, + ); + } +} \ No newline at end of file diff --git a/style/lib/animations/cross_fade_animation.dart b/style/lib/animations/cross_fade_animation.dart new file mode 100644 index 0000000..b8502c1 --- /dev/null +++ b/style/lib/animations/cross_fade_animation.dart @@ -0,0 +1,32 @@ +import 'package:flutter/cupertino.dart'; + +class CrossFadeAnimation extends StatelessWidget { + final bool showChild; + final Widget child; + final Alignment alignment; + final Widget replacement; + + const CrossFadeAnimation({ + super.key, + required this.showChild, + required this.child, + this.replacement = const SizedBox( + width: double.infinity, + ), this.alignment = Alignment.topCenter, + }); + + @override + Widget build(BuildContext context) { + return AnimatedCrossFade( + alignment: alignment, + crossFadeState: + showChild ? CrossFadeState.showFirst : CrossFadeState.showSecond, + firstChild: child, + secondChild: replacement, + firstCurve: Curves.easeInOut, + secondCurve: Curves.easeInOut, + duration: const Duration(milliseconds: 200), + reverseDuration: const Duration(milliseconds: 200), + ); + } +} diff --git a/style/lib/animations/dismissible_page.dart b/style/lib/animations/dismissible_page.dart new file mode 100644 index 0000000..cc77bc6 --- /dev/null +++ b/style/lib/animations/dismissible_page.dart @@ -0,0 +1,76 @@ +import 'package:flutter/cupertino.dart'; + +class DismissiblePage extends StatefulWidget { + final Widget Function(double progress) child; + final double threshold; + final Color backgroundColor; + final bool enable; + final void Function(double progress)? onProgress; + final void Function()? onDismiss; + final double scaleDownPercentage; + + const DismissiblePage({ + Key? key, + required this.child, + this.threshold = 100, + this.onProgress, + this.enable = true, + this.onDismiss, + this.scaleDownPercentage = 0.25, + this.backgroundColor = const Color(0xff000000), + }) : super(key: key); + + @override + State createState() => _DismissiblePageState(); +} + +class _DismissiblePageState extends State { + double _startY = 0.0; + double displacement = 0.0; + double percentage = 0.0; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragStart: (DragStartDetails details) { + _startY = details.globalPosition.dy; + }, + onVerticalDragUpdate: (DragUpdateDetails details) { + if ((details.globalPosition.dy - _startY) > 0 && widget.enable) { + setState(() { + displacement = details.globalPosition.dy - _startY; + percentage = (displacement / widget.threshold).clamp(0, 1); + }); + widget.onProgress?.call(percentage); + } + }, + onVerticalDragEnd: (DragEndDetails details) { + if (displacement > widget.threshold) { + widget.onDismiss?.call(); + } else { + setState(() { + displacement = 0.0; + percentage = 0.0; + }); + } + }, + child: Stack( + children: [ + Container( + color: widget.backgroundColor.withOpacity(1 - percentage), + height: double.infinity, + width: double.infinity, + ), + Transform.translate( + offset: Offset(0, displacement), + child: Transform.scale( + scale: 1 - (percentage * widget.scaleDownPercentage), + child: widget.child(percentage), + ), + ), + ], + ), + ); + } +} diff --git a/style/lib/buttons/action_button.dart b/style/lib/buttons/action_button.dart index cdef9d5..d220461 100644 --- a/style/lib/buttons/action_button.dart +++ b/style/lib/buttons/action_button.dart @@ -1,13 +1,13 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:style/extensions/context_extensions.dart'; import 'package:style/indicators/circular_progress_indicator.dart'; class ActionButton extends StatelessWidget { final void Function() onPressed; final Widget icon; final bool progress; + final MaterialTapTargetSize tapTargetSize; final Color? backgroundColor; final double size; final EdgeInsets padding; @@ -16,18 +16,19 @@ class ActionButton extends StatelessWidget { {super.key, required this.onPressed, required this.icon, - this.size = 30, + this.size = 40, + this.tapTargetSize = MaterialTapTargetSize.padded, this.backgroundColor, this.progress = false, this.padding = const EdgeInsets.all(0)}); @override Widget build(BuildContext context) { - if (Platform.isIOS) { + if (Platform.isIOS || Platform.isMacOS) { return CupertinoButton( minSize: size, borderRadius: BorderRadius.circular(size), - color: backgroundColor ?? context.colorScheme.containerNormal, + color: backgroundColor, onPressed: onPressed, padding: padding, child: progress ? const AppCircularProgressIndicator() : icon, @@ -35,8 +36,8 @@ class ActionButton extends StatelessWidget { } else { return IconButton( style: IconButton.styleFrom( - backgroundColor: - backgroundColor ?? context.colorScheme.containerNormal, + tapTargetSize: tapTargetSize, + backgroundColor: backgroundColor, minimumSize: Size(size, size), ), onPressed: onPressed, diff --git a/style/lib/buttons/segmented_button.dart b/style/lib/buttons/segmented_button.dart index 74a6bf6..5bbcf23 100644 --- a/style/lib/buttons/segmented_button.dart +++ b/style/lib/buttons/segmented_button.dart @@ -17,6 +17,8 @@ class AppSegmentedButton extends StatelessWidget { final List segments; final TextStyle segmentTextStyle; final Function(T value) onSelectionChanged; + final VisualDensity? visualDensity; + final MaterialTapTargetSize tapTargetSize; final T selected; const AppSegmentedButton({ @@ -25,6 +27,8 @@ class AppSegmentedButton extends StatelessWidget { this.segmentTextStyle = AppTextStyles.caption, required this.selected, required this.onSelectionChanged, + this.visualDensity = VisualDensity.compact, + this.tapTargetSize = MaterialTapTargetSize.shrinkWrap, this.size, }); @@ -54,7 +58,7 @@ class AppSegmentedButton extends StatelessWidget { borderRadius: BorderRadius.circular(8), side: BorderSide.none, ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + tapTargetSize: tapTargetSize, fixedSize: size, minimumSize: const Size(0, 0), side: BorderSide.none, @@ -62,7 +66,7 @@ class AppSegmentedButton extends StatelessWidget { selectedForegroundColor: context.colorScheme.onPrimary, selectedBackgroundColor: context.colorScheme.primary, backgroundColor: context.colorScheme.containerNormal, - visualDensity: VisualDensity.compact, + visualDensity: visualDensity, ), ); } diff --git a/style/lib/indicators/circular_progress_indicator.dart b/style/lib/indicators/circular_progress_indicator.dart index 756510b..9891b65 100644 --- a/style/lib/indicators/circular_progress_indicator.dart +++ b/style/lib/indicators/circular_progress_indicator.dart @@ -3,14 +3,18 @@ import 'package:style/extensions/context_extensions.dart'; class AppCircularProgressIndicator extends StatelessWidget { final Color? color; + final Color? backgroundColor; final double? value; + final double? strokeWidth; final double size; const AppCircularProgressIndicator({ super.key, this.color, this.size = 32, + this.backgroundColor, this.value, + this.strokeWidth, }); @override @@ -18,14 +22,24 @@ class AppCircularProgressIndicator extends StatelessWidget { return SizedBox( width: size, height: size, - child: CircularProgressIndicator.adaptive( - strokeWidth: size / 8, - value: value, - valueColor: AlwaysStoppedAnimation( - color ?? context.colorScheme.primary, - ), - strokeCap: StrokeCap.round, - ), + child: value != null + ? CircularProgressIndicator( + strokeWidth: strokeWidth ?? size / 8, + value: value, + valueColor: AlwaysStoppedAnimation( + color ?? context.colorScheme.primary, + ), + backgroundColor: backgroundColor, + strokeCap: StrokeCap.round, + ) + : CircularProgressIndicator.adaptive( + strokeWidth: size / 8, + valueColor: AlwaysStoppedAnimation( + color ?? context.colorScheme.primary, + ), + backgroundColor: backgroundColor, + strokeCap: StrokeCap.round, + ), ); } }