From bee805993c2227ddffc0cf5e837f6d9dfcaea8c2 Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Fri, 29 Mar 2024 17:56:13 +0530 Subject: [PATCH 01/20] Implement local media preview --- app/lib/components/error_view.dart | 0 .../image_preview_screen.dart | 0 .../network_image_preview.dart | 0 .../network_image_preview_view_model.dart | 0 ...work_image_preview_view_model.freezed.dart | 0 .../video_preview_screen.dart | 0 .../ui/flow/media_preview/media_preview.dart | 22 --- .../media_preview/media_preview_screen.dart | 0 .../media_preview_view_model.dart | 24 +++ .../media_preview_view_model.freezed.dart | 147 ++++++++++++++++++ data/.flutter-plugins-dependencies | 2 +- data/lib/models/media/media.dart | 2 +- data/lib/services/google_drive_service.dart | 2 +- 13 files changed, 174 insertions(+), 25 deletions(-) create mode 100644 app/lib/components/error_view.dart rename app/lib/ui/flow/media_preview/{image_preview => components}/image_preview_screen.dart (100%) rename app/lib/ui/flow/media_preview/{image_preview => }/components/network_image_preview/network_image_preview.dart (100%) rename app/lib/ui/flow/media_preview/{image_preview => }/components/network_image_preview/network_image_preview_view_model.dart (100%) rename app/lib/ui/flow/media_preview/{image_preview => }/components/network_image_preview/network_image_preview_view_model.freezed.dart (100%) rename app/lib/ui/flow/media_preview/{ => components}/video_preview_screen.dart (100%) delete mode 100644 app/lib/ui/flow/media_preview/media_preview.dart create mode 100644 app/lib/ui/flow/media_preview/media_preview_screen.dart create mode 100644 app/lib/ui/flow/media_preview/media_preview_view_model.dart create mode 100644 app/lib/ui/flow/media_preview/media_preview_view_model.freezed.dart diff --git a/app/lib/components/error_view.dart b/app/lib/components/error_view.dart new file mode 100644 index 0000000..e69de29 diff --git a/app/lib/ui/flow/media_preview/image_preview/image_preview_screen.dart b/app/lib/ui/flow/media_preview/components/image_preview_screen.dart similarity index 100% rename from app/lib/ui/flow/media_preview/image_preview/image_preview_screen.dart rename to app/lib/ui/flow/media_preview/components/image_preview_screen.dart 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 100% 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 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 100% 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 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 100% 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 diff --git a/app/lib/ui/flow/media_preview/video_preview_screen.dart b/app/lib/ui/flow/media_preview/components/video_preview_screen.dart similarity index 100% rename from app/lib/ui/flow/media_preview/video_preview_screen.dart rename to app/lib/ui/flow/media_preview/components/video_preview_screen.dart 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..e69de29 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..2819321 --- /dev/null +++ b/app/lib/ui/flow/media_preview/media_preview_view_model.dart @@ -0,0 +1,24 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'media_preview_view_model.freezed.dart'; + +final mediaPreviewStateNotifierProvider = StateNotifierProvider.autoDispose< + MediaPreviewStateNotifier, + MediaPreviewState>((ref) => MediaPreviewStateNotifier()); + +class MediaPreviewStateNotifier extends StateNotifier { + MediaPreviewStateNotifier() : super(const MediaPreviewState()); + + void changeVisibleMediaIndex(int index) { + state = state.copyWith(currentIndex: index); + } +} + +@freezed +class MediaPreviewState with _$MediaPreviewState { + const factory MediaPreviewState({ + Object? error, + @Default(0) int currentIndex, + }) = _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..7466cce --- /dev/null +++ b/app/lib/ui/flow/media_preview/media_preview_view_model.freezed.dart @@ -0,0 +1,147 @@ +// 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; + int get currentIndex => 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, int currentIndex}); +} + +/// @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? currentIndex = null, + }) { + return _then(_value.copyWith( + error: freezed == error ? _value.error : error, + currentIndex: null == currentIndex + ? _value.currentIndex + : currentIndex // ignore: cast_nullable_to_non_nullable + as int, + ) 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, int currentIndex}); +} + +/// @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? currentIndex = null, + }) { + return _then(_$MediaPreviewStateImpl( + error: freezed == error ? _value.error : error, + currentIndex: null == currentIndex + ? _value.currentIndex + : currentIndex // ignore: cast_nullable_to_non_nullable + as int, + )); + } +} + +/// @nodoc + +class _$MediaPreviewStateImpl implements _MediaPreviewState { + const _$MediaPreviewStateImpl({this.error, this.currentIndex = 0}); + + @override + final Object? error; + @override + @JsonKey() + final int currentIndex; + + @override + String toString() { + return 'MediaPreviewState(error: $error, currentIndex: $currentIndex)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$MediaPreviewStateImpl && + const DeepCollectionEquality().equals(other.error, error) && + (identical(other.currentIndex, currentIndex) || + other.currentIndex == currentIndex)); + } + + @override + int get hashCode => Object.hash( + runtimeType, const DeepCollectionEquality().hash(error), currentIndex); + + @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 int currentIndex}) = _$MediaPreviewStateImpl; + + @override + Object? get error; + @override + int get currentIndex; + @override + @JsonKey(ignore: true) + _$$MediaPreviewStateImplCopyWith<_$MediaPreviewStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/data/.flutter-plugins-dependencies b/data/.flutter-plugins-dependencies index 1ebfe74..6618003 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":"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-28 15:34:28.802695","version":"3.19.3"} \ No newline at end of file diff --git a/data/lib/models/media/media.dart b/data/lib/models/media/media.dart index 48b7175..91fdc39 100644 --- a/data/lib/models/media/media.dart +++ b/data/lib/models/media/media.dart @@ -200,7 +200,7 @@ extension AppMediaExtension on AppMedia { return await AssetEntity(id: id, typeInt: type.index, width: 0, height: 0) .thumbnailDataWithSize( ThumbnailSize(size.width.toInt(), size.height.toInt()), - format: ThumbnailFormat.jpeg, + format: ThumbnailFormat.png, quality: 70, ); } diff --git a/data/lib/services/google_drive_service.dart b/data/lib/services/google_drive_service.dart index db8ebcb..e480df1 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 ?? []) From 88e4e1f7443538ba53fd6264fe2f10f0430692da Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Fri, 29 Mar 2024 17:56:26 +0530 Subject: [PATCH 02/20] Implement local media preview --- .idea/libraries/Flutter_Plugins.xml | 18 +- app/assets/locales/app_en.arb | 4 +- app/lib/components/app_page.dart | 18 +- app/lib/components/error_view.dart | 68 ++++++ app/lib/ui/flow/accounts/accounts_screen.dart | 12 +- .../no_local_medias_access_screen.dart | 52 ++--- app/lib/ui/flow/home/home_screen.dart | 16 +- .../components/image_preview_screen.dart | 112 +++------- .../network_image_preview.dart | 7 +- .../components/video_preview_screen.dart | 204 +++++++++++++++++- .../media_preview/media_preview_screen.dart | 127 +++++++++++ .../media_preview_view_model.dart | 11 +- .../media_preview_view_model.freezed.dart | 37 +++- app/lib/ui/navigation/app_router.dart | 27 +-- style/lib/buttons/action_button.dart | 3 +- 15 files changed, 527 insertions(+), 189 deletions(-) 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..6467561 100644 --- a/app/assets/locales/app_en.arb +++ b/app/assets/locales/app_en.arb @@ -18,7 +18,9 @@ "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! 🚀", 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 index e69de29..95f42ee 100644 --- a/app/lib/components/error_view.dart +++ b/app/lib/components/error_view.dart @@ -0,0 +1,68 @@ +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 ErrorViewAction? action; + + const ErrorView({ + super.key, + this.icon, + required this.title, + 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: 20), + Text(title, + style: AppTextStyles.subtitle2.copyWith( + color: context.colorScheme.textPrimary, + )), + const SizedBox(height: 20), + Text( + message, + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + if (action != null) ...[ + const SizedBox(height: 20), + PrimaryButton( + onPressed: action!.onPressed, + child: Text(action!.title), + ), + ], + ], + ), + ), + ); + } +} 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/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..f719608 100644 --- a/app/lib/ui/flow/home/home_screen.dart +++ b/app/lib/ui/flow/home/home_screen.dart @@ -1,9 +1,7 @@ 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'; @@ -59,7 +57,6 @@ class _HomeScreenState extends ConsumerState { Widget build(BuildContext context) { _errorObserver(); return AppPage( - //barBackgroundColor: context.colorScheme.surface, titleWidget: _titleWidget(context: context), actions: [ ActionButton( @@ -179,10 +176,12 @@ 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(), + startingMediaId: media.id) + .push(context); } }, onLongTap: () { @@ -208,8 +207,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/media_preview/components/image_preview_screen.dart b/app/lib/ui/flow/media_preview/components/image_preview_screen.dart index b075768..c66b552 100644 --- a/app/lib/ui/flow/media_preview/components/image_preview_screen.dart +++ b/app/lib/ui/flow/media_preview/components/image_preview_screen.dart @@ -1,31 +1,27 @@ import 'dart:io'; -import 'dart:math'; +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 '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'; +import 'network_image_preview/network_image_preview.dart'; +import 'network_image_preview/network_image_preview_view_model.dart'; -class ImagePreviewScreen extends ConsumerStatefulWidget { +class ImagePreview extends ConsumerStatefulWidget { final AppMedia media; - const ImagePreviewScreen({ + const ImagePreview({ super.key, required this.media, }); @override - ConsumerState createState() => _ImagePreviewScreenState(); + ConsumerState createState() => _ImagePreviewScreenState(); } -class _ImagePreviewScreenState extends ConsumerState { - final _transformationController = TransformationController(); - double _translateY = 0; - double _scale = 1; - +class _ImagePreviewScreenState extends ConsumerState { late NetworkImagePreviewStateNotifier notifier; @override @@ -41,76 +37,16 @@ class _ImagePreviewScreenState extends ConsumerState { @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, - ), - ), - ), + 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, ), - ), - ), - if (_scale == 1) - Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - AdaptiveAppBar( - iosTransitionBetweenRoutes: false, - text: widget.media.name ?? '', - ), - ], - ), - ], ), ), ); @@ -119,10 +55,14 @@ class _ImagePreviewScreenState extends ConsumerState { Widget _displayLocalImage({required BuildContext context}) { return Hero( tag: widget.media, - child: Image.file( - File(widget.media.path), - fit: BoxFit.contain, - ), + 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/components/network_image_preview/network_image_preview.dart b/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview.dart index c949a09..28c3fc9 100644 --- a/app/lib/ui/flow/media_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 { @@ -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/components/video_preview_screen.dart b/app/lib/ui/flow/media_preview/components/video_preview_screen.dart index fa98229..61fee53 100644 --- a/app/lib/ui/flow/media_preview/components/video_preview_screen.dart +++ b/app/lib/ui/flow/media_preview/components/video_preview_screen.dart @@ -1,21 +1,205 @@ -import 'package:cloud_gallery/components/app_page.dart'; +import 'dart:io'; +import 'package:cloud_gallery/domain/formatter/duration_formatter.dart'; +import 'package:data/models/media/media.dart'; import 'package:flutter/cupertino.dart'; -class VideoPreviewScreen extends StatefulWidget { - const VideoPreviewScreen({super.key}); +import 'package:flutter/material.dart'; +import 'package:style/animations/on_tap_scale.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/indicators/circular_progress_indicator.dart'; +import 'package:video_player/video_player.dart'; + +class VideoPreview extends StatefulWidget { + final AppMedia media; + + const VideoPreview({super.key, required this.media}); @override - State createState() => _VideoPreviewScreenState(); + State createState() => _VideoPreviewState(); } -class _VideoPreviewScreenState extends State { +class _VideoPreviewState extends State with SingleTickerProviderStateMixin{ + bool _isInitialized = false; + bool _isBuffering = false; + Duration _position = Duration.zero; + Duration _maxDuration = Duration.zero; + + late VideoPlayerController _videoController; + late AnimationController _playPauseController; + + @override - Widget build(BuildContext context) { - return const AppPage( - title: '', - body: Center( - child: Text('Video Preview Screen'), + void dispose() { + _videoController.dispose(); + _playPauseController.dispose(); + super.dispose(); + } + + @override + void initState() { + if (widget.media.sources.contains(AppMediaSource.local)) { + _videoController = VideoPlayerController.file(File(widget.media.path)) + ..initialize().then((_) { + setState(() {}); + }); + } + _playPauseController = AnimationController( + vsync: this, + value: 0, + duration: const Duration( + milliseconds: 300, ), ); + _videoController.play(); + _videoController.addListener(_videoControllerListener); + super.initState(); + } + + + _videoControllerListener() { + if (_videoController.value.position == _videoController.value.duration && _videoController.value.isCompleted ) { + _playPauseController.forward(); + } + _isInitialized = _videoController.value.isInitialized; + _isBuffering = _videoController.value.isBuffering; + _position = _videoController.value.position; + _maxDuration = _videoController.value.duration; + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return _buildVideoPlayer(context); + } + + Widget _buildVideoPlayer(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + if (_isInitialized) + Center( + child: AspectRatio( + aspectRatio: _videoController.value.aspectRatio, + child: VideoPlayer(_videoController), + ), + ), + if (!_isInitialized || _isBuffering) + Center( + child: AppCircularProgressIndicator( + color: context.colorScheme.onPrimary, + ), + ), + _videoActions(context), + _videoDurationSlider(context), + ], + ); } + + Widget _videoActions(BuildContext context) => Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + OnTapScale( + onTap: () { + _videoController.seekTo( + Duration(seconds: _position.inSeconds - 10), + ); + }, + child: const Padding( + padding: + EdgeInsets.only(left: 22, right: 18, top: 18, bottom: 18), + child: Icon( + CupertinoIcons.gobackward_10, + color: Colors.white, + size: 32, + ), + ), + ), + OnTapScale( + onTap: () async { + if (_videoController.value.isPlaying) { + await _playPauseController.forward(); + _videoController.pause(); + } else { + await _playPauseController.reverse(); + _videoController.play(); + } + }, + child: AnimatedIcon( + icon: AnimatedIcons.pause_play, + progress: _playPauseController, + color: Colors.white, + size: 64, + ), + ), + OnTapScale( + onTap: () { + _videoController.seekTo(Duration(seconds: _position.inSeconds + 10), + ); + }, + child: const Padding( + padding: + EdgeInsets.only(left: 18, right: 22, top: 18, bottom: 18), + child: Icon( + CupertinoIcons.goforward_10, + color: Colors.white, + size: 32, + ), + ), + ), + ], + ); + + Widget _videoDurationSlider(BuildContext context) => Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + height: 30, + child: Material( + color: Colors.transparent, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 4, + activeTrackColor: Colors.white, + thumbShape: SliderComponentShape.noThumb, + inactiveTrackColor: Colors.grey.shade500, + ), + child: Slider( + value: _position.inSeconds.toDouble(), + max: _maxDuration.inSeconds.toDouble(), + min: 0, + onChanged: (value) { + setState(() { + _position = Duration(seconds: value.toInt()); + }); + _videoController.seekTo(Duration(seconds: value.toInt())); + }, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 20, right: 20, bottom: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _position.format, + style: const TextStyle( + fontSize: 14, + color: Colors.white, + ), + ), + Text( + _maxDuration.format, + style: const TextStyle( + fontSize: 14, + color: Colors.white, + ), + ), + ], + ), + ), + ], + ); } diff --git a/app/lib/ui/flow/media_preview/media_preview_screen.dart b/app/lib/ui/flow/media_preview/media_preview_screen.dart index e69de29..7560bf7 100644 --- a/app/lib/ui/flow/media_preview/media_preview_screen.dart +++ b/app/lib/ui/flow/media_preview/media_preview_screen.dart @@ -0,0 +1,127 @@ +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:cloud_gallery/domain/extensions/widget_extensions.dart'; +import 'package:cloud_gallery/domain/formatter/date_formatter.dart'; +import 'package:cloud_gallery/ui/flow/media_preview/components/image_preview_screen.dart'; +import 'package:cloud_gallery/ui/flow/media_preview/media_preview_view_model.dart'; +import 'package:data/models/media/media.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:style/buttons/action_button.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'components/video_preview_screen.dart'; + +class MediaPreview extends ConsumerStatefulWidget { + final List medias; + final String startingMediaId; + + const MediaPreview( + {super.key, required this.medias, required this.startingMediaId}); + + @override + ConsumerState createState() => _MediaPreviewState(); +} + +class _MediaPreviewState extends ConsumerState { + late AutoDisposeStateNotifierProvider _provider; + late PageController _pageController; + late MediaPreviewStateNotifier notifier; + + @override + void initState() { + final currentIndex = widget.medias + .indexWhere((element) => element.id == widget.startingMediaId); + _provider = mediaPreviewStateNotifierProvider( + MediaPreviewState(currentIndex: currentIndex)); + _pageController = PageController(initialPage: currentIndex); + notifier = ref.read(_provider.notifier); + runPostFrame(() => notifier.changeVisibleMediaIndex(currentIndex)); + super.initState(); + } + + @override + Widget build(BuildContext context) { + + return AppPage( + body: Stack( + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: notifier.toggleManu, + child: PageView.builder( + onPageChanged: notifier.changeVisibleMediaIndex, + controller: _pageController, + itemCount: widget.medias.length, + itemBuilder: (context, index) => + _preview(context: context, index: index), + ), + ), + _manu(context: context), + ], + ), + ); + } + + Widget _preview({required BuildContext context, required int index}) { + final media = widget.medias[index]; + if (media.type.isVideo) { + return VideoPreview(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 _manu({required BuildContext context}) => Consumer( + builder: (context, ref, child) { + final state = ref.watch(_provider); + return AnimatedCrossFade( + crossFadeState: state.showManu + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: AdaptiveAppBar( + text: widget.medias[state.currentIndex].createdTime + ?.format(context, DateFormatType.relative) ?? + '', + actions: [ + ActionButton( + onPressed: () { + ///TODO: media details + }, + icon: Icon( + CupertinoIcons.info, + color: context.colorScheme.textSecondary, + size: 18, + ), + ), + const SizedBox(width: 8), + ActionButton( + onPressed: () { + ///TODO: delete media feature + }, + icon: Icon( + CupertinoIcons.delete, + color: context.colorScheme.textSecondary, + size: 18, + ), + ), + const SizedBox(width: 16), + ], + ), + secondChild: const SizedBox( + width: double.infinity, + ), + firstCurve: Curves.easeInOut, + secondCurve: Curves.easeInOut, + duration: const Duration(milliseconds: 200), + reverseDuration: const Duration(milliseconds: 200), + ); + }, + ); +} 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 index 2819321..f2dad60 100644 --- a/app/lib/ui/flow/media_preview/media_preview_view_model.dart +++ b/app/lib/ui/flow/media_preview/media_preview_view_model.dart @@ -3,16 +3,20 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'media_preview_view_model.freezed.dart'; -final mediaPreviewStateNotifierProvider = StateNotifierProvider.autoDispose< +final mediaPreviewStateNotifierProvider = StateNotifierProvider.family.autoDispose< MediaPreviewStateNotifier, - MediaPreviewState>((ref) => MediaPreviewStateNotifier()); + MediaPreviewState, MediaPreviewState>((ref, initial) => MediaPreviewStateNotifier(initial)); class MediaPreviewStateNotifier extends StateNotifier { - MediaPreviewStateNotifier() : super(const MediaPreviewState()); + MediaPreviewStateNotifier(MediaPreviewState initialState) : super(initialState); void changeVisibleMediaIndex(int index) { state = state.copyWith(currentIndex: index); } + + void toggleManu() { + state = state.copyWith(showManu: !state.showManu); + } } @freezed @@ -20,5 +24,6 @@ class MediaPreviewState with _$MediaPreviewState { const factory MediaPreviewState({ Object? error, @Default(0) int currentIndex, + @Default(true) bool showManu, }) = _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 index 7466cce..d91b241 100644 --- 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 @@ -18,6 +18,7 @@ final _privateConstructorUsedError = UnsupportedError( mixin _$MediaPreviewState { Object? get error => throw _privateConstructorUsedError; int get currentIndex => throw _privateConstructorUsedError; + bool get showManu => throw _privateConstructorUsedError; @JsonKey(ignore: true) $MediaPreviewStateCopyWith get copyWith => @@ -30,7 +31,7 @@ abstract class $MediaPreviewStateCopyWith<$Res> { MediaPreviewState value, $Res Function(MediaPreviewState) then) = _$MediaPreviewStateCopyWithImpl<$Res, MediaPreviewState>; @useResult - $Res call({Object? error, int currentIndex}); + $Res call({Object? error, int currentIndex, bool showManu}); } /// @nodoc @@ -48,6 +49,7 @@ class _$MediaPreviewStateCopyWithImpl<$Res, $Val extends MediaPreviewState> $Res call({ Object? error = freezed, Object? currentIndex = null, + Object? showManu = null, }) { return _then(_value.copyWith( error: freezed == error ? _value.error : error, @@ -55,6 +57,10 @@ class _$MediaPreviewStateCopyWithImpl<$Res, $Val extends MediaPreviewState> ? _value.currentIndex : currentIndex // ignore: cast_nullable_to_non_nullable as int, + showManu: null == showManu + ? _value.showManu + : showManu // ignore: cast_nullable_to_non_nullable + as bool, ) as $Val); } } @@ -67,7 +73,7 @@ abstract class _$$MediaPreviewStateImplCopyWith<$Res> __$$MediaPreviewStateImplCopyWithImpl<$Res>; @override @useResult - $Res call({Object? error, int currentIndex}); + $Res call({Object? error, int currentIndex, bool showManu}); } /// @nodoc @@ -83,6 +89,7 @@ class __$$MediaPreviewStateImplCopyWithImpl<$Res> $Res call({ Object? error = freezed, Object? currentIndex = null, + Object? showManu = null, }) { return _then(_$MediaPreviewStateImpl( error: freezed == error ? _value.error : error, @@ -90,6 +97,10 @@ class __$$MediaPreviewStateImplCopyWithImpl<$Res> ? _value.currentIndex : currentIndex // ignore: cast_nullable_to_non_nullable as int, + showManu: null == showManu + ? _value.showManu + : showManu // ignore: cast_nullable_to_non_nullable + as bool, )); } } @@ -97,17 +108,21 @@ class __$$MediaPreviewStateImplCopyWithImpl<$Res> /// @nodoc class _$MediaPreviewStateImpl implements _MediaPreviewState { - const _$MediaPreviewStateImpl({this.error, this.currentIndex = 0}); + const _$MediaPreviewStateImpl( + {this.error, this.currentIndex = 0, this.showManu = true}); @override final Object? error; @override @JsonKey() final int currentIndex; + @override + @JsonKey() + final bool showManu; @override String toString() { - return 'MediaPreviewState(error: $error, currentIndex: $currentIndex)'; + return 'MediaPreviewState(error: $error, currentIndex: $currentIndex, showManu: $showManu)'; } @override @@ -117,12 +132,14 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { other is _$MediaPreviewStateImpl && const DeepCollectionEquality().equals(other.error, error) && (identical(other.currentIndex, currentIndex) || - other.currentIndex == currentIndex)); + other.currentIndex == currentIndex) && + (identical(other.showManu, showManu) || + other.showManu == showManu)); } @override - int get hashCode => Object.hash( - runtimeType, const DeepCollectionEquality().hash(error), currentIndex); + int get hashCode => Object.hash(runtimeType, + const DeepCollectionEquality().hash(error), currentIndex, showManu); @JsonKey(ignore: true) @override @@ -134,13 +151,17 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { abstract class _MediaPreviewState implements MediaPreviewState { const factory _MediaPreviewState( - {final Object? error, final int currentIndex}) = _$MediaPreviewStateImpl; + {final Object? error, + final int currentIndex, + final bool showManu}) = _$MediaPreviewStateImpl; @override Object? get error; @override int get currentIndex; @override + bool get showManu; + @override @JsonKey(ignore: true) _$$MediaPreviewStateImplCopyWith<_$MediaPreviewStateImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/app/lib/ui/navigation/app_router.dart b/app/lib/ui/navigation/app_router.dart index dcc76b7..6cd04ac 100644 --- a/app/lib/ui/navigation/app_router.dart +++ b/app/lib/ui/navigation/app_router.dart @@ -1,11 +1,10 @@ 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/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,15 +23,14 @@ class AppRouter { builder: (context) => const AccountsScreen(), ); - static AppRoute imagePreview({required AppMedia media}) => AppRoute( - AppRoutePath.imagePreview, - builder: (context) => ImagePreviewScreen(media: media), - ); - - static AppRoute videoPreview({required String path, required bool isLocal}) => + static AppRoute preview( + {required List medias, required String startingMediaId}) => AppRoute( - AppRoutePath.videoPreview, - builder: (context) => const VideoPreviewScreen(), + AppRoutePath.preview, + builder: (context) => MediaPreview( + medias: medias, + startingMediaId: startingMediaId, + ), ); static final routes = [ @@ -40,7 +38,7 @@ class AppRouter { onBoard.goRoute, accounts.goRoute, GoRoute( - path: AppRoutePath.imagePreview, + path: AppRoutePath.preview, pageBuilder: (context, state) { return CustomTransitionPage( opaque: false, @@ -52,10 +50,6 @@ class AppRouter { ); }, ), - GoRoute( - path: AppRoutePath.videoPreview, - builder: (context, state) => state.widget(context), - ), ]; } @@ -63,6 +57,5 @@ 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'; } diff --git a/style/lib/buttons/action_button.dart b/style/lib/buttons/action_button.dart index cdef9d5..5e4800b 100644 --- a/style/lib/buttons/action_button.dart +++ b/style/lib/buttons/action_button.dart @@ -23,7 +23,7 @@ class ActionButton extends StatelessWidget { @override Widget build(BuildContext context) { - if (Platform.isIOS) { + if (Platform.isIOS || Platform.isMacOS) { return CupertinoButton( minSize: size, borderRadius: BorderRadius.circular(size), @@ -35,6 +35,7 @@ class ActionButton extends StatelessWidget { } else { return IconButton( style: IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.padded, backgroundColor: backgroundColor ?? context.colorScheme.containerNormal, minimumSize: Size(size, size), From 97c07e359888c74cec65061f6815dd0a75ef264c Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Mon, 1 Apr 2024 17:59:15 +0530 Subject: [PATCH 03/20] Use mixin for helper class --- app/lib/components/app_dialog.dart | 0 .../home/home_view_model_helper_mixin.dart | 71 +++++++++++++++++++ data/lib/models/media/media.dart | 9 +++ data/lib/models/media/media.freezed.dart | 24 ++++++- data/lib/models/media/media.g.dart | 2 + data/lib/services/google_drive_service.dart | 9 +++ data/lib/services/local_media_service.dart | 8 +++ 7 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 app/lib/components/app_dialog.dart create mode 100644 app/lib/ui/flow/home/home_view_model_helper_mixin.dart diff --git a/app/lib/components/app_dialog.dart b/app/lib/components/app_dialog.dart new file mode 100644 index 0000000..e69de29 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..0e104ad --- /dev/null +++ b/app/lib/ui/flow/home/home_view_model_helper_mixin.dart @@ -0,0 +1,71 @@ +import 'package:cloud_gallery/domain/formatter/date_formatter.dart'; +import 'package:collection/collection.dart'; +import 'package:data/models/media/media.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final homeViewModelHelperProvider = Provider((ref) { + return const HomeViewModelHelper(); +}); + +class HomeViewModelHelper { + + 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, + driveRefId: googleDriveMedia.id, + )); + }); + } + + 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); + } + } + return allMedias; + } +} diff --git a/data/lib/models/media/media.dart b/data/lib/models/media/media.dart index 91fdc39..009dcef 100644 --- a/data/lib/models/media/media.dart +++ b/data/lib/models/media/media.dart @@ -98,6 +98,7 @@ enum AppMediaSource { class AppMedia with _$AppMedia { const factory AppMedia({ required String id, + String? driveMediaRefId, String? name, required String path, String? thumbnailLink, @@ -204,4 +205,12 @@ extension AppMediaExtension on AppMedia { quality: 70, ); } + + 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; } 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/services/google_drive_service.dart b/data/lib/services/google_drive_service.dart index e480df1..b613a8f 100644 --- a/data/lib/services/google_drive_service.dart +++ b/data/lib/services/google_drive_service.dart @@ -73,6 +73,15 @@ class GoogleDriveService { } } + 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}) async { final localFile = File(media.path); diff --git a/data/lib/services/local_media_service.dart b/data/lib/services/local_media_service.dart index 982d3e5..c7f1ec1 100644 --- a/data/lib/services/local_media_service.dart +++ b/data/lib/services/local_media_service.dart @@ -45,4 +45,12 @@ class LocalMediaService { throw AppError.fromError(e); } } + + Future> deleteMedias(List medias) async { + try { + return await PhotoManager.editor.deleteWithIds(medias); + } catch (e) { + throw AppError.fromError(e); + } + } } From e1ae1e295666e5d59318a59e86e9e0414fbbc9ab Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Mon, 1 Apr 2024 17:59:33 +0530 Subject: [PATCH 04/20] Use mixin for helper class --- app/lib/components/action_sheet.dart | 2 +- app/lib/components/app_dialog.dart | 83 +++++++++++ .../multi_selection_done_button.dart | 89 ++++++++++-- .../ui/flow/home/home_screen_view_model.dart | 130 ++++++++---------- .../home/home_view_model_helper_mixin.dart | 15 +- .../media_preview_view_model.dart | 23 +++- 6 files changed, 244 insertions(+), 98 deletions(-) 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 index e69de29..c85af95 100644 --- a/app/lib/components/app_dialog.dart +++ 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/ui/flow/home/components/multi_selection_done_button.dart b/app/lib/ui/flow/home/components/multi_selection_done_button.dart index 90aab74..99fe330 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,14 @@ 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 +35,75 @@ 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.uploadMediaOnGoogleDrive(); + context.pop(); + }, + ), + if (showDeleteFromDeviceButton) + AppSheetAction( + icon: const Icon(CupertinoIcons.delete), + title: "Delete from device", + onPressed: () { + showAppAlertDialog( + context: context, + title: "Delete selected medias", + message: "Are you sure you want to delete these items?", + actions: [ + AppAlertAction( + title: "Cancel", + onPressed: () { + context.pop(); + }, + ), + AppAlertAction( + isDestructiveAction: true, + title: "Delete", + onPressed: () { + notifier.deleteMediasFromLocal(); + context.pop(); + }, + ), + ], + ); + }, + ), + if (showDeleteFromDriveButton) + AppSheetAction( + icon: const Icon(CupertinoIcons.delete), + title: "Delete from Google Drive", + onPressed: () { + showAppAlertDialog( + context: context, + title: "Delete selected medias", + message: "Are you sure you want to delete these items?", + actions: [ + AppAlertAction( + title: "Cancel", + onPressed: () { + context.pop(); + }, + ), + AppAlertAction( + isDestructiveAction: true, + title: "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/home_screen_view_model.dart b/app/lib/ui/flow/home/home_screen_view_model.dart index 85d0222..7fa1ca3 100644 --- a/app/lib/ui/flow/home/home_screen_view_model.dart +++ b/app/lib/ui/flow/home/home_screen_view_model.dart @@ -1,7 +1,5 @@ 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:data/models/media/media.dart'; import 'package:data/services/auth_service.dart'; @@ -11,6 +9,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'; @@ -24,7 +23,8 @@ final homeViewStateNotifier = ); }); -class HomeViewStateNotifier extends StateNotifier { +class HomeViewStateNotifier extends StateNotifier + with HomeViewModelHelperMixin { final GoogleDriveService _googleDriveService; final AuthService _authService; final LocalMediaService _localMediaService; @@ -46,8 +46,9 @@ class HomeViewStateNotifier extends StateNotifier { if (event == null) { _uploadedMedia.clear(); state = state.copyWith( - medias: _sortMedias( - medias: _removeGoogleDriveRefFromMedias(state.medias)), + medias: sortMedias( + medias: removeGoogleDriveRefFromMedias(state.medias), + ), ); } }); @@ -100,19 +101,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 +148,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 +161,9 @@ 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: removeGoogleDriveRefFromMedias(state.medias), googleDriveMedias: uploadedMedia, ), ...googleDriveMedia @@ -173,6 +183,42 @@ class HomeViewStateNotifier extends StateNotifier { state = state.copyWith(selectedMedias: selectedMedias); } + 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)) + .map((e) => e.isCommonStored ? e.driveMediaRefId ?? '' : e.id) + .toList(); + for (final media in medias) { + await _googleDriveService.deleteMedia(media); + } + state = state.copyWith( + selectedMedias: [], + ); + loadGoogleDriveMedia(); + } catch (e) { + state = state.copyWith(error: e); + } + } + Future uploadMediaOnGoogleDrive() async { try { if (!_authService.signedInWithGoogle) { @@ -235,66 +281,6 @@ class HomeViewStateNotifier extends StateNotifier { } } - //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); - } - } - return allMedias; - } - @override Future dispose() async { await _googleAccountSubscription?.cancel(); 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 index 0e104ad..c1de710 100644 --- a/app/lib/ui/flow/home/home_view_model_helper_mixin.dart +++ b/app/lib/ui/flow/home/home_view_model_helper_mixin.dart @@ -1,14 +1,8 @@ import 'package:cloud_gallery/domain/formatter/date_formatter.dart'; import 'package:collection/collection.dart'; import 'package:data/models/media/media.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final homeViewModelHelperProvider = Provider((ref) { - return const HomeViewModelHelper(); -}); - -class HomeViewModelHelper { +mixin HomeViewModelHelperMixin { List mergeCommonMedia({ required List localMedias, required List googleDriveMedias, @@ -34,7 +28,7 @@ class HomeViewModelHelper { mergedMedias.add(localMedia.copyWith( sources: [AppMediaSource.local, AppMediaSource.googleDrive], thumbnailLink: googleDriveMedia.thumbnailLink, - driveRefId: googleDriveMedia.id, + driveMediaRefId: googleDriveMedia.id, )); }); } @@ -56,13 +50,14 @@ class HomeViewModelHelper { Map> medias) { final allMedias = medias.values.expand((element) => element).toList(); for (int index = 0; index < allMedias.length; index++) { - if (allMedias[index].sources.length > 1) { + if (allMedias[index].isCommonStored) { allMedias[index] = allMedias[index].copyWith( sources: allMedias[index].sources.toList() ..remove(AppMediaSource.googleDrive), thumbnailLink: null, + driveMediaRefId: null, ); - } else if (allMedias.contains(AppMediaSource.googleDrive)) { + } else if (allMedias[index].isGoogleDriveStored) { allMedias.removeAt(index); } } 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 index f2dad60..e323edd 100644 --- a/app/lib/ui/flow/media_preview/media_preview_view_model.dart +++ b/app/lib/ui/flow/media_preview/media_preview_view_model.dart @@ -1,14 +1,25 @@ +import 'package:data/models/media/media.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< - MediaPreviewStateNotifier, - MediaPreviewState, MediaPreviewState>((ref, initial) => MediaPreviewStateNotifier(initial)); +final mediaPreviewStateNotifierProvider = StateNotifierProvider.family + .autoDispose( + (ref, initial) => MediaPreviewStateNotifier( + ref.read(localMediaServiceProvider), + initial, + ), +); class MediaPreviewStateNotifier extends StateNotifier { - MediaPreviewStateNotifier(MediaPreviewState initialState) : super(initialState); + final LocalMediaService _localMediaService; + + MediaPreviewStateNotifier( + this._localMediaService, MediaPreviewState initialState) + : super(initialState); void changeVisibleMediaIndex(int index) { state = state.copyWith(currentIndex: index); @@ -17,6 +28,10 @@ class MediaPreviewStateNotifier extends StateNotifier { void toggleManu() { state = state.copyWith(showManu: !state.showManu); } + + Future deleteMedia(AppMedia appMedia) async { + await _localMediaService.deleteMedias([appMedia.id]); + } } @freezed From 3b819c1ebc65d918352e3b2c665eb4e5161fd06a Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Tue, 2 Apr 2024 15:37:30 +0530 Subject: [PATCH 05/20] Improve progress --- data/lib/models/media/media.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/data/lib/models/media/media.dart b/data/lib/models/media/media.dart index 009dcef..0d7c43b 100644 --- a/data/lib/models/media/media.dart +++ b/data/lib/models/media/media.dart @@ -148,6 +148,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, From 6d46d78be898c82ea4afd7f3b01598d13d5b28ec Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Tue, 2 Apr 2024 15:37:44 +0530 Subject: [PATCH 06/20] Improve progress --- app/lib/ui/flow/home/home_screen.dart | 3 + .../network_image_preview.dart | 2 +- .../network_image_preview_view_model.dart | 44 +++- ...work_image_preview_view_model.freezed.dart | 25 ++- .../components/video_preview_screen.dart | 207 +++++++++--------- .../media_preview/media_preview_screen.dart | 183 +++++++++++++--- .../media_preview_view_model.dart | 24 +- .../media_preview_view_model.freezed.dart | 46 +++- style/lib/buttons/action_button.dart | 12 +- .../circular_progress_indicator.dart | 24 +- 10 files changed, 409 insertions(+), 161 deletions(-) diff --git a/app/lib/ui/flow/home/home_screen.dart b/app/lib/ui/flow/home/home_screen.dart index f719608..e6efb80 100644 --- a/app/lib/ui/flow/home/home_screen.dart +++ b/app/lib/ui/flow/home/home_screen.dart @@ -60,6 +60,7 @@ class _HomeScreenState extends ConsumerState { titleWidget: _titleWidget(context: context), actions: [ ActionButton( + backgroundColor: context.colorScheme.containerNormalOnSurface, onPressed: () { AppRouter.accounts.push(context); }, @@ -69,6 +70,8 @@ class _HomeScreenState extends ConsumerState { size: 18, ), ), + if(!Platform.isIOS && !Platform.isMacOS) + const SizedBox(width: 16), ], body: _body(context: context), ); diff --git a/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview.dart b/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview.dart index 28c3fc9..b794206 100644 --- a/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview.dart +++ b/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview.dart @@ -17,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, diff --git a/app/lib/ui/flow/media_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 index 571b17f..dbff95e 100644 --- a/app/lib/ui/flow/media_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/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 index b953966..a1a47a9 100644 --- a/app/lib/ui/flow/media_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/video_preview_screen.dart b/app/lib/ui/flow/media_preview/components/video_preview_screen.dart index 61fee53..cb58456 100644 --- a/app/lib/ui/flow/media_preview/components/video_preview_screen.dart +++ b/app/lib/ui/flow/media_preview/components/video_preview_screen.dart @@ -2,23 +2,25 @@ import 'dart:io'; import 'package:cloud_gallery/domain/formatter/duration_formatter.dart'; import 'package:data/models/media/media.dart'; import 'package:flutter/cupertino.dart'; - import 'package:flutter/material.dart'; import 'package:style/animations/on_tap_scale.dart'; import 'package:style/extensions/context_extensions.dart'; import 'package:style/indicators/circular_progress_indicator.dart'; +import 'package:style/text/app_text_style.dart'; import 'package:video_player/video_player.dart'; class VideoPreview extends StatefulWidget { final AppMedia media; + final bool showActions; - const VideoPreview({super.key, required this.media}); + const VideoPreview({super.key, required this.media, this.showActions = true}); @override State createState() => _VideoPreviewState(); } -class _VideoPreviewState extends State with SingleTickerProviderStateMixin{ +class _VideoPreviewState extends State + with SingleTickerProviderStateMixin { bool _isInitialized = false; bool _isBuffering = false; Duration _position = Duration.zero; @@ -27,7 +29,6 @@ class _VideoPreviewState extends State with SingleTickerProviderS late VideoPlayerController _videoController; late AnimationController _playPauseController; - @override void dispose() { _videoController.dispose(); @@ -55,9 +56,9 @@ class _VideoPreviewState extends State with SingleTickerProviderS super.initState(); } - _videoControllerListener() { - if (_videoController.value.position == _videoController.value.duration && _videoController.value.isCompleted ) { + if (_videoController.value.position == _videoController.value.duration && + _videoController.value.isCompleted) { _playPauseController.forward(); } _isInitialized = _videoController.value.isInitialized; @@ -96,110 +97,112 @@ class _VideoPreviewState extends State with SingleTickerProviderS } Widget _videoActions(BuildContext context) => Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OnTapScale( - onTap: () { - _videoController.seekTo( - Duration(seconds: _position.inSeconds - 10), - ); - }, - child: const Padding( - padding: - EdgeInsets.only(left: 22, right: 18, top: 18, bottom: 18), - child: Icon( - CupertinoIcons.gobackward_10, - color: Colors.white, - size: 32, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + OnTapScale( + onTap: () { + _videoController.seekTo( + Duration(seconds: _position.inSeconds - 10), + ); + }, + child: const Padding( + padding: + EdgeInsets.only(left: 22, right: 18, top: 18, bottom: 18), + child: Icon( + CupertinoIcons.gobackward_10, + color: Colors.white, + size: 32, + ), + ), ), - ), - ), - OnTapScale( - onTap: () async { - if (_videoController.value.isPlaying) { - await _playPauseController.forward(); - _videoController.pause(); - } else { - await _playPauseController.reverse(); - _videoController.play(); - } - }, - child: AnimatedIcon( - icon: AnimatedIcons.pause_play, - progress: _playPauseController, - color: Colors.white, - size: 64, - ), - ), - OnTapScale( - onTap: () { - _videoController.seekTo(Duration(seconds: _position.inSeconds + 10), - ); - }, - child: const Padding( - padding: - EdgeInsets.only(left: 18, right: 22, top: 18, bottom: 18), - child: Icon( - CupertinoIcons.goforward_10, - color: Colors.white, - size: 32, + OnTapScale( + onTap: () async { + if (_videoController.value.isPlaying) { + await _playPauseController.forward(); + _videoController.pause(); + } else { + await _playPauseController.reverse(); + _videoController.play(); + } + }, + child: AnimatedIcon( + icon: AnimatedIcons.pause_play, + progress: _playPauseController, + color: Colors.white, + size: 64, + ), ), - ), - ), - ], - ); - - Widget _videoDurationSlider(BuildContext context) => Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - SizedBox( - height: 30, - child: Material( - color: Colors.transparent, - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 4, - activeTrackColor: Colors.white, - thumbShape: SliderComponentShape.noThumb, - inactiveTrackColor: Colors.grey.shade500, - ), - child: Slider( - value: _position.inSeconds.toDouble(), - max: _maxDuration.inSeconds.toDouble(), - min: 0, - onChanged: (value) { - setState(() { - _position = Duration(seconds: value.toInt()); - }); - _videoController.seekTo(Duration(seconds: value.toInt())); - }, - ), + OnTapScale( + onTap: () { + _videoController.seekTo( + Duration(seconds: _position.inSeconds + 10), + ); + }, + child: const Padding( + padding: + EdgeInsets.only(left: 18, right: 22, top: 18, bottom: 18), + child: Icon( + CupertinoIcons.goforward_10, + color: Colors.white, + size: 32, ), ), ), - Padding( - padding: const EdgeInsets.only(left: 20, right: 20, bottom: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _position.format, - style: const TextStyle( - fontSize: 14, - color: Colors.white, + ], + ); + + Widget _videoDurationSlider(BuildContext context) => Align( + alignment: Alignment.bottomCenter, + child: Container( + color: context.colorScheme.containerHighInverse, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 30, + child: Material( + color: Colors.transparent, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 4, + activeTrackColor: Colors.white, + thumbShape: SliderComponentShape.noThumb, + inactiveTrackColor: Colors.grey.shade500, + ), + child: Slider( + value: _position.inSeconds.toDouble(), + max: _maxDuration.inSeconds.toDouble(), + min: 0, + activeColor: context.colorScheme.surfaceInverse, + inactiveColor: context.colorScheme.containerNormal, + onChanged: (value) { + setState(() { + _position = Duration(seconds: value.toInt()); + }); + _videoController + .seekTo(Duration(seconds: value.toInt())); + }, + ), ), ), - Text( - _maxDuration.format, - style: const TextStyle( - fontSize: 14, - color: Colors.white, - ), + ), + Padding( + padding: const EdgeInsets.only(left: 20, right: 20, bottom: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(_position.format, + style: AppTextStyles.caption + .copyWith(color: context.colorScheme.textPrimary)), + Text(_maxDuration.format, + style: AppTextStyles.caption + .copyWith(color: context.colorScheme.textPrimary)), + ], ), - ], - ), + ), + ], ), - ], + ), ); } diff --git a/app/lib/ui/flow/media_preview/media_preview_screen.dart b/app/lib/ui/flow/media_preview/media_preview_screen.dart index 7560bf7..74734d7 100644 --- a/app/lib/ui/flow/media_preview/media_preview_screen.dart +++ b/app/lib/ui/flow/media_preview/media_preview_screen.dart @@ -1,15 +1,22 @@ +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/assets/assets_paths.dart'; import 'package:cloud_gallery/domain/extensions/context_extensions.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/components/image_preview_screen.dart'; import 'package:cloud_gallery/ui/flow/media_preview/media_preview_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'; +import 'package:flutter_svg/svg.dart'; +import 'package:go_router/go_router.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/video_preview_screen.dart'; class MediaPreview extends ConsumerStatefulWidget { @@ -33,17 +40,31 @@ class _MediaPreviewState extends ConsumerState { void initState() { final currentIndex = widget.medias .indexWhere((element) => element.id == widget.startingMediaId); + + //initialize view notifier with initial state _provider = mediaPreviewStateNotifierProvider( - MediaPreviewState(currentIndex: currentIndex)); - _pageController = PageController(initialPage: currentIndex); + MediaPreviewState(currentIndex: currentIndex, medias: widget.medias)); notifier = ref.read(_provider.notifier); - runPostFrame(() => notifier.changeVisibleMediaIndex(currentIndex)); + + _pageController = PageController(initialPage: currentIndex); super.initState(); } + void _observeError() { + ref.listen( + _provider, + (previous, next) { + if (next.error != null) { + showErrorSnackBar(context: context, error: next.error!); + } + }, + ); + } + @override Widget build(BuildContext context) { - + _observeError(); + final medias = ref.watch(_provider.select((state) => state.medias)); return AppPage( body: Stack( children: [ @@ -53,19 +74,18 @@ class _MediaPreviewState extends ConsumerState { child: PageView.builder( onPageChanged: notifier.changeVisibleMediaIndex, controller: _pageController, - itemCount: widget.medias.length, + itemCount: medias.length, itemBuilder: (context, index) => - _preview(context: context, index: index), + _preview(context: context, media: medias[index]), ), ), - _manu(context: context), + _actions(context: context), ], ), ); } - Widget _preview({required BuildContext context, required int index}) { - final media = widget.medias[index]; + Widget _preview({required BuildContext context, required AppMedia media}) { if (media.type.isVideo) { return VideoPreview(media: media); } else if (media.type.isImage) { @@ -78,17 +98,19 @@ class _MediaPreviewState extends ConsumerState { } } - Widget _manu({required BuildContext context}) => Consumer( + Widget _actions({required BuildContext context}) => Consumer( builder: (context, ref, child) { - final state = ref.watch(_provider); + final media = ref.watch( + _provider.select((state) => state.medias[state.currentIndex])); + final showManu = + ref.watch(_provider.select((state) => state.showManu)); return AnimatedCrossFade( - crossFadeState: state.showManu - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, + crossFadeState: + showManu ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: AdaptiveAppBar( - text: widget.medias[state.currentIndex].createdTime - ?.format(context, DateFormatType.relative) ?? - '', + text: + media.createdTime?.format(context, DateFormatType.relative) ?? + '', actions: [ ActionButton( onPressed: () { @@ -97,21 +119,105 @@ class _MediaPreviewState extends ConsumerState { icon: Icon( CupertinoIcons.info, color: context.colorScheme.textSecondary, - size: 18, + size: 22, ), ), - const SizedBox(width: 8), ActionButton( - onPressed: () { - ///TODO: delete media feature + 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 { + await showDeleteAlert( + 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("Delete from Google Drive", + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textPrimary, + )), + ], + ), + ), + PopupMenuItem( + onTap: () async { + await showDeleteAlert( + 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( + "Delete from Device", + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textPrimary, + ), + ), + ], + ), + ), + ]); + } else if (media.isGoogleDriveStored && + media.driveMediaRefId != null) { + await showDeleteAlert( + context: context, + onDelete: () { + notifier.deleteMediaFromGoogleDrive( + media.driveMediaRefId); + context.pop(); + }); + } else if (media.isLocalStored) { + await showDeleteAlert( + context: context, + onDelete: () { + notifier.deleteMediaFromLocal(media.id); + context.pop(); + }); + } }, - icon: Icon( - CupertinoIcons.delete, - color: context.colorScheme.textSecondary, - size: 18, + icon: Padding( + padding: const EdgeInsets.all(4.0), + child: Icon( + CupertinoIcons.delete, + color: context.colorScheme.textSecondary, + size: 22, + ), ), ), - const SizedBox(width: 16), + if (!Platform.isIOS && !Platform.isMacOS) + const SizedBox(width: 8), ], ), secondChild: const SizedBox( @@ -124,4 +230,27 @@ class _MediaPreviewState extends ConsumerState { ); }, ); + + Future showDeleteAlert( + {required BuildContext context, required VoidCallback onDelete}) async { + await showAppAlertDialog( + context: context, + title: "Delete", + message: + "Are you sure you want to delete this media? It will be permanently removed.", + actions: [ + AppAlertAction( + title: "Cancel", + onPressed: () { + context.pop(); + }, + ), + AppAlertAction( + isDestructiveAction: true, + title: "Delete", + onPressed: onDelete, + ), + ], + ); + } } 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 index e323edd..7169f83 100644 --- a/app/lib/ui/flow/media_preview/media_preview_view_model.dart +++ b/app/lib/ui/flow/media_preview/media_preview_view_model.dart @@ -1,4 +1,5 @@ import 'package:data/models/media/media.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'; @@ -10,15 +11,17 @@ final mediaPreviewStateNotifierProvider = StateNotifierProvider.family MediaPreviewState>( (ref, initial) => MediaPreviewStateNotifier( ref.read(localMediaServiceProvider), + ref.read(googleDriveServiceProvider), initial, ), ); class MediaPreviewStateNotifier extends StateNotifier { final LocalMediaService _localMediaService; + final GoogleDriveService _googleDriveService; - MediaPreviewStateNotifier( - this._localMediaService, MediaPreviewState initialState) + MediaPreviewStateNotifier(this._localMediaService, this._googleDriveService, + MediaPreviewState initialState) : super(initialState); void changeVisibleMediaIndex(int index) { @@ -29,8 +32,20 @@ class MediaPreviewStateNotifier extends StateNotifier { state = state.copyWith(showManu: !state.showManu); } - Future deleteMedia(AppMedia appMedia) async { - await _localMediaService.deleteMedias([appMedia.id]); + 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); + } } } @@ -38,6 +53,7 @@ class MediaPreviewStateNotifier extends StateNotifier { class MediaPreviewState with _$MediaPreviewState { const factory MediaPreviewState({ Object? error, + @Default([]) List medias, @Default(0) int currentIndex, @Default(true) bool showManu, }) = _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 index d91b241..eb6f9a3 100644 --- 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 @@ -17,6 +17,7 @@ final _privateConstructorUsedError = UnsupportedError( /// @nodoc mixin _$MediaPreviewState { Object? get error => throw _privateConstructorUsedError; + List get medias => throw _privateConstructorUsedError; int get currentIndex => throw _privateConstructorUsedError; bool get showManu => throw _privateConstructorUsedError; @@ -31,7 +32,8 @@ abstract class $MediaPreviewStateCopyWith<$Res> { MediaPreviewState value, $Res Function(MediaPreviewState) then) = _$MediaPreviewStateCopyWithImpl<$Res, MediaPreviewState>; @useResult - $Res call({Object? error, int currentIndex, bool showManu}); + $Res call( + {Object? error, List medias, int currentIndex, bool showManu}); } /// @nodoc @@ -48,11 +50,16 @@ class _$MediaPreviewStateCopyWithImpl<$Res, $Val extends MediaPreviewState> @override $Res call({ Object? error = freezed, + Object? medias = null, Object? currentIndex = null, Object? showManu = 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 @@ -73,7 +80,8 @@ abstract class _$$MediaPreviewStateImplCopyWith<$Res> __$$MediaPreviewStateImplCopyWithImpl<$Res>; @override @useResult - $Res call({Object? error, int currentIndex, bool showManu}); + $Res call( + {Object? error, List medias, int currentIndex, bool showManu}); } /// @nodoc @@ -88,11 +96,16 @@ class __$$MediaPreviewStateImplCopyWithImpl<$Res> @override $Res call({ Object? error = freezed, + Object? medias = null, Object? currentIndex = null, Object? showManu = 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 @@ -109,10 +122,23 @@ class __$$MediaPreviewStateImplCopyWithImpl<$Res> class _$MediaPreviewStateImpl implements _MediaPreviewState { const _$MediaPreviewStateImpl( - {this.error, this.currentIndex = 0, this.showManu = true}); + {this.error, + final List medias = const [], + this.currentIndex = 0, + this.showManu = true}) + : _medias = medias; @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; @@ -122,7 +148,7 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { @override String toString() { - return 'MediaPreviewState(error: $error, currentIndex: $currentIndex, showManu: $showManu)'; + return 'MediaPreviewState(error: $error, medias: $medias, currentIndex: $currentIndex, showManu: $showManu)'; } @override @@ -131,6 +157,7 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { (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.showManu, showManu) || @@ -138,8 +165,12 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { } @override - int get hashCode => Object.hash(runtimeType, - const DeepCollectionEquality().hash(error), currentIndex, showManu); + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(error), + const DeepCollectionEquality().hash(_medias), + currentIndex, + showManu); @JsonKey(ignore: true) @override @@ -152,12 +183,15 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { abstract class _MediaPreviewState implements MediaPreviewState { const factory _MediaPreviewState( {final Object? error, + final List medias, final int currentIndex, final bool showManu}) = _$MediaPreviewStateImpl; @override Object? get error; @override + List get medias; + @override int get currentIndex; @override bool get showManu; diff --git a/style/lib/buttons/action_button.dart b/style/lib/buttons/action_button.dart index 5e4800b..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,7 +16,8 @@ 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)}); @@ -27,7 +28,7 @@ class ActionButton extends StatelessWidget { 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,9 +36,8 @@ class ActionButton extends StatelessWidget { } else { return IconButton( style: IconButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.padded, - backgroundColor: - backgroundColor ?? context.colorScheme.containerNormal, + tapTargetSize: tapTargetSize, + backgroundColor: backgroundColor, minimumSize: Size(size, size), ), onPressed: onPressed, diff --git a/style/lib/indicators/circular_progress_indicator.dart b/style/lib/indicators/circular_progress_indicator.dart index 756510b..bb62b68 100644 --- a/style/lib/indicators/circular_progress_indicator.dart +++ b/style/lib/indicators/circular_progress_indicator.dart @@ -18,14 +18,22 @@ 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: size / 8, + value: value, + valueColor: AlwaysStoppedAnimation( + color ?? context.colorScheme.primary, + ), + strokeCap: StrokeCap.round, + ) + : CircularProgressIndicator.adaptive( + strokeWidth: size / 8, + valueColor: AlwaysStoppedAnimation( + color ?? context.colorScheme.primary, + ), + strokeCap: StrokeCap.round, + ), ); } } From 74092639f99caf54a8d7895a1c4ae86c8a8f5649 Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Thu, 4 Apr 2024 12:09:11 +0530 Subject: [PATCH 07/20] Improve app process flow --- .../flow/home/components/app_media_item.dart | 7 +- .../multi_selection_done_button.dart | 2 +- app/lib/ui/flow/home/home_screen.dart | 14 +- .../ui/flow/home/home_screen_view_model.dart | 142 +++++++++-------- .../home/home_screen_view_model.freezed.dart | 48 +++--- .../home/home_view_model_helper_mixin.dart | 64 ++++++-- data/.flutter-plugins | 3 + data/.flutter-plugins-dependencies | 2 +- data/lib/extensions/iterable_extension.dart | 7 + data/lib/models/media/media.dart | 39 +++-- data/lib/models/media/media.freezed.dart | 149 ++++++++++++++++++ data/lib/repositories/google_drive_repo.dart | 132 ++++++++++++++++ data/lib/services/google_drive_service.dart | 5 +- data/lib/services/local_media_service.dart | 18 ++- data/pubspec.yaml | 1 + 15 files changed, 496 insertions(+), 137 deletions(-) create mode 100644 data/lib/extensions/iterable_extension.dart create mode 100644 data/lib/repositories/google_drive_repo.dart 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..26b98f7 100644 --- a/app/lib/ui/flow/home/components/app_media_item.dart +++ b/app/lib/ui/flow/home/components/app_media_item.dart @@ -1,5 +1,4 @@ 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/media/media.dart'; @@ -17,7 +16,7 @@ class AppMediaItem extends StatefulWidget { final void Function()? onTap; final void Function()? onLongTap; final bool isSelected; - final UploadStatus? status; + final AppMediaProcessStatus? status; const AppMediaItem({ super.key, @@ -171,14 +170,14 @@ class _AppMediaItemState extends State ], ), ), - if (widget.status == UploadStatus.uploading) + if (widget.status?.isProcessing ?? false) _BackgroundContainer( child: AppCircularProgressIndicator( size: 16, color: context.colorScheme.surfaceInverse, ), ), - if (widget.status == UploadStatus.waiting) + if (widget.status?.isWaiting ?? false) _BackgroundContainer( child: Icon( CupertinoIcons.time, 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 99fe330..e9a640f 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 @@ -44,7 +44,7 @@ class MultiSelectionDoneButton extends ConsumerWidget { ), title: context.l10n.back_up_on_google_drive_text, onPressed: () { - notifier.uploadMediaOnGoogleDrive(); + notifier.backUpMediaOnGoogleDrive(); context.pop(); }, ), diff --git a/app/lib/ui/flow/home/home_screen.dart b/app/lib/ui/flow/home/home_screen.dart index e6efb80..3926a86 100644 --- a/app/lib/ui/flow/home/home_screen.dart +++ b/app/lib/ui/flow/home/home_screen.dart @@ -60,7 +60,7 @@ class _HomeScreenState extends ConsumerState { titleWidget: _titleWidget(context: context), actions: [ ActionButton( - backgroundColor: context.colorScheme.containerNormalOnSurface, + backgroundColor: context.colorScheme.containerNormal, onPressed: () { AppRouter.accounts.push(context); }, @@ -81,14 +81,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 +107,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 +123,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( @@ -191,9 +191,9 @@ class _HomeScreenState extends ConsumerState { notifier.toggleMediaSelection(media); }, isSelected: selectedMedias.contains(media), - status: uploadingMedias + status: mediaProcesses .firstWhereOrNull( - (element) => element.mediaId == media.id) + (element) => element.mediaId == media.id || element.mediaId == media.driveMediaRefId) ?.status, media: media, ); 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 7fa1ca3..d2dca0a 100644 --- a/app/lib/ui/flow/home/home_screen_view_model.dart +++ b/app/lib/ui/flow/home/home_screen_view_model.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:cloud_gallery/domain/extensions/map_extensions.dart'; import 'package:data/errors/app_error.dart'; import 'package:data/models/media/media.dart'; +import 'package:data/repositories/google_drive_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'; @@ -20,15 +21,19 @@ final homeViewStateNotifier = ref.read(localMediaServiceProvider), ref.read(googleDriveServiceProvider), ref.read(authServiceProvider), + ref.read(googleDriveRepoProvider), ); }); class HomeViewStateNotifier extends StateNotifier with HomeViewModelHelperMixin { - final GoogleDriveService _googleDriveService; final AuthService _authService; + final GoogleDriveService _googleDriveService; + final GoogleDriveRepo _googleDriveRepo; final LocalMediaService _localMediaService; + StreamSubscription? _googleAccountSubscription; + StreamSubscription? _googleDriveProcessSubscription; List _uploadedMedia = []; String? _backUpFolderId; @@ -36,23 +41,61 @@ 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._googleDriveRepo) : super(const HomeViewState()) { + _listenUserGoogleAccount(); + _listenGoogleDriveProcess(); + _loadInitialMedia(); + } + + void _listenUserGoogleAccount() { _googleAccountSubscription = _authService.onGoogleAccountChange.listen((event) async { state = state.copyWith(googleAccount: event); - await loadGoogleDriveMedia(); - if (event == null) { + _googleDriveRepo.terminateAllProcess(); + if (event != null) { + _backUpFolderId = await _googleDriveService.getBackupFolderId(); + await loadGoogleDriveMedia(); + } else { + _backUpFolderId = null; _uploadedMedia.clear(); state = state.copyWith( - medias: sortMedias( - medias: removeGoogleDriveRefFromMedias(state.medias), - ), + medias: removeGoogleDriveRefFromMedias(medias: state.medias), ); } }); - _loadInitialMedia(); + } + + void _listenGoogleDriveProcess() { + _googleDriveProcessSubscription = + _googleDriveRepo.mediaProcessStream.listen((event) { + final uploadSuccessIds = event + .where((element) => + element.status == AppMediaProcessStatus.uploadingSuccess) + .map((e) => e.mediaId); + + final deleteSuccessIds = event + .where((element) => + element.status == AppMediaProcessStatus.successDelete) + .map((e) => e.mediaId); + + if (uploadSuccessIds.isNotEmpty) { + state = state.copyWith( + medias: addGoogleDriveRefInMedias( + medias: state.medias, + event: event, + uploadSuccessIds: uploadSuccessIds.toList())); + } + if (deleteSuccessIds.isNotEmpty) { + state = state.copyWith( + medias: removeGoogleDriveRefFromMedias( + medias: state.medias, + removeFromIds: deleteSuccessIds.toList())); + } + + state = state.copyWith(mediaProcesses: event); + }); } void _loadInitialMedia() async { @@ -163,7 +206,10 @@ class HomeViewStateNotifier extends StateNotifier state = state.copyWith( medias: sortMedias(medias: [ ...mergeCommonMedia( - localMedias: removeGoogleDriveRefFromMedias(state.medias), + localMedias: removeGoogleDriveRefFromMedias(medias: state.medias) + .values + .expand((element) => element) + .toList(), googleDriveMedias: uploadedMedia, ), ...googleDriveMedia @@ -189,11 +235,8 @@ class HomeViewStateNotifier extends StateNotifier .where((element) => element.sources.contains(AppMediaSource.local)) .map((e) => e.id) .toList(); - await _localMediaService.deleteMedias(medias); - state = state.copyWith( - selectedMedias: [], - ); + state = state.copyWith(selectedMedias: []); await loadLocalMedia(); } catch (e) { state = state.copyWith(error: e); @@ -202,24 +245,23 @@ class HomeViewStateNotifier extends StateNotifier Future deleteMediasFromGoogleDrive() async { try { - final medias = state.selectedMedias + final mediaGoogleDriveIds = state.selectedMedias .where( - (element) => element.sources.contains(AppMediaSource.googleDrive)) - .map((e) => e.isCommonStored ? e.driveMediaRefId ?? '' : e.id) + (element) => + element.sources.contains(AppMediaSource.googleDrive) && + element.driveMediaRefId != null, + ) + .map((e) => e.driveMediaRefId!) .toList(); - for (final media in medias) { - await _googleDriveService.deleteMedia(media); - } - state = state.copyWith( - selectedMedias: [], - ); - loadGoogleDriveMedia(); + + _googleDriveRepo.deleteMediasInGoogleDrive(mediaIds: mediaGoogleDriveIds); + state = state.copyWith(selectedMedias: []); } catch (e) { state = state.copyWith(error: e); } } - Future uploadMediaOnGoogleDrive() async { + Future backUpMediaOnGoogleDrive() async { try { if (!_authService.signedInWithGoogle) { await _authService.signInWithGoogle(); @@ -230,60 +272,28 @@ class HomeViewStateNotifier extends StateNotifier .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, - ); - _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), - ); - } + _googleDriveRepo.uploadMediasInGoogleDrive( + medias: uploadingMedias, + backUpFolderId: _backUpFolderId!, + ); - state = state.copyWith(uploadingMedias: [], selectedMedias: []); + state = state.copyWith(selectedMedias: []); } catch (error) { if (error is BackUpFolderNotFound) { _backUpFolderId = await _googleDriveService.getBackupFolderId(); - uploadMediaOnGoogleDrive(); + backUpMediaOnGoogleDrive(); return; } - state = state.copyWith(error: error, uploadingMedias: []); + state = state.copyWith(error: error); } } @override Future dispose() async { await _googleAccountSubscription?.cancel(); + await _googleDriveProcessSubscription?.cancel(); super.dispose(); } } @@ -298,6 +308,6 @@ class HomeViewState with _$HomeViewState { 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..8f09352 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 @@ -24,7 +24,7 @@ mixin _$HomeViewState { Map> get medias => throw _privateConstructorUsedError; List get selectedMedias => throw _privateConstructorUsedError; - List get uploadingMedias => + List get mediaProcesses => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -46,7 +46,7 @@ abstract class $HomeViewStateCopyWith<$Res> { String? lastLocalMediaId, Map> medias, List selectedMedias, - List uploadingMedias}); + List mediaProcesses}); } /// @nodoc @@ -69,7 +69,7 @@ class _$HomeViewStateCopyWithImpl<$Res, $Val extends HomeViewState> 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, @@ -97,10 +97,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); } } @@ -121,7 +121,7 @@ abstract class _$$HomeViewStateImplCopyWith<$Res> String? lastLocalMediaId, Map> medias, List selectedMedias, - List uploadingMedias}); + List mediaProcesses}); } /// @nodoc @@ -142,7 +142,7 @@ class __$$HomeViewStateImplCopyWithImpl<$Res> Object? lastLocalMediaId = freezed, Object? medias = null, Object? selectedMedias = null, - Object? uploadingMedias = null, + Object? mediaProcesses = null, }) { return _then(_$HomeViewStateImpl( error: freezed == error ? _value.error : error, @@ -170,10 +170,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, )); } } @@ -189,10 +189,10 @@ class _$HomeViewStateImpl implements _HomeViewState { 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; @@ -224,18 +224,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, lastLocalMediaId: $lastLocalMediaId, medias: $medias, selectedMedias: $selectedMedias, mediaProcesses: $mediaProcesses)'; } @override @@ -255,7 +255,7 @@ class _$HomeViewStateImpl implements _HomeViewState { const DeepCollectionEquality() .equals(other._selectedMedias, _selectedMedias) && const DeepCollectionEquality() - .equals(other._uploadingMedias, _uploadingMedias)); + .equals(other._mediaProcesses, _mediaProcesses)); } @override @@ -268,7 +268,7 @@ class _$HomeViewStateImpl implements _HomeViewState { lastLocalMediaId, const DeepCollectionEquality().hash(_medias), const DeepCollectionEquality().hash(_selectedMedias), - const DeepCollectionEquality().hash(_uploadingMedias)); + const DeepCollectionEquality().hash(_mediaProcesses)); @JsonKey(ignore: true) @override @@ -286,7 +286,7 @@ abstract class _HomeViewState implements HomeViewState { final String? lastLocalMediaId, final Map> medias, final List selectedMedias, - final List uploadingMedias}) = _$HomeViewStateImpl; + final List mediaProcesses}) = _$HomeViewStateImpl; @override Object? get error; @@ -303,7 +303,7 @@ abstract class _HomeViewState implements HomeViewState { @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 index c1de710..620275c 100644 --- a/app/lib/ui/flow/home/home_view_model_helper_mixin.dart +++ b/app/lib/ui/flow/home/home_view_model_helper_mixin.dart @@ -1,5 +1,6 @@ import 'package:cloud_gallery/domain/formatter/date_formatter.dart'; import 'package:collection/collection.dart'; +import 'package:data/extensions/iterable_extension.dart'; import 'package:data/models/media/media.dart'; mixin HomeViewModelHelperMixin { @@ -46,21 +47,54 @@ mixin HomeViewModelHelperMixin { ); } - List removeGoogleDriveRefFromMedias( - Map> medias) { - final allMedias = medias.values.expand((element) => element).toList(); - for (int index = 0; index < allMedias.length; index++) { - if (allMedias[index].isCommonStored) { - allMedias[index] = allMedias[index].copyWith( - sources: allMedias[index].sources.toList() - ..remove(AppMediaSource.googleDrive), - thumbnailLink: null, - driveMediaRefId: null, - ); - } else if (allMedias[index].isGoogleDriveStored) { - allMedias.removeAt(index); + Map> removeGoogleDriveRefFromMedias( + {required Map> medias, + List? removeFromIds}) { + return medias.map((key, mediaList) { + for (int index = 0; index < mediaList.length; index++) { + if (mediaList[index].isGoogleDriveStored && + (removeFromIds?.contains(mediaList[index].driveMediaRefId) ?? + true)) { + mediaList.removeAt(index); + } else if (mediaList[index].isCommonStored && + (removeFromIds?.contains(mediaList[index].driveMediaRefId) ?? + true)) { + mediaList[index] = mediaList[index].copyWith( + sources: mediaList[index].sources.toList() + ..remove(AppMediaSource.googleDrive), + thumbnailLink: null, + driveMediaRefId: null, + ); + } } - } - return allMedias; + return MapEntry(key, mediaList); + }); + } + + Map> addGoogleDriveRefInMedias( + {required Map> medias, + required List event, + required List uploadSuccessIds}) { + return medias.map((key, value) { + return MapEntry( + key, + value + .updateWhere( + where: (media) => uploadSuccessIds.contains(media.id), + update: (media) { + final res = event + .where((element) => element.mediaId == media.id) + .first + .response as AppMedia?; + return media.copyWith( + thumbnailLink: res?.thumbnailLink, + driveMediaRefId: res?.id, + sources: media.sources.toList() + ..add(AppMediaSource.googleDrive), + ); + }, + ) + .toList()); + }); } } 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 6618003..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-28 15:34:28.802695","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..6e56fb6 --- /dev/null +++ b/data/lib/extensions/iterable_extension.dart @@ -0,0 +1,7 @@ +extension IterableExtension on Iterable { + Iterable updateWhere( + {required bool Function(T element) where, + required T Function(T element) update}) { + return map((element) => where(element) ? update(element) : element).toList(); + } +} \ No newline at end of file diff --git a/data/lib/models/media/media.dart b/data/lib/models/media/media.dart index 0d7c43b..a87f361 100644 --- a/data/lib/models/media/media.dart +++ b/data/lib/models/media/media.dart @@ -10,23 +10,30 @@ 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; - } +enum AppMediaProcessStatus { + waiting, + uploading, + uploadingFailed, + uploadingSuccess, + deleting, + failedDelete, + successDelete, + none; + + bool get isProcessing => + this == AppMediaProcessStatus.uploading || + this == AppMediaProcessStatus.deleting; + + bool get isWaiting => this == AppMediaProcessStatus.waiting; +} - @override - int get hashCode => mediaId.hashCode ^ status.hashCode; +@freezed +class AppMediaProcess with _$AppMediaProcess { + const factory AppMediaProcess({ + required String mediaId, + required AppMediaProcessStatus status, + Object? response, + }) = _AppMediaProcess; } enum AppMediaType { diff --git a/data/lib/models/media/media.freezed.dart b/data/lib/models/media/media.freezed.dart index 15b6e14..5c06f98 100644 --- a/data/lib/models/media/media.freezed.dart +++ b/data/lib/models/media/media.freezed.dart @@ -14,6 +14,155 @@ 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 _$AppMediaProcess { + String get mediaId => throw _privateConstructorUsedError; + AppMediaProcessStatus get status => throw _privateConstructorUsedError; + Object? get response => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $AppMediaProcessCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AppMediaProcessCopyWith<$Res> { + factory $AppMediaProcessCopyWith( + AppMediaProcess value, $Res Function(AppMediaProcess) then) = + _$AppMediaProcessCopyWithImpl<$Res, AppMediaProcess>; + @useResult + $Res call({String mediaId, AppMediaProcessStatus status, Object? response}); +} + +/// @nodoc +class _$AppMediaProcessCopyWithImpl<$Res, $Val extends AppMediaProcess> + implements $AppMediaProcessCopyWith<$Res> { + _$AppMediaProcessCopyWithImpl(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? mediaId = null, + Object? status = null, + Object? response = freezed, + }) { + return _then(_value.copyWith( + mediaId: null == mediaId + ? _value.mediaId + : mediaId // ignore: cast_nullable_to_non_nullable + as String, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as AppMediaProcessStatus, + response: freezed == response ? _value.response : response, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$AppMediaProcessImplCopyWith<$Res> + implements $AppMediaProcessCopyWith<$Res> { + factory _$$AppMediaProcessImplCopyWith(_$AppMediaProcessImpl value, + $Res Function(_$AppMediaProcessImpl) then) = + __$$AppMediaProcessImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String mediaId, AppMediaProcessStatus status, Object? response}); +} + +/// @nodoc +class __$$AppMediaProcessImplCopyWithImpl<$Res> + extends _$AppMediaProcessCopyWithImpl<$Res, _$AppMediaProcessImpl> + implements _$$AppMediaProcessImplCopyWith<$Res> { + __$$AppMediaProcessImplCopyWithImpl( + _$AppMediaProcessImpl _value, $Res Function(_$AppMediaProcessImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? mediaId = null, + Object? status = null, + Object? response = freezed, + }) { + return _then(_$AppMediaProcessImpl( + mediaId: null == mediaId + ? _value.mediaId + : mediaId // ignore: cast_nullable_to_non_nullable + as String, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as AppMediaProcessStatus, + response: freezed == response ? _value.response : response, + )); + } +} + +/// @nodoc + +class _$AppMediaProcessImpl implements _AppMediaProcess { + const _$AppMediaProcessImpl( + {required this.mediaId, required this.status, this.response}); + + @override + final String mediaId; + @override + final AppMediaProcessStatus status; + @override + final Object? response; + + @override + String toString() { + return 'AppMediaProcess(mediaId: $mediaId, status: $status, response: $response)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AppMediaProcessImpl && + (identical(other.mediaId, mediaId) || other.mediaId == mediaId) && + (identical(other.status, status) || other.status == status) && + const DeepCollectionEquality().equals(other.response, response)); + } + + @override + int get hashCode => Object.hash(runtimeType, mediaId, status, + const DeepCollectionEquality().hash(response)); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$AppMediaProcessImplCopyWith<_$AppMediaProcessImpl> get copyWith => + __$$AppMediaProcessImplCopyWithImpl<_$AppMediaProcessImpl>( + this, _$identity); +} + +abstract class _AppMediaProcess implements AppMediaProcess { + const factory _AppMediaProcess( + {required final String mediaId, + required final AppMediaProcessStatus status, + final Object? response}) = _$AppMediaProcessImpl; + + @override + String get mediaId; + @override + AppMediaProcessStatus get status; + @override + Object? get response; + @override + @JsonKey(ignore: true) + _$$AppMediaProcessImplCopyWith<_$AppMediaProcessImpl> get copyWith => + throw _privateConstructorUsedError; +} + AppMedia _$AppMediaFromJson(Map json) { return _AppMedia.fromJson(json); } diff --git a/data/lib/repositories/google_drive_repo.dart b/data/lib/repositories/google_drive_repo.dart new file mode 100644 index 0000000..4ab9241 --- /dev/null +++ b/data/lib/repositories/google_drive_repo.dart @@ -0,0 +1,132 @@ +import 'dart:async'; +import 'package:data/extensions/iterable_extension.dart'; +import 'package:data/services/google_drive_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/media/media.dart'; + +final googleDriveRepoProvider = Provider((ref) { + return GoogleDriveRepo(ref.read(googleDriveServiceProvider)); +}); + +class GoogleDriveRepo { + final GoogleDriveService _googleDriveService; + + final StreamController> _mediaProcessController = + StreamController>.broadcast(); + + final Set _mediaProcessValue = {}; + + void _updateMediaProcess(Iterable process) { + _mediaProcessValue.clear(); + _mediaProcessValue.addAll(process); + _mediaProcessController.add(mediaProcessValue); + } + + Stream> get mediaProcessStream => + _mediaProcessController.stream; + + List get mediaProcessValue => _mediaProcessValue.toList(); + + GoogleDriveRepo(this._googleDriveService); + + Future uploadMediasInGoogleDrive( + {required List medias, required String backUpFolderId}) async { + _updateMediaProcess([..._mediaProcessValue.toList(), + ...medias.map((media) => AppMediaProcess( + mediaId: media.id, status: AppMediaProcessStatus.waiting))]); + + for (final media in medias) { + //Skip process if queue does not contain mediaId + if (!_mediaProcessValue.map((e) => e.mediaId).contains(media.id)) { + continue; + } + + try { + _updateMediaProcess(_mediaProcessValue.updateWhere( + where: (process) => process.mediaId == media.id, + update: (element) => + element.copyWith(status: AppMediaProcessStatus.uploading), + )); + + final uploadedMedia = await _googleDriveService.uploadInGoogleDrive( + media: media, + folderID: backUpFolderId, + ); + + _updateMediaProcess( + _mediaProcessValue.updateWhere( + where: (process) => process.mediaId == media.id, + update: (element) => element.copyWith( + status: AppMediaProcessStatus.uploadingSuccess, + response: uploadedMedia, + ), + ), + ); + } catch (error) { + _updateMediaProcess(_mediaProcessValue.updateWhere( + where: (process) => process.mediaId == media.id, + update: (element) => element.copyWith( + status: AppMediaProcessStatus.uploadingFailed))); + } + } + // Remove failed processes to upload process + final mediaIds = medias.map((e) => e.id); + _updateMediaProcess(_mediaProcessValue.toList() + ..removeWhere((process) => mediaIds.contains(process.mediaId))); + } + + void deleteMediasInGoogleDrive({required List mediaIds}) async { + _updateMediaProcess([ + ..._mediaProcessValue.toList(), + ...mediaIds.map((id) => + AppMediaProcess(mediaId: id, status: AppMediaProcessStatus.waiting)) + + ]); + + for (final mediaId in mediaIds) { + //Skip process if queue does not contain mediaId + if (!_mediaProcessValue.map((e) => e.mediaId).contains(mediaId)) { + continue; + } + try { + _updateMediaProcess( + _mediaProcessValue.updateWhere( + where: (process) => process.mediaId == mediaId, + update: (element) => + element.copyWith(status: AppMediaProcessStatus.deleting), + ), + ); + + await _googleDriveService.deleteMedia(mediaId); + + _updateMediaProcess(_mediaProcessValue.updateWhere( + where: (process) => process.mediaId == mediaId, + update: (element) => + element.copyWith(status: AppMediaProcessStatus.successDelete))); + } catch (error) { + _updateMediaProcess(_mediaProcessValue.updateWhere( + where: (process) => process.mediaId == mediaId, + update: (element) => + element.copyWith(status: AppMediaProcessStatus.failedDelete))); + } + } + // Remove failed processes to upload process + _updateMediaProcess(_mediaProcessValue.toList() + ..removeWhere((process) => mediaIds.contains(process.mediaId))); + } + + void terminateAllProcess() { + _mediaProcessValue.clear(); + _mediaProcessController.add(_mediaProcessValue.toList()); + } + + void terminateSingleProcess(String id) { + _mediaProcessValue.removeWhere((element) => element.mediaId == id); + _mediaProcessController.add(_mediaProcessValue.toList()); + } + + void dispose() { + _mediaProcessValue.clear(); + _mediaProcessController.close(); + } +} diff --git a/data/lib/services/google_drive_service.dart b/data/lib/services/google_drive_service.dart index b613a8f..9c76dc7 100644 --- a/data/lib/services/google_drive_service.dart +++ b/data/lib/services/google_drive_service.dart @@ -82,7 +82,7 @@ class GoogleDriveService { } } - Future uploadInGoogleDrive( + Future uploadInGoogleDrive( {required String folderID, required AppMedia media}) async { final localFile = File(media.path); try { @@ -93,10 +93,11 @@ class GoogleDriveService { description: media.path, parents: [folderID], ); - await driveApi.files.create( + final googleDriveFile = await driveApi.files.create( file, uploadMedia: drive.Media(localFile.openRead(), localFile.lengthSync()), ); + 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 c7f1ec1..88dbebc 100644 --- a/data/lib/services/local_media_service.dart +++ b/data/lib/services/local_media_service.dart @@ -1,8 +1,10 @@ +import 'dart:io'; +import 'dart:typed_data'; import 'package:collection/collection.dart'; import 'package:data/models/media/media.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( @@ -53,4 +55,18 @@ class LocalMediaService { throw AppError.fromError(e); } } + + Future saveMedia(AppMedia media, Uint8List bytes) async { + if (media.type.isVideo) { + final tempDir = await getTemporaryDirectory(); + final tempVideoFile = File('${tempDir.path}/temp_video.mp4'); + await tempVideoFile.writeAsBytes(bytes); + return await PhotoManager.editor.saveVideo(tempVideoFile, + title: media.name ?? "${DateTime.now()}_cloud_gallery"); + } else if (media.type.isImage) { + return await PhotoManager.editor.saveImage(bytes, + title: media.name ?? "${DateTime.now()}_cloud_gallery"); + } + return null; + } } 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 From 6375a3fd3276ee5a2f8926a825d73653d6b04902 Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Fri, 5 Apr 2024 10:45:08 +0530 Subject: [PATCH 08/20] Manage video with one controller --- app/lib/ui/flow/home/home_screen.dart | 3 +- .../video_actions.dart | 69 ++++++ .../video_duration_slider.dart | 77 +++++++ .../components/video_preview_screen.dart | 208 ------------------ .../media_preview/media_preview_screen.dart | 183 +++++++++++++-- .../media_preview_view_model.dart | 31 +++ .../media_preview_view_model.freezed.dart | 135 +++++++++++- app/lib/ui/navigation/app_router.dart | 4 +- style/lib/animations/animated_icon.dart | 79 +++++++ .../lib/animations/cross_fade_animation.dart | 32 +++ 10 files changed, 581 insertions(+), 240 deletions(-) create mode 100644 app/lib/ui/flow/media_preview/components/video_player_components/video_actions.dart create mode 100644 app/lib/ui/flow/media_preview/components/video_player_components/video_duration_slider.dart delete mode 100644 app/lib/ui/flow/media_preview/components/video_preview_screen.dart create mode 100644 style/lib/animations/animated_icon.dart create mode 100644 style/lib/animations/cross_fade_animation.dart diff --git a/app/lib/ui/flow/home/home_screen.dart b/app/lib/ui/flow/home/home_screen.dart index 3926a86..0960190 100644 --- a/app/lib/ui/flow/home/home_screen.dart +++ b/app/lib/ui/flow/home/home_screen.dart @@ -60,6 +60,7 @@ class _HomeScreenState extends ConsumerState { titleWidget: _titleWidget(context: context), actions: [ ActionButton( + size: 36, backgroundColor: context.colorScheme.containerNormal, onPressed: () { AppRouter.accounts.push(context); @@ -183,7 +184,7 @@ class _HomeScreenState extends ConsumerState { medias: medias.values .expand((element) => element) .toList(), - startingMediaId: media.id) + startFrom: media.id) .push(context); } }, 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..6770785 --- /dev/null +++ b/app/lib/ui/flow/media_preview/components/video_player_components/video_duration_slider.dart @@ -0,0 +1,77 @@ +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 Duration position; + + const VideoDurationSlider( + {super.key, + required this.showSlider, + required this.duration, + required this.position, + 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, + onChanged: (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/components/video_preview_screen.dart b/app/lib/ui/flow/media_preview/components/video_preview_screen.dart deleted file mode 100644 index cb58456..0000000 --- a/app/lib/ui/flow/media_preview/components/video_preview_screen.dart +++ /dev/null @@ -1,208 +0,0 @@ -import 'dart:io'; -import 'package:cloud_gallery/domain/formatter/duration_formatter.dart'; -import 'package:data/models/media/media.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:style/animations/on_tap_scale.dart'; -import 'package:style/extensions/context_extensions.dart'; -import 'package:style/indicators/circular_progress_indicator.dart'; -import 'package:style/text/app_text_style.dart'; -import 'package:video_player/video_player.dart'; - -class VideoPreview extends StatefulWidget { - final AppMedia media; - final bool showActions; - - const VideoPreview({super.key, required this.media, this.showActions = true}); - - @override - State createState() => _VideoPreviewState(); -} - -class _VideoPreviewState extends State - with SingleTickerProviderStateMixin { - bool _isInitialized = false; - bool _isBuffering = false; - Duration _position = Duration.zero; - Duration _maxDuration = Duration.zero; - - late VideoPlayerController _videoController; - late AnimationController _playPauseController; - - @override - void dispose() { - _videoController.dispose(); - _playPauseController.dispose(); - super.dispose(); - } - - @override - void initState() { - if (widget.media.sources.contains(AppMediaSource.local)) { - _videoController = VideoPlayerController.file(File(widget.media.path)) - ..initialize().then((_) { - setState(() {}); - }); - } - _playPauseController = AnimationController( - vsync: this, - value: 0, - duration: const Duration( - milliseconds: 300, - ), - ); - _videoController.play(); - _videoController.addListener(_videoControllerListener); - super.initState(); - } - - _videoControllerListener() { - if (_videoController.value.position == _videoController.value.duration && - _videoController.value.isCompleted) { - _playPauseController.forward(); - } - _isInitialized = _videoController.value.isInitialized; - _isBuffering = _videoController.value.isBuffering; - _position = _videoController.value.position; - _maxDuration = _videoController.value.duration; - setState(() {}); - } - - @override - Widget build(BuildContext context) { - return _buildVideoPlayer(context); - } - - Widget _buildVideoPlayer(BuildContext context) { - return Stack( - fit: StackFit.expand, - children: [ - if (_isInitialized) - Center( - child: AspectRatio( - aspectRatio: _videoController.value.aspectRatio, - child: VideoPlayer(_videoController), - ), - ), - if (!_isInitialized || _isBuffering) - Center( - child: AppCircularProgressIndicator( - color: context.colorScheme.onPrimary, - ), - ), - _videoActions(context), - _videoDurationSlider(context), - ], - ); - } - - Widget _videoActions(BuildContext context) => Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OnTapScale( - onTap: () { - _videoController.seekTo( - Duration(seconds: _position.inSeconds - 10), - ); - }, - child: const Padding( - padding: - EdgeInsets.only(left: 22, right: 18, top: 18, bottom: 18), - child: Icon( - CupertinoIcons.gobackward_10, - color: Colors.white, - size: 32, - ), - ), - ), - OnTapScale( - onTap: () async { - if (_videoController.value.isPlaying) { - await _playPauseController.forward(); - _videoController.pause(); - } else { - await _playPauseController.reverse(); - _videoController.play(); - } - }, - child: AnimatedIcon( - icon: AnimatedIcons.pause_play, - progress: _playPauseController, - color: Colors.white, - size: 64, - ), - ), - OnTapScale( - onTap: () { - _videoController.seekTo( - Duration(seconds: _position.inSeconds + 10), - ); - }, - child: const Padding( - padding: - EdgeInsets.only(left: 18, right: 22, top: 18, bottom: 18), - child: Icon( - CupertinoIcons.goforward_10, - color: Colors.white, - size: 32, - ), - ), - ), - ], - ); - - Widget _videoDurationSlider(BuildContext context) => Align( - alignment: Alignment.bottomCenter, - child: Container( - color: context.colorScheme.containerHighInverse, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 30, - child: Material( - color: Colors.transparent, - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 4, - activeTrackColor: Colors.white, - thumbShape: SliderComponentShape.noThumb, - inactiveTrackColor: Colors.grey.shade500, - ), - child: Slider( - value: _position.inSeconds.toDouble(), - max: _maxDuration.inSeconds.toDouble(), - min: 0, - activeColor: context.colorScheme.surfaceInverse, - inactiveColor: context.colorScheme.containerNormal, - onChanged: (value) { - setState(() { - _position = Duration(seconds: value.toInt()); - }); - _videoController - .seekTo(Duration(seconds: value.toInt())); - }, - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(left: 20, right: 20, bottom: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(_position.format, - style: AppTextStyles.caption - .copyWith(color: context.colorScheme.textPrimary)), - Text(_maxDuration.format, - style: AppTextStyles.caption - .copyWith(color: context.colorScheme.textPrimary)), - ], - ), - ), - ], - ), - ), - ); -} diff --git a/app/lib/ui/flow/media_preview/media_preview_screen.dart b/app/lib/ui/flow/media_preview/media_preview_screen.dart index 74734d7..f16dca8 100644 --- a/app/lib/ui/flow/media_preview/media_preview_screen.dart +++ b/app/lib/ui/flow/media_preview/media_preview_screen.dart @@ -4,8 +4,10 @@ import 'package:cloud_gallery/components/error_view.dart'; import 'package:cloud_gallery/components/snack_bar.dart'; import 'package:cloud_gallery/domain/assets/assets_paths.dart'; import 'package:cloud_gallery/domain/extensions/context_extensions.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/components/image_preview_screen.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:flutter/cupertino.dart'; @@ -15,16 +17,19 @@ import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.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'; +import 'package:video_player/video_player.dart'; import '../../../components/app_dialog.dart'; -import 'components/video_preview_screen.dart'; +import 'package:style/animations/cross_fade_animation.dart'; +import 'components/video_player_components/video_duration_slider.dart'; class MediaPreview extends ConsumerStatefulWidget { final List medias; - final String startingMediaId; + final String startFrom; const MediaPreview( - {super.key, required this.medias, required this.startingMediaId}); + {super.key, required this.medias, required this.startFrom}); @override ConsumerState createState() => _MediaPreviewState(); @@ -36,35 +41,91 @@ class _MediaPreviewState extends ConsumerState { late PageController _pageController; late MediaPreviewStateNotifier notifier; + VideoPlayerController? _videoPlayerController; + @override void initState() { - final currentIndex = widget.medias - .indexWhere((element) => element.id == widget.startingMediaId); + 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); + _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)); + } 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, + _provider.select((value) => value.error), (previous, next) { - if (next.error != null) { - showErrorSnackBar(context: context, error: next.error!); + 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)); + return AppPage( body: Stack( children: [ @@ -80,14 +141,35 @@ class _MediaPreviewState extends ConsumerState { ), ), _actions(context: context), + _videoActions(context), + _videoDurationSlider(context), ], ), ); } Widget _preview({required BuildContext context, required AppMedia media}) { - if (media.type.isVideo) { - return VideoPreview(media: 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 AspectRatio( + aspectRatio: _videoPlayerController!.value.aspectRatio, + child: VideoPlayer(_videoPlayerController!), + ); + } + }), + ); } else if (media.type.isImage) { return ImagePreview(media: media); } else { @@ -104,10 +186,9 @@ class _MediaPreviewState extends ConsumerState { _provider.select((state) => state.medias[state.currentIndex])); final showManu = ref.watch(_provider.select((state) => state.showManu)); - return AnimatedCrossFade( - crossFadeState: - showManu ? CrossFadeState.showFirst : CrossFadeState.showSecond, - firstChild: AdaptiveAppBar( + return CrossFadeAnimation( + showChild: showManu, + child: AdaptiveAppBar( text: media.createdTime?.format(context, DateFormatType.relative) ?? '', @@ -220,17 +301,75 @@ class _MediaPreviewState extends ConsumerState { const SizedBox(width: 8), ], ), - secondChild: const SizedBox( - width: double.infinity, - ), - firstCurve: Curves.easeInOut, - secondCurve: Curves.easeInOut, - duration: const Duration(milliseconds: 200), - reverseDuration: const Duration(milliseconds: 200), ); }, ); + Widget _videoActions(BuildContext context) => Consumer( + builder: (context, ref, child) { + final ({ + bool showActions, + bool isPlaying, + Duration position, + }) state = ref.watch(_provider.select((state) => ( + showActions: state.showManu && + 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.showManu && + 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, + onChanged: (duration) { + notifier.updateVideoPosition(duration); + _videoPlayerController?.seekTo(duration); + }, + ); + }); + Future showDeleteAlert( {required BuildContext context, required VoidCallback onDelete}) async { await showAppAlertDialog( 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 index 7169f83..ba54b9e 100644 --- a/app/lib/ui/flow/media_preview/media_preview_view_model.dart +++ b/app/lib/ui/flow/media_preview/media_preview_view_model.dart @@ -47,6 +47,32 @@ class MediaPreviewStateNotifier extends StateNotifier { state = state.copyWith(error: error); } } + + + 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); + } } @freezed @@ -56,5 +82,10 @@ class MediaPreviewState with _$MediaPreviewState { @Default([]) List medias, @Default(0) int currentIndex, @Default(true) bool showManu, + @Default(false) bool isVideoInitialized, + @Default(false) bool isVideoBuffering, + @Default(Duration.zero) Duration videoPosition, + @Default(Duration.zero) Duration videoMaxDuration, + @Default(false) bool isVideoPlaying, }) = _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 index eb6f9a3..8bab55a 100644 --- 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 @@ -20,6 +20,11 @@ mixin _$MediaPreviewState { List get medias => throw _privateConstructorUsedError; int get currentIndex => throw _privateConstructorUsedError; bool get showManu => 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; @JsonKey(ignore: true) $MediaPreviewStateCopyWith get copyWith => @@ -33,7 +38,15 @@ abstract class $MediaPreviewStateCopyWith<$Res> { _$MediaPreviewStateCopyWithImpl<$Res, MediaPreviewState>; @useResult $Res call( - {Object? error, List medias, int currentIndex, bool showManu}); + {Object? error, + List medias, + int currentIndex, + bool showManu, + bool isVideoInitialized, + bool isVideoBuffering, + Duration videoPosition, + Duration videoMaxDuration, + bool isVideoPlaying}); } /// @nodoc @@ -53,6 +66,11 @@ class _$MediaPreviewStateCopyWithImpl<$Res, $Val extends MediaPreviewState> Object? medias = null, Object? currentIndex = null, Object? showManu = null, + Object? isVideoInitialized = null, + Object? isVideoBuffering = null, + Object? videoPosition = null, + Object? videoMaxDuration = null, + Object? isVideoPlaying = null, }) { return _then(_value.copyWith( error: freezed == error ? _value.error : error, @@ -68,6 +86,26 @@ class _$MediaPreviewStateCopyWithImpl<$Res, $Val extends MediaPreviewState> ? _value.showManu : showManu // 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, ) as $Val); } } @@ -81,7 +119,15 @@ abstract class _$$MediaPreviewStateImplCopyWith<$Res> @override @useResult $Res call( - {Object? error, List medias, int currentIndex, bool showManu}); + {Object? error, + List medias, + int currentIndex, + bool showManu, + bool isVideoInitialized, + bool isVideoBuffering, + Duration videoPosition, + Duration videoMaxDuration, + bool isVideoPlaying}); } /// @nodoc @@ -99,6 +145,11 @@ class __$$MediaPreviewStateImplCopyWithImpl<$Res> Object? medias = null, Object? currentIndex = null, Object? showManu = null, + Object? isVideoInitialized = null, + Object? isVideoBuffering = null, + Object? videoPosition = null, + Object? videoMaxDuration = null, + Object? isVideoPlaying = null, }) { return _then(_$MediaPreviewStateImpl( error: freezed == error ? _value.error : error, @@ -114,6 +165,26 @@ class __$$MediaPreviewStateImplCopyWithImpl<$Res> ? _value.showManu : showManu // 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, )); } } @@ -125,7 +196,12 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { {this.error, final List medias = const [], this.currentIndex = 0, - this.showManu = true}) + this.showManu = true, + this.isVideoInitialized = false, + this.isVideoBuffering = false, + this.videoPosition = Duration.zero, + this.videoMaxDuration = Duration.zero, + this.isVideoPlaying = false}) : _medias = medias; @override @@ -145,10 +221,25 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { @override @JsonKey() final bool showManu; + @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; @override String toString() { - return 'MediaPreviewState(error: $error, medias: $medias, currentIndex: $currentIndex, showManu: $showManu)'; + return 'MediaPreviewState(error: $error, medias: $medias, currentIndex: $currentIndex, showManu: $showManu, isVideoInitialized: $isVideoInitialized, isVideoBuffering: $isVideoBuffering, videoPosition: $videoPosition, videoMaxDuration: $videoMaxDuration, isVideoPlaying: $isVideoPlaying)'; } @override @@ -161,7 +252,17 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { (identical(other.currentIndex, currentIndex) || other.currentIndex == currentIndex) && (identical(other.showManu, showManu) || - other.showManu == showManu)); + other.showManu == showManu) && + (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)); } @override @@ -170,7 +271,12 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { const DeepCollectionEquality().hash(error), const DeepCollectionEquality().hash(_medias), currentIndex, - showManu); + showManu, + isVideoInitialized, + isVideoBuffering, + videoPosition, + videoMaxDuration, + isVideoPlaying); @JsonKey(ignore: true) @override @@ -185,7 +291,12 @@ abstract class _MediaPreviewState implements MediaPreviewState { {final Object? error, final List medias, final int currentIndex, - final bool showManu}) = _$MediaPreviewStateImpl; + final bool showManu, + final bool isVideoInitialized, + final bool isVideoBuffering, + final Duration videoPosition, + final Duration videoMaxDuration, + final bool isVideoPlaying}) = _$MediaPreviewStateImpl; @override Object? get error; @@ -196,6 +307,16 @@ abstract class _MediaPreviewState implements MediaPreviewState { @override bool get showManu; @override + bool get isVideoInitialized; + @override + bool get isVideoBuffering; + @override + Duration get videoPosition; + @override + Duration get videoMaxDuration; + @override + bool get isVideoPlaying; + @override @JsonKey(ignore: true) _$$MediaPreviewStateImplCopyWith<_$MediaPreviewStateImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/app/lib/ui/navigation/app_router.dart b/app/lib/ui/navigation/app_router.dart index 6cd04ac..77ac371 100644 --- a/app/lib/ui/navigation/app_router.dart +++ b/app/lib/ui/navigation/app_router.dart @@ -24,12 +24,12 @@ class AppRouter { ); static AppRoute preview( - {required List medias, required String startingMediaId}) => + {required List medias, required String startFrom}) => AppRoute( AppRoutePath.preview, builder: (context) => MediaPreview( medias: medias, - startingMediaId: startingMediaId, + startFrom: startFrom, ), ); 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), + ); + } +} From 9fb140e9adf6d4c3bdf1b57bd60c747de1cb1fda Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Fri, 5 Apr 2024 11:01:20 +0530 Subject: [PATCH 09/20] Resolve some minor issues --- data/lib/repositories/google_drive_repo.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/data/lib/repositories/google_drive_repo.dart b/data/lib/repositories/google_drive_repo.dart index 4ab9241..e9af203 100644 --- a/data/lib/repositories/google_drive_repo.dart +++ b/data/lib/repositories/google_drive_repo.dart @@ -14,11 +14,10 @@ class GoogleDriveRepo { final StreamController> _mediaProcessController = StreamController>.broadcast(); - final Set _mediaProcessValue = {}; + Set _mediaProcessValue = {}; void _updateMediaProcess(Iterable process) { - _mediaProcessValue.clear(); - _mediaProcessValue.addAll(process); + _mediaProcessValue = process.toSet(); _mediaProcessController.add(mediaProcessValue); } From 3c87a78cbd0fbd91ddbaa56305412b826004545b Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Fri, 5 Apr 2024 11:01:36 +0530 Subject: [PATCH 10/20] Resolve some minor issues --- .../home/home_view_model_helper_mixin.dart | 1 - .../media_preview/media_preview_screen.dart | 8 ++-- .../media_preview_view_model.dart | 6 +-- .../media_preview_view_model.freezed.dart | 38 +++++++++---------- 4 files changed, 26 insertions(+), 27 deletions(-) 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 index 620275c..816c07b 100644 --- a/app/lib/ui/flow/home/home_view_model_helper_mixin.dart +++ b/app/lib/ui/flow/home/home_view_model_helper_mixin.dart @@ -21,7 +21,6 @@ mixin HomeViewModelHelperMixin { // 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); diff --git a/app/lib/ui/flow/media_preview/media_preview_screen.dart b/app/lib/ui/flow/media_preview/media_preview_screen.dart index f16dca8..54c3ff6 100644 --- a/app/lib/ui/flow/media_preview/media_preview_screen.dart +++ b/app/lib/ui/flow/media_preview/media_preview_screen.dart @@ -131,7 +131,7 @@ class _MediaPreviewState extends ConsumerState { children: [ GestureDetector( behavior: HitTestBehavior.opaque, - onTap: notifier.toggleManu, + onTap: notifier.toggleActionVisibility, child: PageView.builder( onPageChanged: notifier.changeVisibleMediaIndex, controller: _pageController, @@ -185,7 +185,7 @@ class _MediaPreviewState extends ConsumerState { final media = ref.watch( _provider.select((state) => state.medias[state.currentIndex])); final showManu = - ref.watch(_provider.select((state) => state.showManu)); + ref.watch(_provider.select((state) => state.showActions)); return CrossFadeAnimation( showChild: showManu, child: AdaptiveAppBar( @@ -312,7 +312,7 @@ class _MediaPreviewState extends ConsumerState { bool isPlaying, Duration position, }) state = ref.watch(_provider.select((state) => ( - showActions: state.showManu && + showActions: state.showActions && state.medias[state.currentIndex].type.isVideo && state.isVideoInitialized, isPlaying: state.isVideoPlaying, @@ -352,7 +352,7 @@ class _MediaPreviewState extends ConsumerState { Duration duration, Duration position }) state = ref.watch(_provider.select((state) => ( - showDurationSlider: state.showManu && + showDurationSlider: state.showActions && state.medias[state.currentIndex].type.isVideo && state.isVideoInitialized, duration: state.videoMaxDuration, 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 index ba54b9e..38cf592 100644 --- a/app/lib/ui/flow/media_preview/media_preview_view_model.dart +++ b/app/lib/ui/flow/media_preview/media_preview_view_model.dart @@ -28,8 +28,8 @@ class MediaPreviewStateNotifier extends StateNotifier { state = state.copyWith(currentIndex: index); } - void toggleManu() { - state = state.copyWith(showManu: !state.showManu); + void toggleActionVisibility() { + state = state.copyWith(showActions: !state.showActions); } Future deleteMediaFromLocal(String id) async { @@ -81,7 +81,7 @@ class MediaPreviewState with _$MediaPreviewState { Object? error, @Default([]) List medias, @Default(0) int currentIndex, - @Default(true) bool showManu, + @Default(true) bool showActions, @Default(false) bool isVideoInitialized, @Default(false) bool isVideoBuffering, @Default(Duration.zero) Duration videoPosition, 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 index 8bab55a..44c8737 100644 --- 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 @@ -19,7 +19,7 @@ mixin _$MediaPreviewState { Object? get error => throw _privateConstructorUsedError; List get medias => throw _privateConstructorUsedError; int get currentIndex => throw _privateConstructorUsedError; - bool get showManu => throw _privateConstructorUsedError; + bool get showActions => throw _privateConstructorUsedError; bool get isVideoInitialized => throw _privateConstructorUsedError; bool get isVideoBuffering => throw _privateConstructorUsedError; Duration get videoPosition => throw _privateConstructorUsedError; @@ -41,7 +41,7 @@ abstract class $MediaPreviewStateCopyWith<$Res> { {Object? error, List medias, int currentIndex, - bool showManu, + bool showActions, bool isVideoInitialized, bool isVideoBuffering, Duration videoPosition, @@ -65,7 +65,7 @@ class _$MediaPreviewStateCopyWithImpl<$Res, $Val extends MediaPreviewState> Object? error = freezed, Object? medias = null, Object? currentIndex = null, - Object? showManu = null, + Object? showActions = null, Object? isVideoInitialized = null, Object? isVideoBuffering = null, Object? videoPosition = null, @@ -82,9 +82,9 @@ class _$MediaPreviewStateCopyWithImpl<$Res, $Val extends MediaPreviewState> ? _value.currentIndex : currentIndex // ignore: cast_nullable_to_non_nullable as int, - showManu: null == showManu - ? _value.showManu - : showManu // ignore: cast_nullable_to_non_nullable + showActions: null == showActions + ? _value.showActions + : showActions // ignore: cast_nullable_to_non_nullable as bool, isVideoInitialized: null == isVideoInitialized ? _value.isVideoInitialized @@ -122,7 +122,7 @@ abstract class _$$MediaPreviewStateImplCopyWith<$Res> {Object? error, List medias, int currentIndex, - bool showManu, + bool showActions, bool isVideoInitialized, bool isVideoBuffering, Duration videoPosition, @@ -144,7 +144,7 @@ class __$$MediaPreviewStateImplCopyWithImpl<$Res> Object? error = freezed, Object? medias = null, Object? currentIndex = null, - Object? showManu = null, + Object? showActions = null, Object? isVideoInitialized = null, Object? isVideoBuffering = null, Object? videoPosition = null, @@ -161,9 +161,9 @@ class __$$MediaPreviewStateImplCopyWithImpl<$Res> ? _value.currentIndex : currentIndex // ignore: cast_nullable_to_non_nullable as int, - showManu: null == showManu - ? _value.showManu - : showManu // ignore: cast_nullable_to_non_nullable + showActions: null == showActions + ? _value.showActions + : showActions // ignore: cast_nullable_to_non_nullable as bool, isVideoInitialized: null == isVideoInitialized ? _value.isVideoInitialized @@ -196,7 +196,7 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { {this.error, final List medias = const [], this.currentIndex = 0, - this.showManu = true, + this.showActions = true, this.isVideoInitialized = false, this.isVideoBuffering = false, this.videoPosition = Duration.zero, @@ -220,7 +220,7 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { final int currentIndex; @override @JsonKey() - final bool showManu; + final bool showActions; @override @JsonKey() final bool isVideoInitialized; @@ -239,7 +239,7 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { @override String toString() { - return 'MediaPreviewState(error: $error, medias: $medias, currentIndex: $currentIndex, showManu: $showManu, isVideoInitialized: $isVideoInitialized, isVideoBuffering: $isVideoBuffering, videoPosition: $videoPosition, videoMaxDuration: $videoMaxDuration, isVideoPlaying: $isVideoPlaying)'; + return 'MediaPreviewState(error: $error, medias: $medias, currentIndex: $currentIndex, showActions: $showActions, isVideoInitialized: $isVideoInitialized, isVideoBuffering: $isVideoBuffering, videoPosition: $videoPosition, videoMaxDuration: $videoMaxDuration, isVideoPlaying: $isVideoPlaying)'; } @override @@ -251,8 +251,8 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { const DeepCollectionEquality().equals(other._medias, _medias) && (identical(other.currentIndex, currentIndex) || other.currentIndex == currentIndex) && - (identical(other.showManu, showManu) || - other.showManu == showManu) && + (identical(other.showActions, showActions) || + other.showActions == showActions) && (identical(other.isVideoInitialized, isVideoInitialized) || other.isVideoInitialized == isVideoInitialized) && (identical(other.isVideoBuffering, isVideoBuffering) || @@ -271,7 +271,7 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { const DeepCollectionEquality().hash(error), const DeepCollectionEquality().hash(_medias), currentIndex, - showManu, + showActions, isVideoInitialized, isVideoBuffering, videoPosition, @@ -291,7 +291,7 @@ abstract class _MediaPreviewState implements MediaPreviewState { {final Object? error, final List medias, final int currentIndex, - final bool showManu, + final bool showActions, final bool isVideoInitialized, final bool isVideoBuffering, final Duration videoPosition, @@ -305,7 +305,7 @@ abstract class _MediaPreviewState implements MediaPreviewState { @override int get currentIndex; @override - bool get showManu; + bool get showActions; @override bool get isVideoInitialized; @override From a5f82a38a7b7702be234ba59a4ba48889d4b46db Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Fri, 5 Apr 2024 12:20:56 +0530 Subject: [PATCH 11/20] Implement download video --- app/lib/components/error_view.dart | 8 +++-- .../media_preview/media_preview_screen.dart | 36 ++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/app/lib/components/error_view.dart b/app/lib/components/error_view.dart index 95f42ee..700dbb2 100644 --- a/app/lib/components/error_view.dart +++ b/app/lib/components/error_view.dart @@ -14,12 +14,14 @@ 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, }); @@ -43,13 +45,13 @@ class ErrorView extends StatelessWidget { const SizedBox(height: 20), Text(title, style: AppTextStyles.subtitle2.copyWith( - color: context.colorScheme.textPrimary, + color: foregroundColor?? context.colorScheme.textPrimary, )), const SizedBox(height: 20), Text( message, style: AppTextStyles.body2.copyWith( - color: context.colorScheme.textSecondary, + color: foregroundColor ??context.colorScheme.textSecondary, ), textAlign: TextAlign.center, ), @@ -57,7 +59,7 @@ class ErrorView extends StatelessWidget { const SizedBox(height: 20), PrimaryButton( onPressed: action!.onPressed, - child: Text(action!.title), + child: Text(action!.title, style: AppTextStyles.button,), ), ], ], diff --git a/app/lib/ui/flow/media_preview/media_preview_screen.dart b/app/lib/ui/flow/media_preview/media_preview_screen.dart index 54c3ff6..d9d190a 100644 --- a/app/lib/ui/flow/media_preview/media_preview_screen.dart +++ b/app/lib/ui/flow/media_preview/media_preview_screen.dart @@ -53,14 +53,14 @@ class _MediaPreviewState extends ConsumerState { MediaPreviewState(currentIndex: currentIndex, medias: widget.medias)); notifier = ref.read(_provider.notifier); - _pageController = - PageController(initialPage: currentIndex, keepPage: true); + _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(); } @@ -69,7 +69,8 @@ class _MediaPreviewState extends ConsumerState { _videoPlayerController = VideoPlayerController.file(File(path)); _videoPlayerController?.addListener(_observeVideoController); await _videoPlayerController?.initialize(); - notifier.updateVideoInitialized(_videoPlayerController?.value.isInitialized ?? false); + notifier.updateVideoInitialized( + _videoPlayerController?.value.isInitialized ?? false); await _videoPlayerController?.play(); } @@ -170,6 +171,32 @@ class _MediaPreviewState extends ConsumerState { } }), ); + } else if (media.type.isVideo && media.isGoogleDriveStored) { + return Center( + child: Stack( + alignment: Alignment.center, + children: [ + Image.network( + height: double.infinity, + width: double.infinity, + media.thumbnailLink!, + fit: BoxFit.cover, + ), + Container( + color: Colors.black38, + child: ErrorView( + foregroundColor: context.colorScheme.onPrimary, + icon: Icon(CupertinoIcons.cloud_download, + size: 68, color: context.colorScheme.onPrimary), + title: "Download Required", + message: + "To watch the video, simply download it first. Tap the download button to begin.", + action: ErrorViewAction(title: "Download", onPressed: () {}), + ), + ), + ], + ), + ); } else if (media.type.isImage) { return ImagePreview(media: media); } else { @@ -358,7 +385,6 @@ class _MediaPreviewState extends ConsumerState { duration: state.videoMaxDuration, position: state.videoPosition ))); - return VideoDurationSlider( showSlider: state.showDurationSlider, duration: state.duration, From 6977863535c6762981db75411c7c6e5557ef9f27 Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Fri, 5 Apr 2024 17:40:59 +0530 Subject: [PATCH 12/20] Add drag down to dispose --- .../components/download_require_view.dart | 39 +++ .../media_preview/components/top_bar.dart | 173 ++++++++++ .../video_duration_slider.dart | 5 +- .../media_preview/media_preview_screen.dart | 298 ++++++------------ data/lib/models/app_process/app_process.dart | 37 +++ .../app_process/app_process.freezed.dart | 226 +++++++++++++ 6 files changed, 581 insertions(+), 197 deletions(-) create mode 100644 app/lib/ui/flow/media_preview/components/download_require_view.dart create mode 100644 app/lib/ui/flow/media_preview/components/top_bar.dart create mode 100644 data/lib/models/app_process/app_process.dart create mode 100644 data/lib/models/app_process/app_process.freezed.dart 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..61aafe8 --- /dev/null +++ b/app/lib/ui/flow/media_preview/components/download_require_view.dart @@ -0,0 +1,39 @@ +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 '../../../../components/error_view.dart'; + +class DownloadRequireView extends StatelessWidget { + final AppMedia media; + const DownloadRequireView({super.key, required this.media}); + + @override + Widget build(BuildContext context) { + return Center( + child: Stack( + alignment: Alignment.center, + children: [ + Image.network( + height: double.infinity, + width: double.infinity, + media.thumbnailLink!, + fit: BoxFit.cover, + ), + Container( + color: Colors.black38, + child: ErrorView( + foregroundColor: context.colorScheme.onPrimary, + icon: Icon(CupertinoIcons.cloud_download, + size: 68, color: context.colorScheme.onPrimary), + title: "Download Required", + message: + "To watch the video, simply download it first. Tap the download button to begin.", + action: ErrorViewAction(title: "Download", onPressed: () {}), + ), + ), + ], + ), + ); + } +} 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..d07977d --- /dev/null +++ b/app/lib/ui/flow/media_preview/components/top_bar.dart @@ -0,0 +1,173 @@ +import 'dart:io'; + +import 'package:data/models/media/media.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 { + await showDeleteAlert( + 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("Delete from Google Drive", + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textPrimary, + )), + ], + ), + ), + PopupMenuItem( + onTap: () async { + await showDeleteAlert( + 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( + "Delete from Device", + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textPrimary, + ), + ), + ], + ), + ), + ], + ); + } else if (media.isGoogleDriveStored && + media.driveMediaRefId != null) { + await showDeleteAlert( + context: context, + onDelete: () { + notifier + .deleteMediaFromGoogleDrive(media.driveMediaRefId); + context.pop(); + }); + } else if (media.isLocalStored) { + await showDeleteAlert( + 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 showDeleteAlert( + {required BuildContext context, required VoidCallback onDelete}) async { + await showAppAlertDialog( + context: context, + title: "Delete", + message: + "Are you sure you want to delete this media? It will be permanently removed.", + actions: [ + AppAlertAction( + title: "Cancel", + onPressed: () { + context.pop(); + }, + ), + AppAlertAction( + isDestructiveAction: true, + title: "Delete", + onPressed: onDelete, + ), + ], + ); + } +} 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 index 6770785..205b8cd 100644 --- 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 @@ -9,6 +9,7 @@ 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( @@ -16,6 +17,7 @@ class VideoDurationSlider extends StatelessWidget { required this.showSlider, required this.duration, required this.position, + required this.onChangeEnd, required this.onChanged}); @override @@ -58,7 +60,8 @@ class VideoDurationSlider extends StatelessWidget { min: 0, activeColor: context.colorScheme.primary, inactiveColor: context.colorScheme.outline, - onChanged: (value) => onChanged.call(Duration(seconds: value.toInt())), + onChangeEnd: (value) => onChangeEnd.call(Duration(seconds: value.toInt())), + onChanged: (double value) => onChanged.call(Duration(seconds: value.toInt())), ), ), ), diff --git a/app/lib/ui/flow/media_preview/media_preview_screen.dart b/app/lib/ui/flow/media_preview/media_preview_screen.dart index d9d190a..a0ab6a5 100644 --- a/app/lib/ui/flow/media_preview/media_preview_screen.dart +++ b/app/lib/ui/flow/media_preview/media_preview_screen.dart @@ -2,26 +2,20 @@ 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/assets/assets_paths.dart'; import 'package:cloud_gallery/domain/extensions/context_extensions.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/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: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/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'; import 'package:video_player/video_player.dart'; -import '../../../components/app_dialog.dart'; -import 'package:style/animations/cross_fade_animation.dart'; import 'components/video_player_components/video_duration_slider.dart'; class MediaPreview extends ConsumerStatefulWidget { @@ -126,25 +120,37 @@ class _MediaPreviewState extends ConsumerState { _observeError(); _updateVideoControllerOnMediaChange(); final medias = ref.watch(_provider.select((state) => state.medias)); + final showActions = + ref.watch(_provider.select((state) => state.showActions)); - return AppPage( - 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]), + return DismissiblePage( + onProgress: (progress) { + if (progress > 0 && showActions) { + notifier.toggleActionVisibility(); + } + }, + onDismiss: () { + context.pop(); + }, + child: AppPage( + 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]), + ), ), - ), - _actions(context: context), - _videoActions(context), - _videoDurationSlider(context), - ], + PreviewTopBar(provider: _provider), + _videoActions(context), + _videoDurationSlider(context), + ], + ), ), ); } @@ -172,31 +178,7 @@ class _MediaPreviewState extends ConsumerState { }), ); } else if (media.type.isVideo && media.isGoogleDriveStored) { - return Center( - child: Stack( - alignment: Alignment.center, - children: [ - Image.network( - height: double.infinity, - width: double.infinity, - media.thumbnailLink!, - fit: BoxFit.cover, - ), - Container( - color: Colors.black38, - child: ErrorView( - foregroundColor: context.colorScheme.onPrimary, - icon: Icon(CupertinoIcons.cloud_download, - size: 68, color: context.colorScheme.onPrimary), - title: "Download Required", - message: - "To watch the video, simply download it first. Tap the download button to begin.", - action: ErrorViewAction(title: "Download", onPressed: () {}), - ), - ), - ], - ), - ); + return DownloadRequireView(media: media); } else if (media.type.isImage) { return ImagePreview(media: media); } else { @@ -207,131 +189,6 @@ class _MediaPreviewState extends ConsumerState { } } - Widget _actions({required BuildContext context}) => Consumer( - builder: (context, ref, child) { - 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( - 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 { - await showDeleteAlert( - 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("Delete from Google Drive", - style: AppTextStyles.body2.copyWith( - color: context.colorScheme.textPrimary, - )), - ], - ), - ), - PopupMenuItem( - onTap: () async { - await showDeleteAlert( - 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( - "Delete from Device", - style: AppTextStyles.body2.copyWith( - color: context.colorScheme.textPrimary, - ), - ), - ], - ), - ), - ]); - } else if (media.isGoogleDriveStored && - media.driveMediaRefId != null) { - await showDeleteAlert( - context: context, - onDelete: () { - notifier.deleteMediaFromGoogleDrive( - media.driveMediaRefId); - context.pop(); - }); - } else if (media.isLocalStored) { - await showDeleteAlert( - 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), - ], - ), - ); - }, - ); - Widget _videoActions(BuildContext context) => Consumer( builder: (context, ref, child) { final ({ @@ -389,33 +246,82 @@ class _MediaPreviewState extends ConsumerState { showSlider: state.showDurationSlider, duration: state.duration, position: state.position, + onChangeEnd: (duration) { + _videoPlayerController?.seekTo(duration); + }, onChanged: (duration) { notifier.updateVideoPosition(duration); - _videoPlayerController?.seekTo(duration); }, ); }); +} - Future showDeleteAlert( - {required BuildContext context, required VoidCallback onDelete}) async { - await showAppAlertDialog( - context: context, - title: "Delete", - message: - "Are you sure you want to delete this media? It will be permanently removed.", - actions: [ - AppAlertAction( - title: "Cancel", - onPressed: () { - context.pop(); - }, - ), - AppAlertAction( - isDestructiveAction: true, - title: "Delete", - onPressed: onDelete, - ), - ], +class DismissiblePage extends StatefulWidget { + final Widget child; + final double threshold; + final void Function(double progress)? onProgress; + final void Function()? onDismiss; + final double scaleDownPercentage; + + const DismissiblePage({ + Key? key, + required this.child, + this.threshold = 200, + this.onProgress, + this.onDismiss, + this.scaleDownPercentage = 0.25, + }) : 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( + onVerticalDragStart: (DragStartDetails details) { + _startY = details.globalPosition.dy; + }, + onVerticalDragUpdate: (DragUpdateDetails details) { + if ((details.globalPosition.dy - _startY) > 0) { + 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: Colors.black.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, + ), + ), + ], + ), ); } } 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..16ba40a --- /dev/null +++ b/data/lib/models/app_process/app_process.dart @@ -0,0 +1,37 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../media/media.dart'; + +part 'app_process.freezed.dart'; + +enum AppProcessStatus { + waiting, + upload, + uploadSuccess, + uploadFailed, + delete, + deleteSuccess, + deleteFailed, + download, + downloadSuccess, + downloadFailed; + + bool get isProcessing => + this == AppProcessStatus.upload || + this == AppProcessStatus.delete || + this == AppProcessStatus.download; + + bool get isWaiting => this == AppProcessStatus.waiting; +} + +@freezed +class AppProcess with _$AppProcess { + const factory AppProcess({ + required String id, + required AppMedia media, + required AppProcessStatus status, + Object? response, + @Default(0) double progress, + }) = _AppProcess; +} + 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..6b0d208 --- /dev/null +++ b/data/lib/models/app_process/app_process.freezed.dart @@ -0,0 +1,226 @@ +// 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; + double 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, + double progress}); + + $AppMediaCopyWith<$Res> get media; +} + +/// @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 = null, + }) { + 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: null == progress + ? _value.progress + : progress // ignore: cast_nullable_to_non_nullable + as double, + ) 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); + }); + } +} + +/// @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, + double progress}); + + @override + $AppMediaCopyWith<$Res> get media; +} + +/// @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 = null, + }) { + 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: null == progress + ? _value.progress + : progress // ignore: cast_nullable_to_non_nullable + as double, + )); + } +} + +/// @nodoc + +class _$AppProcessImpl implements _AppProcess { + const _$AppProcessImpl( + {required this.id, + required this.media, + required this.status, + this.response, + this.progress = 0}); + + @override + final String id; + @override + final AppMedia media; + @override + final AppProcessStatus status; + @override + final Object? response; + @override + @JsonKey() + final double 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 double progress}) = _$AppProcessImpl; + + @override + String get id; + @override + AppMedia get media; + @override + AppProcessStatus get status; + @override + Object? get response; + @override + double get progress; + @override + @JsonKey(ignore: true) + _$$AppProcessImplCopyWith<_$AppProcessImpl> get copyWith => + throw _privateConstructorUsedError; +} From 67e4cce4c02d261572e6afd8ae81738384f4ec01 Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Mon, 8 Apr 2024 10:26:24 +0530 Subject: [PATCH 13/20] Implement dismissible page --- .../components/download_require_view.dart | 14 +-- .../media_preview/media_preview_screen.dart | 85 +++---------------- style/lib/animations/dismissible_page.dart | 76 +++++++++++++++++ 3 files changed, 97 insertions(+), 78 deletions(-) create mode 100644 style/lib/animations/dismissible_page.dart 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 index 61aafe8..13156c2 100644 --- a/app/lib/ui/flow/media_preview/components/download_require_view.dart +++ b/app/lib/ui/flow/media_preview/components/download_require_view.dart @@ -1,6 +1,7 @@ import 'package:data/models/media/media.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:style/extensions/context_extensions.dart'; import '../../../../components/error_view.dart'; @@ -14,11 +15,14 @@ class DownloadRequireView extends StatelessWidget { child: Stack( alignment: Alignment.center, children: [ - Image.network( - height: double.infinity, - width: double.infinity, - media.thumbnailLink!, - fit: BoxFit.cover, + Hero( + tag: media, + child: Image.network( + height: double.infinity, + width: double.infinity, + media.thumbnailLink!, + fit: BoxFit.cover, + ), ), Container( color: Colors.black38, diff --git a/app/lib/ui/flow/media_preview/media_preview_screen.dart b/app/lib/ui/flow/media_preview/media_preview_screen.dart index a0ab6a5..a69fcfe 100644 --- a/app/lib/ui/flow/media_preview/media_preview_screen.dart +++ b/app/lib/ui/flow/media_preview/media_preview_screen.dart @@ -17,6 +17,7 @@ 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; @@ -124,6 +125,7 @@ class _MediaPreviewState extends ConsumerState { ref.watch(_provider.select((state) => state.showActions)); return DismissiblePage( + backgroundColor: context.colorScheme.surface, onProgress: (progress) { if (progress > 0 && showActions) { notifier.toggleActionVisibility(); @@ -132,7 +134,10 @@ class _MediaPreviewState extends ConsumerState { onDismiss: () { context.pop(); }, - child: AppPage( + child: (progress) => AppPage( + backgroundColor: progress == 0 + ? context.colorScheme.surface + : Colors.transparent, body: Stack( children: [ GestureDetector( @@ -170,9 +175,12 @@ class _MediaPreviewState extends ConsumerState { color: context.colorScheme.onPrimary, ); } else { - return AspectRatio( - aspectRatio: _videoPlayerController!.value.aspectRatio, - child: VideoPlayer(_videoPlayerController!), + return Hero( + tag: media, + child: AspectRatio( + aspectRatio: _videoPlayerController!.value.aspectRatio, + child: VideoPlayer(_videoPlayerController!), + ), ); } }), @@ -256,72 +264,3 @@ class _MediaPreviewState extends ConsumerState { }); } -class DismissiblePage extends StatefulWidget { - final Widget child; - final double threshold; - final void Function(double progress)? onProgress; - final void Function()? onDismiss; - final double scaleDownPercentage; - - const DismissiblePage({ - Key? key, - required this.child, - this.threshold = 200, - this.onProgress, - this.onDismiss, - this.scaleDownPercentage = 0.25, - }) : 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( - onVerticalDragStart: (DragStartDetails details) { - _startY = details.globalPosition.dy; - }, - onVerticalDragUpdate: (DragUpdateDetails details) { - if ((details.globalPosition.dy - _startY) > 0) { - 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: Colors.black.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, - ), - ), - ], - ), - ); - } -} 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), + ), + ), + ], + ), + ); + } +} From 698f8f53148b0103259852455755c1c92303772a Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Tue, 9 Apr 2024 16:24:11 +0530 Subject: [PATCH 14/20] Show progress --- .../flow/home/components/app_media_item.dart | 31 ++- app/lib/ui/flow/home/home_screen.dart | 16 +- .../ui/flow/home/home_screen_view_model.dart | 101 ++++---- .../home/home_screen_view_model.freezed.dart | 21 +- .../home/home_view_model_helper_mixin.dart | 45 ++-- .../components/download_require_view.dart | 1 - data/lib/extensions/iterable_extension.dart | 14 +- data/lib/models/app_process/app_process.dart | 40 ++- .../app_process/app_process.freezed.dart | 177 ++++++++++++- data/lib/models/media/media.dart | 26 -- data/lib/models/media/media.freezed.dart | 149 ----------- .../google_drive_process_repo.dart | 239 ++++++++++++++++++ data/lib/repositories/google_drive_repo.dart | 131 ---------- data/lib/services/google_drive_service.dart | 14 +- 14 files changed, 560 insertions(+), 445 deletions(-) create mode 100644 data/lib/repositories/google_drive_process_repo.dart delete mode 100644 data/lib/repositories/google_drive_repo.dart 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 26b98f7..4cbf492 100644 --- a/app/lib/ui/flow/home/components/app_media_item.dart +++ b/app/lib/ui/flow/home/components/app_media_item.dart @@ -1,6 +1,7 @@ import 'dart:async'; 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:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; @@ -16,7 +17,7 @@ class AppMediaItem extends StatefulWidget { final void Function()? onTap; final void Function()? onLongTap; final bool isSelected; - final AppMediaProcessStatus? status; + final AppProcess? process; const AppMediaItem({ super.key, @@ -24,7 +25,7 @@ class AppMediaItem extends StatefulWidget { this.onTap, this.onLongTap, this.isSelected = false, - this.status, + this.process, }); @override @@ -170,14 +171,30 @@ class _AppMediaItemState extends State ], ), ), - if (widget.status?.isProcessing ?? false) + 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?.isWaiting ?? false) + if (widget.process?.status.isWaiting ?? false) _BackgroundContainer( child: Icon( CupertinoIcons.time, diff --git a/app/lib/ui/flow/home/home_screen.dart b/app/lib/ui/flow/home/home_screen.dart index 0960190..a5dbf72 100644 --- a/app/lib/ui/flow/home/home_screen.dart +++ b/app/lib/ui/flow/home/home_screen.dart @@ -6,6 +6,7 @@ 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'; @@ -71,8 +72,7 @@ class _HomeScreenState extends ConsumerState { size: 18, ), ), - if(!Platform.isIOS && !Platform.isMacOS) - const SizedBox(width: 16), + if (!Platform.isIOS && !Platform.isMacOS) const SizedBox(width: 16), ], body: _body(context: context), ); @@ -82,14 +82,14 @@ class _HomeScreenState extends ConsumerState { //View State final ({ Map> medias, - List mediaProcesses, + List mediaProcesses, List selectedMedias, bool isLoading, bool hasLocalMediaAccess, String? lastLocalMediaId }) state = ref.watch(homeViewStateNotifier.select((value) => ( medias: value.medias, - mediaProcesses: value.mediaProcesses, + mediaProcesses: value.mediaProcesses, selectedMedias: value.selectedMedias, isLoading: value.loading, hasLocalMediaAccess: value.hasLocalMediaAccess, @@ -124,7 +124,7 @@ class _HomeScreenState extends ConsumerState { Widget _buildMediaList( {required BuildContext context, required Map> medias, - required List mediaProcesses, + required List mediaProcesses, required String? lastLocalMediaId, required List selectedMedias}) { return Scrollbar( @@ -192,10 +192,8 @@ class _HomeScreenState extends ConsumerState { notifier.toggleMediaSelection(media); }, isSelected: selectedMedias.contains(media), - status: mediaProcesses - .firstWhereOrNull( - (element) => element.mediaId == media.id || element.mediaId == media.driveMediaRefId) - ?.status, + process: mediaProcesses.firstWhereOrNull( + (process) => process.id == media.id), media: media, ); }, 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 d2dca0a..a59fe5c 100644 --- a/app/lib/ui/flow/home/home_screen_view_model.dart +++ b/app/lib/ui/flow/home/home_screen_view_model.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'package:cloud_gallery/domain/extensions/map_extensions.dart'; -import 'package:data/errors/app_error.dart'; +import 'package:data/models/app_process/app_process.dart'; import 'package:data/models/media/media.dart'; -import 'package:data/repositories/google_drive_repo.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'; @@ -21,7 +21,7 @@ final homeViewStateNotifier = ref.read(localMediaServiceProvider), ref.read(googleDriveServiceProvider), ref.read(authServiceProvider), - ref.read(googleDriveRepoProvider), + ref.read(googleDriveProcessRepoProvider), ); }); @@ -29,11 +29,10 @@ class HomeViewStateNotifier extends StateNotifier with HomeViewModelHelperMixin { final AuthService _authService; final GoogleDriveService _googleDriveService; - final GoogleDriveRepo _googleDriveRepo; + final GoogleDriveProcessRepo _googleDriveProcessRepo; final LocalMediaService _localMediaService; StreamSubscription? _googleAccountSubscription; - StreamSubscription? _googleDriveProcessSubscription; List _uploadedMedia = []; String? _backUpFolderId; @@ -42,10 +41,11 @@ class HomeViewStateNotifier extends StateNotifier bool _isMaxLocalMediaLoaded = false; HomeViewStateNotifier(this._localMediaService, this._googleDriveService, - this._authService, this._googleDriveRepo) + this._authService, this._googleDriveProcessRepo) : super(const HomeViewState()) { _listenUserGoogleAccount(); - _listenGoogleDriveProcess(); + _googleDriveProcessRepo.setBackUpFolderId(_backUpFolderId); + _googleDriveProcessRepo.addListener(_listenGoogleDriveProcess); _loadInitialMedia(); } @@ -53,9 +53,10 @@ class HomeViewStateNotifier extends StateNotifier _googleAccountSubscription = _authService.onGoogleAccountChange.listen((event) async { state = state.copyWith(googleAccount: event); - _googleDriveRepo.terminateAllProcess(); + _googleDriveProcessRepo.clearAllQueue(); if (event != null) { _backUpFolderId = await _googleDriveService.getBackupFolderId(); + _googleDriveProcessRepo.setBackUpFolderId(_backUpFolderId); await loadGoogleDriveMedia(); } else { _backUpFolderId = null; @@ -68,34 +69,33 @@ class HomeViewStateNotifier extends StateNotifier } void _listenGoogleDriveProcess() { - _googleDriveProcessSubscription = - _googleDriveRepo.mediaProcessStream.listen((event) { - final uploadSuccessIds = event - .where((element) => - element.status == AppMediaProcessStatus.uploadingSuccess) - .map((e) => e.mediaId); + final successUploads = _googleDriveProcessRepo.uploadQueue + .where((element) => element.status.isSuccess); - final deleteSuccessIds = event - .where((element) => - element.status == AppMediaProcessStatus.successDelete) - .map((e) => e.mediaId); + final successDeletes = _googleDriveProcessRepo.deleteQueue + .where((element) => element.status.isSuccess) + .map((e) => e.id); - if (uploadSuccessIds.isNotEmpty) { - state = state.copyWith( - medias: addGoogleDriveRefInMedias( - medias: state.medias, - event: event, - uploadSuccessIds: uploadSuccessIds.toList())); - } - if (deleteSuccessIds.isNotEmpty) { - state = state.copyWith( - medias: removeGoogleDriveRefFromMedias( - medias: state.medias, - removeFromIds: deleteSuccessIds.toList())); - } + if (successUploads.isNotEmpty) { + state = state.copyWith( + medias: addGoogleDriveRefInMedias( + medias: state.medias, + process: successUploads.toList(), + )); + } + if (successDeletes.isNotEmpty) { + state = state.copyWith( + medias: removeGoogleDriveRefFromMedias( + medias: state.medias, + removeFromIds: successDeletes.toList(), + )); + } - state = state.copyWith(mediaProcesses: event); - }); + state = state.copyWith(mediaProcesses: [ + ..._googleDriveProcessRepo.uploadQueue, + ..._googleDriveProcessRepo.deleteQueue, + ..._googleDriveProcessRepo.downloadQueue, + ]); } void _loadInitialMedia() async { @@ -245,16 +245,14 @@ class HomeViewStateNotifier extends StateNotifier Future deleteMediasFromGoogleDrive() async { try { - final mediaGoogleDriveIds = state.selectedMedias - .where( - (element) => - element.sources.contains(AppMediaSource.googleDrive) && - element.driveMediaRefId != null, - ) - .map((e) => e.driveMediaRefId!) - .toList(); + final medias = state.selectedMedias.where( + (element) => + element.sources.contains(AppMediaSource.googleDrive) && + element.driveMediaRefId != null, + ); - _googleDriveRepo.deleteMediasInGoogleDrive(mediaIds: mediaGoogleDriveIds); + _googleDriveProcessRepo.deleteMediasFromGoogleDrive( + medias: medias.toList()); state = state.copyWith(selectedMedias: []); } catch (e) { state = state.copyWith(error: e); @@ -267,25 +265,16 @@ class HomeViewStateNotifier extends StateNotifier await _authService.signInWithGoogle(); await loadGoogleDriveMedia(); } - - List uploadingMedias = state.selectedMedias + List medias = state.selectedMedias .where((element) => !element.sources.contains(AppMediaSource.googleDrive)) .toList(); - _backUpFolderId ??= await _googleDriveService.getBackupFolderId(); - _googleDriveRepo.uploadMediasInGoogleDrive( - medias: uploadingMedias, - backUpFolderId: _backUpFolderId!, + _googleDriveProcessRepo.uploadMediasInGoogleDrive( + medias: medias, ); - state = state.copyWith(selectedMedias: []); } catch (error) { - if (error is BackUpFolderNotFound) { - _backUpFolderId = await _googleDriveService.getBackupFolderId(); - backUpMediaOnGoogleDrive(); - return; - } state = state.copyWith(error: error); } } @@ -293,7 +282,7 @@ class HomeViewStateNotifier extends StateNotifier @override Future dispose() async { await _googleAccountSubscription?.cancel(); - await _googleDriveProcessSubscription?.cancel(); + _googleDriveProcessRepo.removeListener(_listenGoogleDriveProcess); super.dispose(); } } @@ -308,6 +297,6 @@ class HomeViewState with _$HomeViewState { String? lastLocalMediaId, @Default({}) Map> medias, @Default([]) List selectedMedias, - @Default([]) List mediaProcesses, + @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 8f09352..38b7237 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 @@ -24,8 +24,7 @@ mixin _$HomeViewState { Map> get medias => throw _privateConstructorUsedError; List get selectedMedias => throw _privateConstructorUsedError; - List get mediaProcesses => - throw _privateConstructorUsedError; + List get mediaProcesses => throw _privateConstructorUsedError; @JsonKey(ignore: true) $HomeViewStateCopyWith get copyWith => @@ -46,7 +45,7 @@ abstract class $HomeViewStateCopyWith<$Res> { String? lastLocalMediaId, Map> medias, List selectedMedias, - List mediaProcesses}); + List mediaProcesses}); } /// @nodoc @@ -100,7 +99,7 @@ class _$HomeViewStateCopyWithImpl<$Res, $Val extends HomeViewState> mediaProcesses: null == mediaProcesses ? _value.mediaProcesses : mediaProcesses // ignore: cast_nullable_to_non_nullable - as List, + as List, ) as $Val); } } @@ -121,7 +120,7 @@ abstract class _$$HomeViewStateImplCopyWith<$Res> String? lastLocalMediaId, Map> medias, List selectedMedias, - List mediaProcesses}); + List mediaProcesses}); } /// @nodoc @@ -173,7 +172,7 @@ class __$$HomeViewStateImplCopyWithImpl<$Res> mediaProcesses: null == mediaProcesses ? _value._mediaProcesses : mediaProcesses // ignore: cast_nullable_to_non_nullable - as List, + as List, )); } } @@ -189,7 +188,7 @@ class _$HomeViewStateImpl implements _HomeViewState { this.lastLocalMediaId, final Map> medias = const {}, final List selectedMedias = const [], - final List mediaProcesses = const []}) + final List mediaProcesses = const []}) : _medias = medias, _selectedMedias = selectedMedias, _mediaProcesses = mediaProcesses; @@ -224,10 +223,10 @@ class _$HomeViewStateImpl implements _HomeViewState { return EqualUnmodifiableListView(_selectedMedias); } - final List _mediaProcesses; + final List _mediaProcesses; @override @JsonKey() - List get mediaProcesses { + List get mediaProcesses { if (_mediaProcesses is EqualUnmodifiableListView) return _mediaProcesses; // ignore: implicit_dynamic_type return EqualUnmodifiableListView(_mediaProcesses); @@ -286,7 +285,7 @@ abstract class _HomeViewState implements HomeViewState { final String? lastLocalMediaId, final Map> medias, final List selectedMedias, - final List mediaProcesses}) = _$HomeViewStateImpl; + final List mediaProcesses}) = _$HomeViewStateImpl; @override Object? get error; @@ -303,7 +302,7 @@ abstract class _HomeViewState implements HomeViewState { @override List get selectedMedias; @override - List get mediaProcesses; + 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 index 816c07b..4f54f83 100644 --- a/app/lib/ui/flow/home/home_view_model_helper_mixin.dart +++ b/app/lib/ui/flow/home/home_view_model_helper_mixin.dart @@ -1,6 +1,7 @@ import 'package:cloud_gallery/domain/formatter/date_formatter.dart'; 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.dart'; mixin HomeViewModelHelperMixin { @@ -52,11 +53,11 @@ mixin HomeViewModelHelperMixin { return medias.map((key, mediaList) { for (int index = 0; index < mediaList.length; index++) { if (mediaList[index].isGoogleDriveStored && - (removeFromIds?.contains(mediaList[index].driveMediaRefId) ?? + (removeFromIds?.contains(mediaList[index].id) ?? true)) { mediaList.removeAt(index); } else if (mediaList[index].isCommonStored && - (removeFromIds?.contains(mediaList[index].driveMediaRefId) ?? + (removeFromIds?.contains(mediaList[index].id) ?? true)) { mediaList[index] = mediaList[index].copyWith( sources: mediaList[index].sources.toList() @@ -70,30 +71,30 @@ mixin HomeViewModelHelperMixin { }); } - Map> addGoogleDriveRefInMedias( - {required Map> medias, - required List event, - required List uploadSuccessIds}) { + Map> addGoogleDriveRefInMedias({ + required Map> medias, + required List process, + }) { + final processIds = process.map((e) => e.id).toList(); return medias.map((key, value) { return MapEntry( key, value - .updateWhere( - where: (media) => uploadSuccessIds.contains(media.id), - update: (media) { - final res = event - .where((element) => element.mediaId == media.id) - .first - .response as AppMedia?; - return media.copyWith( - thumbnailLink: res?.thumbnailLink, - driveMediaRefId: res?.id, - sources: media.sources.toList() - ..add(AppMediaSource.googleDrive), - ); - }, - ) - .toList()); + ..updateWhere( + where: (media) => processIds.contains(media.id), + update: (media) { + final res = process + .where((element) => element.id == media.id) + .first + .response as AppMedia?; + return media.copyWith( + thumbnailLink: res?.thumbnailLink, + driveMediaRefId: res?.id, + sources: media.sources.toList() + ..add(AppMediaSource.googleDrive), + ); + }, + )); }); } } 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 index 13156c2..7fea5f3 100644 --- a/app/lib/ui/flow/media_preview/components/download_require_view.dart +++ b/app/lib/ui/flow/media_preview/components/download_require_view.dart @@ -1,7 +1,6 @@ import 'package:data/models/media/media.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:style/extensions/context_extensions.dart'; import '../../../../components/error_view.dart'; diff --git a/data/lib/extensions/iterable_extension.dart b/data/lib/extensions/iterable_extension.dart index 6e56fb6..9b91d16 100644 --- a/data/lib/extensions/iterable_extension.dart +++ b/data/lib/extensions/iterable_extension.dart @@ -1,7 +1,11 @@ -extension IterableExtension on Iterable { - Iterable updateWhere( - {required bool Function(T element) where, - required T Function(T element) update}) { - return map((element) => where(element) ? update(element) : element).toList(); +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 index 16ba40a..8ae5c4f 100644 --- a/data/lib/models/app_process/app_process.dart +++ b/data/lib/models/app_process/app_process.dart @@ -6,22 +6,22 @@ part 'app_process.freezed.dart'; enum AppProcessStatus { waiting, - upload, - uploadSuccess, - uploadFailed, - delete, - deleteSuccess, - deleteFailed, - download, - downloadSuccess, - downloadFailed; + uploading, + deleting, + downloading, + success, + failed; bool get isProcessing => - this == AppProcessStatus.upload || - this == AppProcessStatus.delete || - this == AppProcessStatus.download; + 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 @@ -31,7 +31,21 @@ class AppProcess with _$AppProcess { required AppMedia media, required AppProcessStatus status, Object? response, - @Default(0) double progress, + @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 index 6b0d208..d7055ed 100644 --- a/data/lib/models/app_process/app_process.freezed.dart +++ b/data/lib/models/app_process/app_process.freezed.dart @@ -20,7 +20,7 @@ mixin _$AppProcess { AppMedia get media => throw _privateConstructorUsedError; AppProcessStatus get status => throw _privateConstructorUsedError; Object? get response => throw _privateConstructorUsedError; - double get progress => throw _privateConstructorUsedError; + AppProcessProgress? get progress => throw _privateConstructorUsedError; @JsonKey(ignore: true) $AppProcessCopyWith get copyWith => @@ -38,9 +38,10 @@ abstract class $AppProcessCopyWith<$Res> { AppMedia media, AppProcessStatus status, Object? response, - double progress}); + AppProcessProgress? progress}); $AppMediaCopyWith<$Res> get media; + $AppProcessProgressCopyWith<$Res>? get progress; } /// @nodoc @@ -60,7 +61,7 @@ class _$AppProcessCopyWithImpl<$Res, $Val extends AppProcess> Object? media = null, Object? status = null, Object? response = freezed, - Object? progress = null, + Object? progress = freezed, }) { return _then(_value.copyWith( id: null == id @@ -76,10 +77,10 @@ class _$AppProcessCopyWithImpl<$Res, $Val extends AppProcess> : status // ignore: cast_nullable_to_non_nullable as AppProcessStatus, response: freezed == response ? _value.response : response, - progress: null == progress + progress: freezed == progress ? _value.progress : progress // ignore: cast_nullable_to_non_nullable - as double, + as AppProcessProgress?, ) as $Val); } @@ -90,6 +91,18 @@ class _$AppProcessCopyWithImpl<$Res, $Val extends AppProcess> 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 @@ -105,10 +118,12 @@ abstract class _$$AppProcessImplCopyWith<$Res> AppMedia media, AppProcessStatus status, Object? response, - double progress}); + AppProcessProgress? progress}); @override $AppMediaCopyWith<$Res> get media; + @override + $AppProcessProgressCopyWith<$Res>? get progress; } /// @nodoc @@ -126,7 +141,7 @@ class __$$AppProcessImplCopyWithImpl<$Res> Object? media = null, Object? status = null, Object? response = freezed, - Object? progress = null, + Object? progress = freezed, }) { return _then(_$AppProcessImpl( id: null == id @@ -142,10 +157,10 @@ class __$$AppProcessImplCopyWithImpl<$Res> : status // ignore: cast_nullable_to_non_nullable as AppProcessStatus, response: freezed == response ? _value.response : response, - progress: null == progress + progress: freezed == progress ? _value.progress : progress // ignore: cast_nullable_to_non_nullable - as double, + as AppProcessProgress?, )); } } @@ -158,7 +173,7 @@ class _$AppProcessImpl implements _AppProcess { required this.media, required this.status, this.response, - this.progress = 0}); + this.progress = null}); @override final String id; @@ -170,7 +185,7 @@ class _$AppProcessImpl implements _AppProcess { final Object? response; @override @JsonKey() - final double progress; + final AppProcessProgress? progress; @override String toString() { @@ -207,7 +222,7 @@ abstract class _AppProcess implements AppProcess { required final AppMedia media, required final AppProcessStatus status, final Object? response, - final double progress}) = _$AppProcessImpl; + final AppProcessProgress? progress}) = _$AppProcessImpl; @override String get id; @@ -218,9 +233,145 @@ abstract class _AppProcess implements AppProcess { @override Object? get response; @override - double get progress; + 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 a87f361..a593d3b 100644 --- a/data/lib/models/media/media.dart +++ b/data/lib/models/media/media.dart @@ -10,32 +10,6 @@ part 'media.freezed.dart'; part 'media.g.dart'; -enum AppMediaProcessStatus { - waiting, - uploading, - uploadingFailed, - uploadingSuccess, - deleting, - failedDelete, - successDelete, - none; - - bool get isProcessing => - this == AppMediaProcessStatus.uploading || - this == AppMediaProcessStatus.deleting; - - bool get isWaiting => this == AppMediaProcessStatus.waiting; -} - -@freezed -class AppMediaProcess with _$AppMediaProcess { - const factory AppMediaProcess({ - required String mediaId, - required AppMediaProcessStatus status, - Object? response, - }) = _AppMediaProcess; -} - enum AppMediaType { other, image, diff --git a/data/lib/models/media/media.freezed.dart b/data/lib/models/media/media.freezed.dart index 5c06f98..15b6e14 100644 --- a/data/lib/models/media/media.freezed.dart +++ b/data/lib/models/media/media.freezed.dart @@ -14,155 +14,6 @@ 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 _$AppMediaProcess { - String get mediaId => throw _privateConstructorUsedError; - AppMediaProcessStatus get status => throw _privateConstructorUsedError; - Object? get response => throw _privateConstructorUsedError; - - @JsonKey(ignore: true) - $AppMediaProcessCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $AppMediaProcessCopyWith<$Res> { - factory $AppMediaProcessCopyWith( - AppMediaProcess value, $Res Function(AppMediaProcess) then) = - _$AppMediaProcessCopyWithImpl<$Res, AppMediaProcess>; - @useResult - $Res call({String mediaId, AppMediaProcessStatus status, Object? response}); -} - -/// @nodoc -class _$AppMediaProcessCopyWithImpl<$Res, $Val extends AppMediaProcess> - implements $AppMediaProcessCopyWith<$Res> { - _$AppMediaProcessCopyWithImpl(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? mediaId = null, - Object? status = null, - Object? response = freezed, - }) { - return _then(_value.copyWith( - mediaId: null == mediaId - ? _value.mediaId - : mediaId // ignore: cast_nullable_to_non_nullable - as String, - status: null == status - ? _value.status - : status // ignore: cast_nullable_to_non_nullable - as AppMediaProcessStatus, - response: freezed == response ? _value.response : response, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$AppMediaProcessImplCopyWith<$Res> - implements $AppMediaProcessCopyWith<$Res> { - factory _$$AppMediaProcessImplCopyWith(_$AppMediaProcessImpl value, - $Res Function(_$AppMediaProcessImpl) then) = - __$$AppMediaProcessImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({String mediaId, AppMediaProcessStatus status, Object? response}); -} - -/// @nodoc -class __$$AppMediaProcessImplCopyWithImpl<$Res> - extends _$AppMediaProcessCopyWithImpl<$Res, _$AppMediaProcessImpl> - implements _$$AppMediaProcessImplCopyWith<$Res> { - __$$AppMediaProcessImplCopyWithImpl( - _$AppMediaProcessImpl _value, $Res Function(_$AppMediaProcessImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? mediaId = null, - Object? status = null, - Object? response = freezed, - }) { - return _then(_$AppMediaProcessImpl( - mediaId: null == mediaId - ? _value.mediaId - : mediaId // ignore: cast_nullable_to_non_nullable - as String, - status: null == status - ? _value.status - : status // ignore: cast_nullable_to_non_nullable - as AppMediaProcessStatus, - response: freezed == response ? _value.response : response, - )); - } -} - -/// @nodoc - -class _$AppMediaProcessImpl implements _AppMediaProcess { - const _$AppMediaProcessImpl( - {required this.mediaId, required this.status, this.response}); - - @override - final String mediaId; - @override - final AppMediaProcessStatus status; - @override - final Object? response; - - @override - String toString() { - return 'AppMediaProcess(mediaId: $mediaId, status: $status, response: $response)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$AppMediaProcessImpl && - (identical(other.mediaId, mediaId) || other.mediaId == mediaId) && - (identical(other.status, status) || other.status == status) && - const DeepCollectionEquality().equals(other.response, response)); - } - - @override - int get hashCode => Object.hash(runtimeType, mediaId, status, - const DeepCollectionEquality().hash(response)); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$AppMediaProcessImplCopyWith<_$AppMediaProcessImpl> get copyWith => - __$$AppMediaProcessImplCopyWithImpl<_$AppMediaProcessImpl>( - this, _$identity); -} - -abstract class _AppMediaProcess implements AppMediaProcess { - const factory _AppMediaProcess( - {required final String mediaId, - required final AppMediaProcessStatus status, - final Object? response}) = _$AppMediaProcessImpl; - - @override - String get mediaId; - @override - AppMediaProcessStatus get status; - @override - Object? get response; - @override - @JsonKey(ignore: true) - _$$AppMediaProcessImplCopyWith<_$AppMediaProcessImpl> get copyWith => - throw _privateConstructorUsedError; -} - AppMedia _$AppMediaFromJson(Map json) { return _AppMedia.fromJson(json); } 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..bed11d2 --- /dev/null +++ b/data/lib/repositories/google_drive_process_repo.dart @@ -0,0 +1,239 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'package:collection/collection.dart'; +import 'package:data/extensions/iterable_extension.dart'; +import 'package:data/models/app_process/app_process.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 { + StreamSubscription? subscription; + 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!); + + List bytes = []; + + subscription = mediaContent.stream.listen((chunk) { + bytes.addAll(chunk); + _downloadQueue.updateWhere( + where: (element) => element.id == process.id, + update: (element) => element.copyWith( + progress: AppProcessProgress( + total: mediaContent.length ?? 0, chunk: bytes.length)), + ); + notifyListeners(); + }, onDone: () async { + final res = await _localMediaService.saveMedia( + process.media, Uint8List.fromList(bytes)); + + _downloadQueue.updateWhere( + where: (element) => element.id == process.id, + update: (element) => + element.copyWith(status: AppProcessStatus.success, response: res), + ); + notifyListeners(); + subscription?.cancel(); + }, onError: (error) { + _downloadQueue.updateWhere( + where: (element) => element.id == process.id, + update: (element) => + element.copyWith(status: AppProcessStatus.failed), + ); + notifyListeners(); + subscription?.cancel(); + }); + } catch (error) { + _downloadQueue.updateWhere( + where: (element) => element.id == process.id, + update: (element) => element.copyWith(status: AppProcessStatus.failed), + ); + 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/repositories/google_drive_repo.dart b/data/lib/repositories/google_drive_repo.dart deleted file mode 100644 index e9af203..0000000 --- a/data/lib/repositories/google_drive_repo.dart +++ /dev/null @@ -1,131 +0,0 @@ -import 'dart:async'; -import 'package:data/extensions/iterable_extension.dart'; -import 'package:data/services/google_drive_service.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../models/media/media.dart'; - -final googleDriveRepoProvider = Provider((ref) { - return GoogleDriveRepo(ref.read(googleDriveServiceProvider)); -}); - -class GoogleDriveRepo { - final GoogleDriveService _googleDriveService; - - final StreamController> _mediaProcessController = - StreamController>.broadcast(); - - Set _mediaProcessValue = {}; - - void _updateMediaProcess(Iterable process) { - _mediaProcessValue = process.toSet(); - _mediaProcessController.add(mediaProcessValue); - } - - Stream> get mediaProcessStream => - _mediaProcessController.stream; - - List get mediaProcessValue => _mediaProcessValue.toList(); - - GoogleDriveRepo(this._googleDriveService); - - Future uploadMediasInGoogleDrive( - {required List medias, required String backUpFolderId}) async { - _updateMediaProcess([..._mediaProcessValue.toList(), - ...medias.map((media) => AppMediaProcess( - mediaId: media.id, status: AppMediaProcessStatus.waiting))]); - - for (final media in medias) { - //Skip process if queue does not contain mediaId - if (!_mediaProcessValue.map((e) => e.mediaId).contains(media.id)) { - continue; - } - - try { - _updateMediaProcess(_mediaProcessValue.updateWhere( - where: (process) => process.mediaId == media.id, - update: (element) => - element.copyWith(status: AppMediaProcessStatus.uploading), - )); - - final uploadedMedia = await _googleDriveService.uploadInGoogleDrive( - media: media, - folderID: backUpFolderId, - ); - - _updateMediaProcess( - _mediaProcessValue.updateWhere( - where: (process) => process.mediaId == media.id, - update: (element) => element.copyWith( - status: AppMediaProcessStatus.uploadingSuccess, - response: uploadedMedia, - ), - ), - ); - } catch (error) { - _updateMediaProcess(_mediaProcessValue.updateWhere( - where: (process) => process.mediaId == media.id, - update: (element) => element.copyWith( - status: AppMediaProcessStatus.uploadingFailed))); - } - } - // Remove failed processes to upload process - final mediaIds = medias.map((e) => e.id); - _updateMediaProcess(_mediaProcessValue.toList() - ..removeWhere((process) => mediaIds.contains(process.mediaId))); - } - - void deleteMediasInGoogleDrive({required List mediaIds}) async { - _updateMediaProcess([ - ..._mediaProcessValue.toList(), - ...mediaIds.map((id) => - AppMediaProcess(mediaId: id, status: AppMediaProcessStatus.waiting)) - - ]); - - for (final mediaId in mediaIds) { - //Skip process if queue does not contain mediaId - if (!_mediaProcessValue.map((e) => e.mediaId).contains(mediaId)) { - continue; - } - try { - _updateMediaProcess( - _mediaProcessValue.updateWhere( - where: (process) => process.mediaId == mediaId, - update: (element) => - element.copyWith(status: AppMediaProcessStatus.deleting), - ), - ); - - await _googleDriveService.deleteMedia(mediaId); - - _updateMediaProcess(_mediaProcessValue.updateWhere( - where: (process) => process.mediaId == mediaId, - update: (element) => - element.copyWith(status: AppMediaProcessStatus.successDelete))); - } catch (error) { - _updateMediaProcess(_mediaProcessValue.updateWhere( - where: (process) => process.mediaId == mediaId, - update: (element) => - element.copyWith(status: AppMediaProcessStatus.failedDelete))); - } - } - // Remove failed processes to upload process - _updateMediaProcess(_mediaProcessValue.toList() - ..removeWhere((process) => mediaIds.contains(process.mediaId))); - } - - void terminateAllProcess() { - _mediaProcessValue.clear(); - _mediaProcessController.add(_mediaProcessValue.toList()); - } - - void terminateSingleProcess(String id) { - _mediaProcessValue.removeWhere((element) => element.mediaId == id); - _mediaProcessController.add(_mediaProcessValue.toList()); - } - - void dispose() { - _mediaProcessValue.clear(); - _mediaProcessController.close(); - } -} diff --git a/data/lib/services/google_drive_service.dart b/data/lib/services/google_drive_service.dart index 9c76dc7..2d556e8 100644 --- a/data/lib/services/google_drive_service.dart +++ b/data/lib/services/google_drive_service.dart @@ -83,7 +83,9 @@ class GoogleDriveService { } Future uploadInGoogleDrive( - {required String folderID, required AppMedia media}) async { + {required String folderID, + required AppMedia media, + void Function(int total, int chunk)? onProgress}) async { final localFile = File(media.path); try { final driveApi = await _getGoogleDriveAPI(); @@ -93,9 +95,17 @@ class GoogleDriveService { description: media.path, parents: [folderID], ); + 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) { From 7e8fa6826414435a2720bcdaf5c78cdffd953ced Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Wed, 10 Apr 2024 15:19:55 +0530 Subject: [PATCH 15/20] Implement media transfer screen --- app/assets/locales/app_en.arb | 24 +- app/lib/domain/formatter/byte_formatter.dart | 11 + .../flow/home/components/app_media_item.dart | 1 - .../ui/flow/home/components/hint_view.dart | 88 ++++++++ app/lib/ui/flow/home/components/hints.dart | 87 +------ .../multi_selection_done_button.dart | 25 +- app/lib/ui/flow/home/home_screen.dart | 24 ++ .../ui/flow/home/home_screen_view_model.dart | 28 ++- .../home/home_screen_view_model.freezed.dart | 25 +- .../home/home_view_model_helper_mixin.dart | 37 ++- .../media_preview/components/top_bar.dart | 51 +++-- .../components/transfer_item.dart | 188 ++++++++++++++++ .../media_transfer/media_transfer_screen.dart | 149 ++++++++++++ .../media_transfer_view_model.dart | 55 +++++ .../media_transfer_view_model.freezed.dart | 213 ++++++++++++++++++ app/lib/ui/navigation/app_router.dart | 8 + style/lib/buttons/segmented_button.dart | 8 +- 17 files changed, 895 insertions(+), 127 deletions(-) create mode 100644 app/lib/domain/formatter/byte_formatter.dart create mode 100644 app/lib/ui/flow/home/components/hint_view.dart create mode 100644 app/lib/ui/flow/media_transfer/components/transfer_item.dart create mode 100644 app/lib/ui/flow/media_transfer/media_transfer_screen.dart create mode 100644 app/lib/ui/flow/media_transfer/media_transfer_view_model.dart create mode 100644 app/lib/ui/flow/media_transfer/media_transfer_view_model.freezed.dart diff --git a/app/assets/locales/app_en.arb b/app/assets/locales/app_en.arb index 6467561..17758ff 100644 --- a/app/assets/locales/app_en.arb +++ b/app/assets/locales/app_en.arb @@ -14,12 +14,21 @@ "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_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! 🚀", @@ -43,6 +52,19 @@ "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...", + + "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." } \ No newline at end of file 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/home/components/app_media_item.dart b/app/lib/ui/flow/home/components/app_media_item.dart index 4cbf492..09e654d 100644 --- a/app/lib/ui/flow/home/components/app_media_item.dart +++ b/app/lib/ui/flow/home/components/app_media_item.dart @@ -190,7 +190,6 @@ class _AppMediaItemState extends State ), ), ] - ], ), ), 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 e9a640f..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 @@ -25,7 +25,8 @@ class MultiSelectionDoneButton extends ConsumerWidget { .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)); + final bool showUploadToDriveButton = selectedMedias.any( + (element) => !element.sources.contains(AppMediaSource.googleDrive)); return FloatingActionButton( elevation: 3, backgroundColor: context.colorScheme.primary, @@ -51,22 +52,23 @@ class MultiSelectionDoneButton extends ConsumerWidget { if (showDeleteFromDeviceButton) AppSheetAction( icon: const Icon(CupertinoIcons.delete), - title: "Delete from device", + title: context.l10n.common_delete_from_device, onPressed: () { showAppAlertDialog( context: context, - title: "Delete selected medias", - message: "Are you sure you want to delete these items?", + title: context.l10n.common_delete_from_device, + message: context + .l10n.delete_media_from_device_confirmation_message, actions: [ AppAlertAction( - title: "Cancel", + title: context.l10n.common_cancel, onPressed: () { context.pop(); }, ), AppAlertAction( isDestructiveAction: true, - title: "Delete", + title: context.l10n.common_delete, onPressed: () { notifier.deleteMediasFromLocal(); context.pop(); @@ -79,22 +81,23 @@ class MultiSelectionDoneButton extends ConsumerWidget { if (showDeleteFromDriveButton) AppSheetAction( icon: const Icon(CupertinoIcons.delete), - title: "Delete from Google Drive", + title: context.l10n.common_delete_from_google_drive, onPressed: () { showAppAlertDialog( context: context, - title: "Delete selected medias", - message: "Are you sure you want to delete these items?", + title: context.l10n.common_delete_from_google_drive, + message: context.l10n + .delete_media_from_google_drive_confirmation_message, actions: [ AppAlertAction( - title: "Cancel", + title: context.l10n.common_cancel, onPressed: () { context.pop(); }, ), AppAlertAction( isDestructiveAction: true, - title: "Delete", + title: context.l10n.common_delete, onPressed: () { notifier.deleteMediasFromGoogleDrive(); context.pop(); diff --git a/app/lib/ui/flow/home/home_screen.dart b/app/lib/ui/flow/home/home_screen.dart index a5dbf72..fdf9d6b 100644 --- a/app/lib/ui/flow/home/home_screen.dart +++ b/app/lib/ui/flow/home/home_screen.dart @@ -60,6 +60,30 @@ class _HomeScreenState extends ConsumerState { return AppPage( 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, 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 a59fe5c..c3c2090 100644 --- a/app/lib/ui/flow/home/home_screen_view_model.dart +++ b/app/lib/ui/flow/home/home_screen_view_model.dart @@ -76,13 +76,17 @@ class HomeViewStateNotifier extends StateNotifier .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: addGoogleDriveRefInMedias( + medias: addGoogleDriveMediaRef( medias: state.medias, process: successUploads.toList(), )); } + if (successDeletes.isNotEmpty) { state = state.copyWith( medias: removeGoogleDriveRefFromMedias( @@ -91,11 +95,22 @@ class HomeViewStateNotifier extends StateNotifier )); } - state = state.copyWith(mediaProcesses: [ - ..._googleDriveProcessRepo.uploadQueue, - ..._googleDriveProcessRepo.deleteQueue, - ..._googleDriveProcessRepo.downloadQueue, - ]); + if (successDownloads.isNotEmpty) { + state = state.copyWith( + medias: addLocalMediaRef( + 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 { @@ -294,6 +309,7 @@ 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, 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 38b7237..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,6 +20,7 @@ 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; @@ -42,6 +43,7 @@ abstract class $HomeViewStateCopyWith<$Res> { bool hasLocalMediaAccess, bool loading, GoogleSignInAccount? googleAccount, + bool showTransfer, String? lastLocalMediaId, Map> medias, List selectedMedias, @@ -65,6 +67,7 @@ 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, @@ -84,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 @@ -117,6 +124,7 @@ abstract class _$$HomeViewStateImplCopyWith<$Res> bool hasLocalMediaAccess, bool loading, GoogleSignInAccount? googleAccount, + bool showTransfer, String? lastLocalMediaId, Map> medias, List selectedMedias, @@ -138,6 +146,7 @@ class __$$HomeViewStateImplCopyWithImpl<$Res> Object? hasLocalMediaAccess = null, Object? loading = null, Object? googleAccount = freezed, + Object? showTransfer = null, Object? lastLocalMediaId = freezed, Object? medias = null, Object? selectedMedias = null, @@ -157,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 @@ -185,6 +198,7 @@ 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 [], @@ -204,6 +218,9 @@ class _$HomeViewStateImpl implements _HomeViewState { @override final GoogleSignInAccount? googleAccount; @override + @JsonKey() + final bool showTransfer; + @override final String? lastLocalMediaId; final Map> _medias; @override @@ -234,7 +251,7 @@ class _$HomeViewStateImpl implements _HomeViewState { @override String toString() { - return 'HomeViewState(error: $error, hasLocalMediaAccess: $hasLocalMediaAccess, loading: $loading, googleAccount: $googleAccount, lastLocalMediaId: $lastLocalMediaId, medias: $medias, selectedMedias: $selectedMedias, mediaProcesses: $mediaProcesses)'; + return 'HomeViewState(error: $error, hasLocalMediaAccess: $hasLocalMediaAccess, loading: $loading, googleAccount: $googleAccount, showTransfer: $showTransfer, lastLocalMediaId: $lastLocalMediaId, medias: $medias, selectedMedias: $selectedMedias, mediaProcesses: $mediaProcesses)'; } @override @@ -248,6 +265,8 @@ 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) && @@ -264,6 +283,7 @@ class _$HomeViewStateImpl implements _HomeViewState { hasLocalMediaAccess, loading, googleAccount, + showTransfer, lastLocalMediaId, const DeepCollectionEquality().hash(_medias), const DeepCollectionEquality().hash(_selectedMedias), @@ -282,6 +302,7 @@ 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, @@ -296,6 +317,8 @@ abstract class _HomeViewState implements HomeViewState { @override GoogleSignInAccount? get googleAccount; @override + bool get showTransfer; + @override String? get lastLocalMediaId; @override Map> get medias; 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 index 4f54f83..534286c 100644 --- a/app/lib/ui/flow/home/home_view_model_helper_mixin.dart +++ b/app/lib/ui/flow/home/home_view_model_helper_mixin.dart @@ -53,12 +53,10 @@ mixin HomeViewModelHelperMixin { return medias.map((key, mediaList) { for (int index = 0; index < mediaList.length; index++) { if (mediaList[index].isGoogleDriveStored && - (removeFromIds?.contains(mediaList[index].id) ?? - true)) { + (removeFromIds?.contains(mediaList[index].id) ?? true)) { mediaList.removeAt(index); } else if (mediaList[index].isCommonStored && - (removeFromIds?.contains(mediaList[index].id) ?? - true)) { + (removeFromIds?.contains(mediaList[index].id) ?? true)) { mediaList[index] = mediaList[index].copyWith( sources: mediaList[index].sources.toList() ..remove(AppMediaSource.googleDrive), @@ -71,7 +69,7 @@ mixin HomeViewModelHelperMixin { }); } - Map> addGoogleDriveRefInMedias({ + Map> addGoogleDriveMediaRef({ required Map> medias, required List process, }) { @@ -97,4 +95,33 @@ mixin HomeViewModelHelperMixin { )); }); } + + Map> addLocalMediaRef({ + required Map> medias, + required List process, + }) { + final processIds = process.map((e) => e.id).toList(); + return medias.map((key, value) { + return MapEntry( + key, + value + ..updateWhere( + where: (media) => processIds.contains(media.id), + update: (media) { + final res = process + .where((element) => element.id == media.id) + .first + .response as AppMedia?; + + if (res == null) return media; + return res.copyWith( + thumbnailLink: media.thumbnailLink, + driveMediaRefId: media.id, + sources: res.sources.toList() + ..add(AppMediaSource.googleDrive), + ); + }, + )); + }); + } } diff --git a/app/lib/ui/flow/media_preview/components/top_bar.dart b/app/lib/ui/flow/media_preview/components/top_bar.dart index d07977d..311fbe9 100644 --- a/app/lib/ui/flow/media_preview/components/top_bar.dart +++ b/app/lib/ui/flow/media_preview/components/top_bar.dart @@ -1,5 +1,5 @@ import 'dart:io'; - +import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; import 'package:data/models/media/media.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -65,7 +65,7 @@ class PreviewTopBar extends StatelessWidget { items: [ PopupMenuItem( onTap: () async { - await showDeleteAlert( + _showDeleteFromDriveDialog( context: context, onDelete: () { notifier.deleteMediaFromGoogleDrive( @@ -81,7 +81,7 @@ class PreviewTopBar extends StatelessWidget { height: 20, ), const SizedBox(width: 16), - Text("Delete from Google Drive", + Text(context.l10n.common_delete_from_google_drive, style: AppTextStyles.body2.copyWith( color: context.colorScheme.textPrimary, )), @@ -90,7 +90,7 @@ class PreviewTopBar extends StatelessWidget { ), PopupMenuItem( onTap: () async { - await showDeleteAlert( + _showDeleteFromDeviceDialog( context: context, onDelete: () { notifier.deleteMediaFromLocal(media.id); @@ -104,7 +104,7 @@ class PreviewTopBar extends StatelessWidget { size: 22), const SizedBox(width: 16), Text( - "Delete from Device", + context.l10n.common_delete_from_device, style: AppTextStyles.body2.copyWith( color: context.colorScheme.textPrimary, ), @@ -116,7 +116,7 @@ class PreviewTopBar extends StatelessWidget { ); } else if (media.isGoogleDriveStored && media.driveMediaRefId != null) { - await showDeleteAlert( + _showDeleteFromDriveDialog( context: context, onDelete: () { notifier @@ -124,7 +124,7 @@ class PreviewTopBar extends StatelessWidget { context.pop(); }); } else if (media.isLocalStored) { - await showDeleteAlert( + _showDeleteFromDeviceDialog( context: context, onDelete: () { notifier.deleteMediaFromLocal(media.id); @@ -148,23 +148,46 @@ class PreviewTopBar extends StatelessWidget { }); } - Future showDeleteAlert( - {required BuildContext context, required VoidCallback onDelete}) async { + 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: "Delete", - message: - "Are you sure you want to delete this media? It will be permanently removed.", + title: context.l10n.common_delete_from_device, + message: context.l10n.delete_media_from_device_confirmation_message, actions: [ AppAlertAction( - title: "Cancel", + title: context.l10n.common_cancel, onPressed: () { context.pop(); }, ), AppAlertAction( isDestructiveAction: true, - title: "Delete", + title: context.l10n.common_delete, onPressed: onDelete, ), ], 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..4486d05 --- /dev/null +++ b/app/lib/ui/flow/media_transfer/components/transfer_item.dart @@ -0,0 +1,188 @@ +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: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 StatelessWidget { + final AppProcess process; + final void Function() onCancelTap; + + const ProcessItem( + {super.key, required this.process, required this.onCancelTap}); + + @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( + process.media.name != null && + process.media.name!.trim().isNotEmpty + ? process.media.name! + : process.media.path, + style: AppTextStyles.body.copyWith( + color: context.colorScheme.textPrimary, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + const SizedBox(height: 8), + if (process.status.isWaiting) + Text( + context.l10n.waiting_in_queue_text, + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textSecondary, + ), + ), + if (process.progress != null && process.status.isProcessing) ...[ + LinearProgressIndicator( + value: 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( + '${process.progress?.chunk.formatBytes} ${process.progress?.percentage.toStringAsFixed(2)}%', + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textSecondary, + ), + ), + Text( + '${process.progress?.total.formatBytes}', + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textSecondary, + ), + ), + ], + ) + ] + ], + ), + ), + if (process.status.isWaiting) + ActionButton( + onPressed: onCancelTap, + icon: const Icon(CupertinoIcons.xmark), + ) + ], + ); + } + + Widget _buildThumbnailView({required BuildContext context}) { + if (process.media.sources.contains(AppMediaSource.local)) { + return FutureByteLoader( + bytes: process.media.thumbnailDataWithSize(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: 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 77ac371..4fc3f77 100644 --- a/app/lib/ui/navigation/app_router.dart +++ b/app/lib/ui/navigation/app_router.dart @@ -1,4 +1,5 @@ import 'package:cloud_gallery/ui/flow/accounts/accounts_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'; @@ -23,6 +24,11 @@ class AppRouter { builder: (context) => const AccountsScreen(), ); + static AppRoute get mediaTransfer => AppRoute( + AppRoutePath.transfer, + builder: (context) => const MediaTransferScreen(), + ); + static AppRoute preview( {required List medias, required String startFrom}) => AppRoute( @@ -37,6 +43,7 @@ class AppRouter { home.goRoute, onBoard.goRoute, accounts.goRoute, + mediaTransfer.goRoute, GoRoute( path: AppRoutePath.preview, pageBuilder: (context, state) { @@ -58,4 +65,5 @@ class AppRoutePath { static const onBoard = '/on-board'; static const accounts = '/accounts'; static const preview = '/preview'; + static const transfer = '/transfer'; } 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, ), ); } From a351a6e28a1a8647b9bdd049fe0c8f338e574682 Mon Sep 17 00:00:00 2001 From: cp-sneha-s Date: Thu, 11 Apr 2024 08:36:03 +0530 Subject: [PATCH 16/20] Process Media item on background thread --- .../flow/home/components/app_media_item.dart | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) 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 09e654d..b1ed149 100644 --- a/app/lib/ui/flow/home/components/app_media_item.dart +++ b/app/lib/ui/flow/home/components/app_media_item.dart @@ -1,16 +1,42 @@ import 'dart:async'; +import 'dart:ui'; 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:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_svg/svg.dart'; import 'package:style/extensions/context_extensions.dart'; import 'package:style/indicators/circular_progress_indicator.dart'; import 'package:style/text/app_text_style.dart'; import '../../../../domain/assets/assets_paths.dart'; import 'package:style/animations/item_selector.dart'; +import 'package:photo_manager/photo_manager.dart' + show AssetEntity, ThumbnailFormat, ThumbnailSize; + +class ThumbNailParameter { + final RootIsolateToken token; + final Size size; + final String id; + final AppMediaType type; + + ThumbNailParameter(this.token,this.size, this.id, this.type); +} + +FutureOr thumbnailDataWithSize(ThumbNailParameter thumbNailParameter) async { + BackgroundIsolateBinaryMessenger.ensureInitialized(thumbNailParameter.token); + + return await AssetEntity(id: thumbNailParameter.id, typeInt: thumbNailParameter.type.index, width: 0, height: 0) + .thumbnailDataWithSize( + ThumbnailSize(thumbNailParameter.size.width.toInt(), thumbNailParameter.size.height.toInt()), + format: ThumbnailFormat.png, + quality: 70, + ); +} + + class AppMediaItem extends StatefulWidget { final AppMedia media; @@ -34,20 +60,29 @@ class AppMediaItem extends StatefulWidget { class _AppMediaItemState extends State with AutomaticKeepAliveClientMixin { - late Future thumbnailByte; + late Future thumbnailByte; @override void initState() { if (widget.media.sources.contains(AppMediaSource.local)) { - _loadImage(); + // _loadImage(); } super.initState(); } - _loadImage() async { - thumbnailByte = widget.media.thumbnailDataWithSize(const Size(300, 300)); + Future _loadImage() async { + var rootToken = RootIsolateToken.instance!; + final ThumbNailParameter thumbNailParameter= ThumbNailParameter(rootToken,const Size(300,300), widget.media.id, widget.media.type); + final bytes=await compute( + thumbnailDataWithSize, thumbNailParameter + ); + + return bytes; + //thumbnailByte = widget.media.thumbnailDataWithSize(const Size(300, 300)); } + + @override Widget build(BuildContext context) { super.build(context); @@ -71,6 +106,8 @@ class _AppMediaItemState extends State ); } + + Widget _videoDuration(BuildContext context) => Align( alignment: Alignment.bottomRight, child: _BackgroundContainer( @@ -98,7 +135,7 @@ class _AppMediaItemState extends State {required BuildContext context, required BoxConstraints constraints}) { if (widget.media.sources.contains(AppMediaSource.local)) { return FutureBuilder( - future: thumbnailByte, + future: _loadImage(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { From 3be95d20714ea289a76a12062cb2486876e79c61 Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Thu, 11 Apr 2024 17:17:47 +0530 Subject: [PATCH 17/20] Implement google drive video preview --- .../extensions/media_list_extension.dart | 60 ++++++++++++++++++ data/lib/models/media/media.dart | 28 +-------- data/lib/models/media/media_extension.dart | 63 +++++++++++++++++++ .../google_drive_process_repo.dart | 29 +++++---- data/lib/services/google_drive_service.dart | 11 ++++ data/lib/services/local_media_service.dart | 22 ++++--- 6 files changed, 169 insertions(+), 44 deletions(-) create mode 100644 app/lib/domain/extensions/media_list_extension.dart create mode 100644 data/lib/models/media/media_extension.dart 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..738981e --- /dev/null +++ b/app/lib/domain/extensions/media_list_extension.dart @@ -0,0 +1,60 @@ +import 'package:data/extensions/iterable_extension.dart'; +import 'package:data/models/app_process/app_process.dart'; +import 'package:data/models/media/media.dart'; + +extension MediaListHelper 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 addGoogleDriveRefInMedia( + {required List process, required List processIds}) { + updateWhere( + where: (media) => processIds.contains(media.id), + update: (media) { + final res = process + .where((element) => element.id == media.id) + .first + .response as AppMedia?; + return media.copyWith( + thumbnailLink: res?.thumbnailLink, + driveMediaRefId: res?.id, + sources: media.sources.toList()..add(AppMediaSource.googleDrive), + ); + }, + ); + } + + void addLocalRefInMedias( + {required List process, required List processIds}) { + updateWhere( + where: (media) => processIds.contains(media.id), + update: (media) { + final res = process + .where((element) => element.id == media.id) + .first + .response as AppMedia?; + + if (res == null) return media; + return res.copyWith( + thumbnailLink: media.thumbnailLink, + driveMediaRefId: media.id, + sources: res.sources.toList()..add(AppMediaSource.googleDrive), + ); + }, + ); + } +} diff --git a/data/lib/models/media/media.dart b/data/lib/models/media/media.dart index a593d3b..f7d629c 100644 --- a/data/lib/models/media/media.dart +++ b/data/lib/models/media/media.dart @@ -1,10 +1,8 @@ -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'; @@ -174,25 +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.png, - quality: 70, - ); - } - - 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; -} diff --git a/data/lib/models/media/media_extension.dart b/data/lib/models/media/media_extension.dart new file mode 100644 index 0000000..2e09b84 --- /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 margeGoogleDriveMedia(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 index bed11d2..ff97b59 100644 --- a/data/lib/repositories/google_drive_process_repo.dart +++ b/data/lib/repositories/google_drive_process_repo.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; 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'; @@ -186,17 +187,6 @@ class GoogleDriveProcessRepo extends ChangeNotifier { total: mediaContent.length ?? 0, chunk: bytes.length)), ); notifyListeners(); - }, onDone: () async { - final res = await _localMediaService.saveMedia( - process.media, Uint8List.fromList(bytes)); - - _downloadQueue.updateWhere( - where: (element) => element.id == process.id, - update: (element) => - element.copyWith(status: AppProcessStatus.success, response: res), - ); - notifyListeners(); - subscription?.cancel(); }, onError: (error) { _downloadQueue.updateWhere( where: (element) => element.id == process.id, @@ -206,6 +196,23 @@ class GoogleDriveProcessRepo extends ChangeNotifier { notifyListeners(); subscription?.cancel(); }); + await subscription.asFuture(); + + final localMedia = await _localMediaService.saveMedia( + process.media, Uint8List.fromList(bytes)); + + 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?.margeGoogleDriveMedia(updatedMedia)), + ); + + notifyListeners(); + subscription.cancel(); } catch (error) { _downloadQueue.updateWhere( where: (element) => element.id == process.id, diff --git a/data/lib/services/google_drive_service.dart b/data/lib/services/google_drive_service.dart index 2d556e8..6191b62 100644 --- a/data/lib/services/google_drive_service.dart +++ b/data/lib/services/google_drive_service.dart @@ -73,6 +73,17 @@ class GoogleDriveService { } } + 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(); diff --git a/data/lib/services/local_media_service.dart b/data/lib/services/local_media_service.dart index 88dbebc..dbb02f7 100644 --- a/data/lib/services/local_media_service.dart +++ b/data/lib/services/local_media_service.dart @@ -56,17 +56,25 @@ class LocalMediaService { } } - Future saveMedia(AppMedia media, Uint8List bytes) async { + Future saveMedia(AppMedia media, Uint8List bytes) async { + final extension = media.mimeType?.trim().isNotEmpty ?? false + ? media.mimeType!.split('/').last + : media.type.isVideo + ? 'mp4' + : 'jpg'; + AssetEntity? asset; if (media.type.isVideo) { final tempDir = await getTemporaryDirectory(); - final tempVideoFile = File('${tempDir.path}/temp_video.mp4'); + final tempVideoFile = File('${tempDir.path}/temp_video'); await tempVideoFile.writeAsBytes(bytes); - return await PhotoManager.editor.saveVideo(tempVideoFile, - title: media.name ?? "${DateTime.now()}_cloud_gallery"); + asset = await PhotoManager.editor.saveVideo( + tempVideoFile, + title: "${media.name ?? DateTime.now()}_gd_cloud_gallery.$extension", + ); } else if (media.type.isImage) { - return await PhotoManager.editor.saveImage(bytes, - title: media.name ?? "${DateTime.now()}_cloud_gallery"); + asset = await PhotoManager.editor.saveImage(bytes, + title: "${media.name ?? DateTime.now()}_gd_cloud_gallery.$extension"); } - return null; + return asset != null ? AppMedia.fromAssetEntity(asset) : null; } } From e1508c4257a9eb11c96ceb1a06399aecb1e43767 Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Thu, 11 Apr 2024 17:18:07 +0530 Subject: [PATCH 18/20] Implement google drive video preview --- app/assets/locales/app_en.arb | 8 +- app/lib/components/error_view.dart | 6 +- .../extensions/media_list_extension.dart | 30 +++-- .../flow/home/components/app_media_item.dart | 52 +-------- .../ui/flow/home/home_screen_view_model.dart | 17 +-- .../home/home_view_model_helper_mixin.dart | 73 +++--------- .../components/download_require_view.dart | 67 +++++++++-- .../media_preview/components/top_bar.dart | 2 +- .../media_preview/media_preview_screen.dart | 27 ++++- .../media_preview_view_model.dart | 79 +++++++++++-- .../media_preview_view_model.freezed.dart | 105 ++++++++++++++++-- .../components/transfer_item.dart | 37 +++--- .../circular_progress_indicator.dart | 8 +- 13 files changed, 331 insertions(+), 180 deletions(-) diff --git a/app/assets/locales/app_en.arb b/app/assets/locales/app_en.arb index 17758ff..81d8549 100644 --- a/app/assets/locales/app_en.arb +++ b/app/assets/locales/app_en.arb @@ -58,6 +58,7 @@ "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", @@ -65,6 +66,11 @@ "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." + "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/error_view.dart b/app/lib/components/error_view.dart index 700dbb2..b6e3609 100644 --- a/app/lib/components/error_view.dart +++ b/app/lib/components/error_view.dart @@ -42,15 +42,15 @@ class ErrorView extends StatelessWidget { color: context.colorScheme.containerHighOnSurface, size: 100, ), - const SizedBox(height: 20), + const SizedBox(height: 40), Text(title, - style: AppTextStyles.subtitle2.copyWith( + style: AppTextStyles.subtitle1.copyWith( color: foregroundColor?? context.colorScheme.textPrimary, )), const SizedBox(height: 20), Text( message, - style: AppTextStyles.body2.copyWith( + style: AppTextStyles.subtitle2.copyWith( color: foregroundColor ??context.colorScheme.textSecondary, ), textAlign: TextAlign.center, diff --git a/app/lib/domain/extensions/media_list_extension.dart b/app/lib/domain/extensions/media_list_extension.dart index 738981e..6b7cbc8 100644 --- a/app/lib/domain/extensions/media_list_extension.dart +++ b/app/lib/domain/extensions/media_list_extension.dart @@ -1,8 +1,9 @@ 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 MediaListHelper on List { +extension MediaListExtension on List { void removeGoogleDriveRefFromMedias({List? removeFromIds}) { for (int index = 0; index < length; index++) { if (this[index].isGoogleDriveStored && @@ -20,28 +21,27 @@ extension MediaListHelper on List { } } - void addGoogleDriveRefInMedia( - {required List process, required List processIds}) { + void addGoogleDriveRefInMedias( + {required List process, List? processIds}) { + processIds ??= process.map((e) => e.id).toList(); updateWhere( - where: (media) => processIds.contains(media.id), + where: (media) => processIds?.contains(media.id) ?? false, update: (media) { final res = process .where((element) => element.id == media.id) .first .response as AppMedia?; - return media.copyWith( - thumbnailLink: res?.thumbnailLink, - driveMediaRefId: res?.id, - sources: media.sources.toList()..add(AppMediaSource.googleDrive), - ); + if (res == null) return media; + return media.margeGoogleDriveMedia(res); }, ); } - void addLocalRefInMedias( - {required List process, required List processIds}) { + void replaceMediaRefInMedias( + {required List process, List? processIds}) { + processIds ??= process.map((e) => e.id).toList(); updateWhere( - where: (media) => processIds.contains(media.id), + where: (media) => processIds?.contains(media.id) ?? false, update: (media) { final res = process .where((element) => element.id == media.id) @@ -49,11 +49,7 @@ extension MediaListHelper on List { .response as AppMedia?; if (res == null) return media; - return res.copyWith( - thumbnailLink: media.thumbnailLink, - driveMediaRefId: media.id, - sources: res.sources.toList()..add(AppMediaSource.googleDrive), - ); + return res; }, ); } 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 b1ed149..12efc36 100644 --- a/app/lib/ui/flow/home/components/app_media_item.dart +++ b/app/lib/ui/flow/home/components/app_media_item.dart @@ -1,42 +1,17 @@ import 'dart:async'; -import 'dart:ui'; +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/services.dart'; import 'package:flutter_svg/svg.dart'; import 'package:style/extensions/context_extensions.dart'; import 'package:style/indicators/circular_progress_indicator.dart'; import 'package:style/text/app_text_style.dart'; import '../../../../domain/assets/assets_paths.dart'; import 'package:style/animations/item_selector.dart'; -import 'package:photo_manager/photo_manager.dart' - show AssetEntity, ThumbnailFormat, ThumbnailSize; - -class ThumbNailParameter { - final RootIsolateToken token; - final Size size; - final String id; - final AppMediaType type; - - ThumbNailParameter(this.token,this.size, this.id, this.type); -} - -FutureOr thumbnailDataWithSize(ThumbNailParameter thumbNailParameter) async { - BackgroundIsolateBinaryMessenger.ensureInitialized(thumbNailParameter.token); - - return await AssetEntity(id: thumbNailParameter.id, typeInt: thumbNailParameter.type.index, width: 0, height: 0) - .thumbnailDataWithSize( - ThumbnailSize(thumbNailParameter.size.width.toInt(), thumbNailParameter.size.height.toInt()), - format: ThumbnailFormat.png, - quality: 70, - ); -} - - class AppMediaItem extends StatefulWidget { final AppMedia media; @@ -60,29 +35,16 @@ class AppMediaItem extends StatefulWidget { class _AppMediaItemState extends State with AutomaticKeepAliveClientMixin { - late Future thumbnailByte; + late Future thumbnailByte; @override void initState() { if (widget.media.sources.contains(AppMediaSource.local)) { - // _loadImage(); + thumbnailByte = widget.media.loadThumbnail(); } super.initState(); } - Future _loadImage() async { - var rootToken = RootIsolateToken.instance!; - final ThumbNailParameter thumbNailParameter= ThumbNailParameter(rootToken,const Size(300,300), widget.media.id, widget.media.type); - final bytes=await compute( - thumbnailDataWithSize, thumbNailParameter - ); - - return bytes; - //thumbnailByte = widget.media.thumbnailDataWithSize(const Size(300, 300)); - } - - - @override Widget build(BuildContext context) { super.build(context); @@ -106,8 +68,6 @@ class _AppMediaItemState extends State ); } - - Widget _videoDuration(BuildContext context) => Align( alignment: Alignment.bottomRight, child: _BackgroundContainer( @@ -135,7 +95,7 @@ class _AppMediaItemState extends State {required BuildContext context, required BoxConstraints constraints}) { if (widget.media.sources.contains(AppMediaSource.local)) { return FutureBuilder( - future: _loadImage(), + future: thumbnailByte, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { @@ -157,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, 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 c3c2090..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,7 +1,9 @@ import 'dart:async'; import 'package:cloud_gallery/domain/extensions/map_extensions.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'; @@ -62,7 +64,7 @@ class HomeViewStateNotifier extends StateNotifier _backUpFolderId = null; _uploadedMedia.clear(); state = state.copyWith( - medias: removeGoogleDriveRefFromMedias(medias: state.medias), + medias: removeGoogleDriveRefFromMediaMap(medias: state.medias), ); } }); @@ -81,7 +83,7 @@ class HomeViewStateNotifier extends StateNotifier if (successUploads.isNotEmpty) { state = state.copyWith( - medias: addGoogleDriveMediaRef( + medias: addGoogleDriveRefInMediaMap( medias: state.medias, process: successUploads.toList(), )); @@ -89,7 +91,7 @@ class HomeViewStateNotifier extends StateNotifier if (successDeletes.isNotEmpty) { state = state.copyWith( - medias: removeGoogleDriveRefFromMedias( + medias: removeGoogleDriveRefFromMediaMap( medias: state.medias, removeFromIds: successDeletes.toList(), )); @@ -97,7 +99,7 @@ class HomeViewStateNotifier extends StateNotifier if (successDownloads.isNotEmpty) { state = state.copyWith( - medias: addLocalMediaRef( + medias: replaceMediaRefInMediaMap( medias: state.medias, process: successDownloads.toList(), )); @@ -221,10 +223,9 @@ class HomeViewStateNotifier extends StateNotifier state = state.copyWith( medias: sortMedias(medias: [ ...mergeCommonMedia( - localMedias: removeGoogleDriveRefFromMedias(medias: state.medias) - .values - .expand((element) => element) - .toList(), + localMedias: + state.medias.values.expand((element) => element).toList() + ..removeGoogleDriveRefFromMedias(), googleDriveMedias: uploadedMedia, ), ...googleDriveMedia 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 index 534286c..1e3c670 100644 --- a/app/lib/ui/flow/home/home_view_model_helper_mixin.dart +++ b/app/lib/ui/flow/home/home_view_model_helper_mixin.dart @@ -1,8 +1,9 @@ +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/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'; mixin HomeViewModelHelperMixin { List mergeCommonMedia({ @@ -26,11 +27,7 @@ mixin HomeViewModelHelperMixin { .forEach((googleDriveMedia) { localMedias.removeWhere((media) => media.id == localMedia.id); - mergedMedias.add(localMedia.copyWith( - sources: [AppMediaSource.local, AppMediaSource.googleDrive], - thumbnailLink: googleDriveMedia.thumbnailLink, - driveMediaRefId: googleDriveMedia.id, - )); + mergedMedias.add(localMedia.margeGoogleDriveMedia(googleDriveMedia)); }); } @@ -47,29 +44,16 @@ mixin HomeViewModelHelperMixin { ); } - Map> removeGoogleDriveRefFromMedias( + Map> removeGoogleDriveRefFromMediaMap( {required Map> medias, List? removeFromIds}) { - return medias.map((key, mediaList) { - for (int index = 0; index < mediaList.length; index++) { - if (mediaList[index].isGoogleDriveStored && - (removeFromIds?.contains(mediaList[index].id) ?? true)) { - mediaList.removeAt(index); - } else if (mediaList[index].isCommonStored && - (removeFromIds?.contains(mediaList[index].id) ?? true)) { - mediaList[index] = mediaList[index].copyWith( - sources: mediaList[index].sources.toList() - ..remove(AppMediaSource.googleDrive), - thumbnailLink: null, - driveMediaRefId: null, - ); - } - } - return MapEntry(key, mediaList); + return medias.map((key, value) { + return MapEntry(key, + value..removeGoogleDriveRefFromMedias(removeFromIds: removeFromIds)); }); } - Map> addGoogleDriveMediaRef({ + Map> addGoogleDriveRefInMediaMap({ required Map> medias, required List process, }) { @@ -78,50 +62,19 @@ mixin HomeViewModelHelperMixin { return MapEntry( key, value - ..updateWhere( - where: (media) => processIds.contains(media.id), - update: (media) { - final res = process - .where((element) => element.id == media.id) - .first - .response as AppMedia?; - return media.copyWith( - thumbnailLink: res?.thumbnailLink, - driveMediaRefId: res?.id, - sources: media.sources.toList() - ..add(AppMediaSource.googleDrive), - ); - }, - )); + ..addGoogleDriveRefInMedias( + process: process, processIds: processIds)); }); } - Map> addLocalMediaRef({ + 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 - ..updateWhere( - where: (media) => processIds.contains(media.id), - update: (media) { - final res = process - .where((element) => element.id == media.id) - .first - .response as AppMedia?; - - if (res == null) return media; - return res.copyWith( - thumbnailLink: media.thumbnailLink, - driveMediaRefId: media.id, - sources: res.sources.toList() - ..add(AppMediaSource.googleDrive), - ); - }, - )); + 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 index 7fea5f3..48df419 100644 --- a/app/lib/ui/flow/media_preview/components/download_require_view.dart +++ b/app/lib/ui/flow/media_preview/components/download_require_view.dart @@ -1,12 +1,23 @@ +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; - const DownloadRequireView({super.key, required this.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) { @@ -24,17 +35,59 @@ class DownloadRequireView extends StatelessWidget { ), ), Container( + height: double.infinity, + width: double.infinity, color: Colors.black38, - child: ErrorView( + ), + 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 ?? "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: "Download Required", - message: - "To watch the video, simply download it first. Tap the download button to begin.", - action: ErrorViewAction(title: "Download", onPressed: () {}), + 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/top_bar.dart b/app/lib/ui/flow/media_preview/components/top_bar.dart index 311fbe9..2c5cc58 100644 --- a/app/lib/ui/flow/media_preview/components/top_bar.dart +++ b/app/lib/ui/flow/media_preview/components/top_bar.dart @@ -1,6 +1,6 @@ import 'dart:io'; import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; -import 'package:data/models/media/media.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'; diff --git a/app/lib/ui/flow/media_preview/media_preview_screen.dart b/app/lib/ui/flow/media_preview/media_preview_screen.dart index a69fcfe..5e70c34 100644 --- a/app/lib/ui/flow/media_preview/media_preview_screen.dart +++ b/app/lib/ui/flow/media_preview/media_preview_screen.dart @@ -10,6 +10,7 @@ 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'; @@ -135,9 +136,8 @@ class _MediaPreviewState extends ConsumerState { context.pop(); }, child: (progress) => AppPage( - backgroundColor: progress == 0 - ? context.colorScheme.surface - : Colors.transparent, + backgroundColor: + progress == 0 ? context.colorScheme.surface : Colors.transparent, body: Stack( children: [ GestureDetector( @@ -186,7 +186,7 @@ class _MediaPreviewState extends ConsumerState { }), ); } else if (media.type.isVideo && media.isGoogleDriveStored) { - return DownloadRequireView(media: media); + return _googleDriveVideoView(context: context, media: media); } else if (media.type.isImage) { return ImagePreview(media: media); } else { @@ -197,6 +197,24 @@ class _MediaPreviewState extends ConsumerState { } } + 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 ({ @@ -263,4 +281,3 @@ class _MediaPreviewState extends ConsumerState { ); }); } - 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 index 38cf592..efef08f 100644 --- a/app/lib/ui/flow/media_preview/media_preview_view_model.dart +++ b/app/lib/ui/flow/media_preview/media_preview_view_model.dart @@ -1,4 +1,7 @@ +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'; @@ -12,6 +15,7 @@ final mediaPreviewStateNotifierProvider = StateNotifierProvider.family (ref, initial) => MediaPreviewStateNotifier( ref.read(localMediaServiceProvider), ref.read(googleDriveServiceProvider), + ref.read(googleDriveProcessRepoProvider), initial, ), ); @@ -19,10 +23,57 @@ final mediaPreviewStateNotifierProvider = StateNotifierProvider.family class MediaPreviewStateNotifier extends StateNotifier { final LocalMediaService _localMediaService; final GoogleDriveService _googleDriveService; + final GoogleDriveProcessRepo _googleDriveProcessRepo; MediaPreviewStateNotifier(this._localMediaService, this._googleDriveService, - MediaPreviewState initialState) - : super(initialState); + 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); @@ -48,31 +99,40 @@ class MediaPreviewStateNotifier extends StateNotifier { } } + Future downloadMediaFromGoogleDrive({required AppMedia media})async { + _googleDriveProcessRepo.downloadMediasFromGoogleDrive(medias: [media]); + } - void updateVideoPosition(Duration position) { - if(state.videoPosition == position) return; + void updateVideoPosition(Duration position) { + if (state.videoPosition == position) return; state = state.copyWith(videoPosition: position); } void updateVideoPlaying(bool isPlaying) { - if(state.isVideoPlaying == isPlaying) return; + if (state.isVideoPlaying == isPlaying) return; state = state.copyWith(isVideoPlaying: isPlaying); } void updateVideoBuffering(bool isBuffering) { - if(state.isVideoBuffering == isBuffering) return; + if (state.isVideoBuffering == isBuffering) return; state = state.copyWith(isVideoBuffering: isBuffering); } void updateVideoInitialized(bool isInitialized) { - if(state.isVideoInitialized == isInitialized) return; + if (state.isVideoInitialized == isInitialized) return; state = state.copyWith(isVideoInitialized: isInitialized); } void updateVideoMaxDuration(Duration maxDuration) { - if(state.videoMaxDuration == maxDuration) return; + if (state.videoMaxDuration == maxDuration) return; state = state.copyWith(videoMaxDuration: maxDuration); } + + @override + void dispose() { + _googleDriveProcessRepo.removeListener(_listenGoogleDriveProcessUpdates); + super.dispose(); + } } @freezed @@ -87,5 +147,8 @@ class MediaPreviewState with _$MediaPreviewState { @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 index 44c8737..d55aae7 100644 --- 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 @@ -25,6 +25,9 @@ mixin _$MediaPreviewState { 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 => @@ -46,7 +49,10 @@ abstract class $MediaPreviewStateCopyWith<$Res> { bool isVideoBuffering, Duration videoPosition, Duration videoMaxDuration, - bool isVideoPlaying}); + bool isVideoPlaying, + List uploadProcess, + List downloadProcess, + List deleteProcess}); } /// @nodoc @@ -71,6 +77,9 @@ class _$MediaPreviewStateCopyWithImpl<$Res, $Val extends MediaPreviewState> 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, @@ -106,6 +115,18 @@ class _$MediaPreviewStateCopyWithImpl<$Res, $Val extends MediaPreviewState> ? _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); } } @@ -127,7 +148,10 @@ abstract class _$$MediaPreviewStateImplCopyWith<$Res> bool isVideoBuffering, Duration videoPosition, Duration videoMaxDuration, - bool isVideoPlaying}); + bool isVideoPlaying, + List uploadProcess, + List downloadProcess, + List deleteProcess}); } /// @nodoc @@ -150,6 +174,9 @@ class __$$MediaPreviewStateImplCopyWithImpl<$Res> 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, @@ -185,6 +212,18 @@ class __$$MediaPreviewStateImplCopyWithImpl<$Res> ? _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, )); } } @@ -201,8 +240,14 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { this.isVideoBuffering = false, this.videoPosition = Duration.zero, this.videoMaxDuration = Duration.zero, - this.isVideoPlaying = false}) - : _medias = medias; + 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; @@ -236,10 +281,36 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { @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)'; + 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 @@ -262,7 +333,13 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { (identical(other.videoMaxDuration, videoMaxDuration) || other.videoMaxDuration == videoMaxDuration) && (identical(other.isVideoPlaying, isVideoPlaying) || - 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 @@ -276,7 +353,10 @@ class _$MediaPreviewStateImpl implements _MediaPreviewState { isVideoBuffering, videoPosition, videoMaxDuration, - isVideoPlaying); + isVideoPlaying, + const DeepCollectionEquality().hash(_uploadProcess), + const DeepCollectionEquality().hash(_downloadProcess), + const DeepCollectionEquality().hash(_deleteProcess)); @JsonKey(ignore: true) @override @@ -296,7 +376,10 @@ abstract class _MediaPreviewState implements MediaPreviewState { final bool isVideoBuffering, final Duration videoPosition, final Duration videoMaxDuration, - final bool isVideoPlaying}) = _$MediaPreviewStateImpl; + final bool isVideoPlaying, + final List uploadProcess, + final List downloadProcess, + final List deleteProcess}) = _$MediaPreviewStateImpl; @override Object? get error; @@ -317,6 +400,12 @@ abstract class _MediaPreviewState implements MediaPreviewState { @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_transfer/components/transfer_item.dart b/app/lib/ui/flow/media_transfer/components/transfer_item.dart index 4486d05..5e6ddf7 100644 --- a/app/lib/ui/flow/media_transfer/components/transfer_item.dart +++ b/app/lib/ui/flow/media_transfer/components/transfer_item.dart @@ -4,6 +4,7 @@ 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'; @@ -12,13 +13,19 @@ 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 StatelessWidget { +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( @@ -31,10 +38,10 @@ class ProcessItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - process.media.name != null && - process.media.name!.trim().isNotEmpty - ? process.media.name! - : process.media.path, + 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, ), @@ -42,16 +49,16 @@ class ProcessItem extends StatelessWidget { maxLines: 1, ), const SizedBox(height: 8), - if (process.status.isWaiting) + if (widget.process.status.isWaiting) Text( context.l10n.waiting_in_queue_text, style: AppTextStyles.body2.copyWith( color: context.colorScheme.textSecondary, ), ), - if (process.progress != null && process.status.isProcessing) ...[ + if (widget.process.progress != null && widget.process.status.isProcessing) ...[ LinearProgressIndicator( - value: process.progress?.percentageInPoint, + value: widget.process.progress?.percentageInPoint, backgroundColor: context.colorScheme.outline, borderRadius: BorderRadius.circular(4), valueColor: AlwaysStoppedAnimation( @@ -62,13 +69,13 @@ class ProcessItem extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '${process.progress?.chunk.formatBytes} ${process.progress?.percentage.toStringAsFixed(2)}%', + '${widget.process.progress?.chunk.formatBytes} ${widget.process.progress?.percentage.toStringAsFixed(2)}%', style: AppTextStyles.body2.copyWith( color: context.colorScheme.textSecondary, ), ), Text( - '${process.progress?.total.formatBytes}', + '${widget.process.progress?.total.formatBytes}', style: AppTextStyles.body2.copyWith( color: context.colorScheme.textSecondary, ), @@ -79,9 +86,9 @@ class ProcessItem extends StatelessWidget { ], ), ), - if (process.status.isWaiting) + if (widget.process.status.isWaiting) ActionButton( - onPressed: onCancelTap, + onPressed: widget.onCancelTap, icon: const Icon(CupertinoIcons.xmark), ) ], @@ -89,9 +96,9 @@ class ProcessItem extends StatelessWidget { } Widget _buildThumbnailView({required BuildContext context}) { - if (process.media.sources.contains(AppMediaSource.local)) { + if (widget.process.media.sources.contains(AppMediaSource.local)) { return FutureByteLoader( - bytes: process.media.thumbnailDataWithSize(const Size(100, 100)), + bytes: widget.process.media.loadThumbnail(size: const Size(100, 100)), builder: (context, bytes) => Container( width: 60, height: 60, @@ -109,7 +116,7 @@ class ProcessItem extends StatelessWidget { ); } else { return CachedNetworkImage( - imageUrl: process.media.thumbnailLink!, + imageUrl: widget.process.media.thumbnailLink!, width: 80, height: 80, fit: BoxFit.cover, diff --git a/style/lib/indicators/circular_progress_indicator.dart b/style/lib/indicators/circular_progress_indicator.dart index bb62b68..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 @@ -20,11 +24,12 @@ class AppCircularProgressIndicator extends StatelessWidget { height: size, child: value != null ? CircularProgressIndicator( - strokeWidth: size / 8, + strokeWidth: strokeWidth ?? size / 8, value: value, valueColor: AlwaysStoppedAnimation( color ?? context.colorScheme.primary, ), + backgroundColor: backgroundColor, strokeCap: StrokeCap.round, ) : CircularProgressIndicator.adaptive( @@ -32,6 +37,7 @@ class AppCircularProgressIndicator extends StatelessWidget { valueColor: AlwaysStoppedAnimation( color ?? context.colorScheme.primary, ), + backgroundColor: backgroundColor, strokeCap: StrokeCap.round, ), ); From 9616dd2e0f796a5eb1654fc1bd269b3423104a4c Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Thu, 11 Apr 2024 19:30:54 +0530 Subject: [PATCH 19/20] Implement google drive preview --- .../components/download_require_view.dart | 2 +- .../components/transfer_item.dart | 2 +- .../google_drive_process_repo.dart | 41 ++++-------- data/lib/services/local_media_service.dart | 66 +++++++++++++------ 4 files changed, 62 insertions(+), 49 deletions(-) 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 index 48df419..9dafe1e 100644 --- a/app/lib/ui/flow/media_preview/components/download_require_view.dart +++ b/app/lib/ui/flow/media_preview/components/download_require_view.dart @@ -61,7 +61,7 @@ class DownloadRequireView extends StatelessWidget { ], ), title: - "${downloadProcess?.progress?.chunk.formatBytes ?? "0.0 B"} - ${downloadProcess?.progress?.total.formatBytes ?? "0.0 B"} ${downloadProcess?.progress?.percentage ?? "0.0"}%", + "${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) ...[ diff --git a/app/lib/ui/flow/media_transfer/components/transfer_item.dart b/app/lib/ui/flow/media_transfer/components/transfer_item.dart index 5e6ddf7..aff7a6a 100644 --- a/app/lib/ui/flow/media_transfer/components/transfer_item.dart +++ b/app/lib/ui/flow/media_transfer/components/transfer_item.dart @@ -69,7 +69,7 @@ class _ProcessItemState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '${widget.process.progress?.chunk.formatBytes} ${widget.process.progress?.percentage.toStringAsFixed(2)}%', + '${widget.process.progress?.chunk.formatBytes} ${widget.process.progress?.percentage.toStringAsFixed(0)}%', style: AppTextStyles.body2.copyWith( color: context.colorScheme.textSecondary, ), diff --git a/data/lib/repositories/google_drive_process_repo.dart b/data/lib/repositories/google_drive_process_repo.dart index ff97b59..2cd2ed3 100644 --- a/data/lib/repositories/google_drive_process_repo.dart +++ b/data/lib/repositories/google_drive_process_repo.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:collection/collection.dart'; import 'package:data/extensions/iterable_extension.dart'; import 'package:data/models/app_process/app_process.dart'; @@ -164,7 +163,6 @@ class GoogleDriveProcessRepo extends ChangeNotifier { } Future _downloadFromGoogleDrive(AppProcess process) async { - StreamSubscription? subscription; try { _downloadQueue.updateWhere( where: (element) => element.id == process.id, @@ -176,30 +174,19 @@ class GoogleDriveProcessRepo extends ChangeNotifier { final mediaContent = await _googleDriveService .fetchMediaBytes(process.media.driveMediaRefId!); - List bytes = []; - - subscription = mediaContent.stream.listen((chunk) { - bytes.addAll(chunk); - _downloadQueue.updateWhere( - where: (element) => element.id == process.id, - update: (element) => element.copyWith( - progress: AppProcessProgress( - total: mediaContent.length ?? 0, chunk: bytes.length)), - ); - notifyListeners(); - }, onError: (error) { - _downloadQueue.updateWhere( - where: (element) => element.id == process.id, - update: (element) => - element.copyWith(status: AppProcessStatus.failed), - ); - notifyListeners(); - subscription?.cancel(); - }); - await subscription.asFuture(); - final localMedia = await _localMediaService.saveMedia( - process.media, Uint8List.fromList(bytes)); + 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 ?? ""); @@ -210,14 +197,12 @@ class GoogleDriveProcessRepo extends ChangeNotifier { status: AppProcessStatus.success, response: localMedia?.margeGoogleDriveMedia(updatedMedia)), ); - - notifyListeners(); - subscription.cancel(); } catch (error) { _downloadQueue.updateWhere( where: (element) => element.id == process.id, update: (element) => element.copyWith(status: AppProcessStatus.failed), ); + } finally { notifyListeners(); } } diff --git a/data/lib/services/local_media_service.dart b/data/lib/services/local_media_service.dart index dbb02f7..501c3d2 100644 --- a/data/lib/services/local_media_service.dart +++ b/data/lib/services/local_media_service.dart @@ -1,7 +1,8 @@ +import 'dart:async'; import 'dart:io'; -import 'dart:typed_data'; 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'; @@ -56,25 +57,52 @@ class LocalMediaService { } } - Future saveMedia(AppMedia media, Uint8List bytes) async { - final extension = media.mimeType?.trim().isNotEmpty ?? false - ? media.mimeType!.split('/').last - : media.type.isVideo - ? 'mp4' - : 'jpg'; - AssetEntity? asset; - if (media.type.isVideo) { + 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 tempVideoFile = File('${tempDir.path}/temp_video'); - await tempVideoFile.writeAsBytes(bytes); - asset = await PhotoManager.editor.saveVideo( - tempVideoFile, - title: "${media.name ?? DateTime.now()}_gd_cloud_gallery.$extension", - ); - } else if (media.type.isImage) { - asset = await PhotoManager.editor.saveImage(bytes, - title: "${media.name ?? DateTime.now()}_gd_cloud_gallery.$extension"); + 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); } - return asset != null ? AppMedia.fromAssetEntity(asset) : null; } } From 9b7113ede5806164a91f3b89e52a505817dc5b92 Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Thu, 11 Apr 2024 19:35:57 +0530 Subject: [PATCH 20/20] Rename func --- app/lib/domain/extensions/media_list_extension.dart | 2 +- app/lib/ui/flow/home/home_view_model_helper_mixin.dart | 2 +- data/lib/models/media/media_extension.dart | 2 +- data/lib/repositories/google_drive_process_repo.dart | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/lib/domain/extensions/media_list_extension.dart b/app/lib/domain/extensions/media_list_extension.dart index 6b7cbc8..49900ce 100644 --- a/app/lib/domain/extensions/media_list_extension.dart +++ b/app/lib/domain/extensions/media_list_extension.dart @@ -32,7 +32,7 @@ extension MediaListExtension on List { .first .response as AppMedia?; if (res == null) return media; - return media.margeGoogleDriveMedia(res); + return media.mergeGoogleDriveMedia(res); }, ); } 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 index 1e3c670..b258dea 100644 --- a/app/lib/ui/flow/home/home_view_model_helper_mixin.dart +++ b/app/lib/ui/flow/home/home_view_model_helper_mixin.dart @@ -27,7 +27,7 @@ mixin HomeViewModelHelperMixin { .forEach((googleDriveMedia) { localMedias.removeWhere((media) => media.id == localMedia.id); - mergedMedias.add(localMedia.margeGoogleDriveMedia(googleDriveMedia)); + mergedMedias.add(localMedia.mergeGoogleDriveMedia(googleDriveMedia)); }); } diff --git a/data/lib/models/media/media_extension.dart b/data/lib/models/media/media_extension.dart index 2e09b84..be7857a 100644 --- a/data/lib/models/media/media_extension.dart +++ b/data/lib/models/media/media_extension.dart @@ -36,7 +36,7 @@ extension AppMediaExtension on AppMedia { ); } - AppMedia margeGoogleDriveMedia(AppMedia media){ + AppMedia mergeGoogleDriveMedia(AppMedia media){ return copyWith( thumbnailLink: media.thumbnailLink, driveMediaRefId: media.driveMediaRefId, diff --git a/data/lib/repositories/google_drive_process_repo.dart b/data/lib/repositories/google_drive_process_repo.dart index 2cd2ed3..3431d77 100644 --- a/data/lib/repositories/google_drive_process_repo.dart +++ b/data/lib/repositories/google_drive_process_repo.dart @@ -195,7 +195,7 @@ class GoogleDriveProcessRepo extends ChangeNotifier { where: (element) => element.id == process.id, update: (element) => element.copyWith( status: AppProcessStatus.success, - response: localMedia?.margeGoogleDriveMedia(updatedMedia)), + response: localMedia?.mergeGoogleDriveMedia(updatedMedia)), ); } catch (error) { _downloadQueue.updateWhere(