diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml index f48099f..6bccb1c 100644 --- a/.idea/libraries/Dart_Packages.xml +++ b/.idea/libraries/Dart_Packages.xml @@ -1014,7 +1014,7 @@ - @@ -1252,7 +1252,7 @@ - + diff --git a/.idea/libraries/Flutter_Plugins.xml b/.idea/libraries/Flutter_Plugins.xml index 8f6f0be..5371787 100644 --- a/.idea/libraries/Flutter_Plugins.xml +++ b/.idea/libraries/Flutter_Plugins.xml @@ -22,13 +22,13 @@ - + diff --git a/app/assets/locales/app_en.arb b/app/assets/locales/app_en.arb index e55366b..28f1489 100644 --- a/app/assets/locales/app_en.arb +++ b/app/assets/locales/app_en.arb @@ -10,10 +10,15 @@ "common_google_drive": "Google Drive", "common_term_and_condition": "Terms and Conditions", "common_privacy_policy": "Privacy Policy", + "common_today": "Today", + "common_yesterday": "Yesterday", + "common_tomorrow": "Tomorrow", "no_internet_connection_error": "No internet connection! Please check your network and try again.", "something_went_wrong_error": "Something went wrong! Please try again later.", "user_google_sign_in_account_not_found_error": "You haven't signed in with Google account yet. Please sign in with Google account and try again.", + "back_up_folder_not_found_error": "Back up folder not found!", + "unable_to_open_attachment_error": "Unable to open attachment!", "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 4839f8f..e91d72f 100644 --- a/app/lib/components/app_page.dart +++ b/app/lib/components/app_page.dart @@ -9,8 +9,11 @@ class AppPage extends StatelessWidget { final Widget? leading; final Widget? floatingActionButton; final Widget? body; + final Widget Function(BuildContext context)? bodyBuilder; final bool automaticallyImplyLeading; final bool? resizeToAvoidBottomInset; + final Color? backgroundColor; + final Color? barBackgroundColor; const AppPage({ super.key, @@ -21,7 +24,10 @@ class AppPage extends StatelessWidget { this.body, this.floatingActionButton, this.resizeToAvoidBottomInset, + this.bodyBuilder, this.automaticallyImplyLeading = true, + this.barBackgroundColor, + this.backgroundColor, }); @override @@ -39,6 +45,7 @@ class AppPage extends StatelessWidget { leading == null ? null : CupertinoNavigationBar( + backgroundColor: barBackgroundColor, leading: leading, middle: titleWidget ?? _title(context), border: null, @@ -56,10 +63,15 @@ class AppPage extends StatelessWidget { : null, ), resizeToAvoidBottomInset: resizeToAvoidBottomInset ?? true, + backgroundColor: backgroundColor, child: Stack( alignment: Alignment.bottomRight, children: [ - body ?? const SizedBox(), + body ?? + Builder( + builder: (context) => + bodyBuilder?.call(context) ?? const SizedBox(), + ), SafeArea( child: Padding( padding: const EdgeInsets.only(right: 16, bottom: 16), @@ -76,12 +88,18 @@ class AppPage extends StatelessWidget { leading == null ? null : AppBar( + backgroundColor: barBackgroundColor, title: titleWidget ?? _title(context), actions: actions, leading: leading, automaticallyImplyLeading: automaticallyImplyLeading, ), - body: body, + body: body ?? + Builder( + builder: (context) => + bodyBuilder?.call(context) ?? const SizedBox(), + ), + backgroundColor: backgroundColor, floatingActionButton: floatingActionButton, resizeToAvoidBottomInset: resizeToAvoidBottomInset, ); @@ -92,3 +110,46 @@ class AppPage extends StatelessWidget { overflow: TextOverflow.ellipsis, ); } + +class AdaptiveAppBar extends StatelessWidget { + final String text; + final Widget? leading; + final List? actions; + final bool iosTransitionBetweenRoutes; + final bool automaticallyImplyLeading; + + const AdaptiveAppBar( + {super.key, + required this.text, + this.leading, + this.actions, + this.iosTransitionBetweenRoutes = true, + this.automaticallyImplyLeading = true}); + + @override + Widget build(BuildContext context) { + return Platform.isIOS || Platform.isMacOS + ? CupertinoNavigationBar( + transitionBetweenRoutes: iosTransitionBetweenRoutes, + middle: Text(text), + previousPageTitle: + MaterialLocalizations.of(context).backButtonTooltip, + automaticallyImplyLeading: automaticallyImplyLeading, + leading: leading, + trailing: actions == null + ? null + : actions!.length == 1 + ? actions!.first + : Row( + mainAxisSize: MainAxisSize.min, + children: actions!, + ), + ) + : AppBar( + leading: leading, + actions: actions, + automaticallyImplyLeading: automaticallyImplyLeading, + title: Text(text), + ); + } +} diff --git a/app/lib/domain/extensions/app_error_extensions.dart b/app/lib/domain/extensions/app_error_extensions.dart index 45b5ef8..a1e751c 100644 --- a/app/lib/domain/extensions/app_error_extensions.dart +++ b/app/lib/domain/extensions/app_error_extensions.dart @@ -13,6 +13,8 @@ extension AppErrorExtensions on Object { return context.l10n.something_went_wrong_error; case AppErrorL10nCodes.googleSignInUserNotFoundError: return context.l10n.user_google_sign_in_account_not_found_error; + case AppErrorL10nCodes.backUpFolderNotFound: + return context.l10n.back_up_folder_not_found_error; default: return (this as AppError).message ?? context.l10n.something_went_wrong_error; diff --git a/app/lib/domain/extensions/date_extensions.dart b/app/lib/domain/extensions/date_extensions.dart deleted file mode 100644 index 016df91..0000000 --- a/app/lib/domain/extensions/date_extensions.dart +++ /dev/null @@ -1,3 +0,0 @@ -extension DateTimeExtensions on DateTime { - DateTime get dateOnly => DateTime(year, month, day); -} \ No newline at end of file diff --git a/app/lib/domain/extensions/map_extensions.dart b/app/lib/domain/extensions/map_extensions.dart new file mode 100644 index 0000000..017dff4 --- /dev/null +++ b/app/lib/domain/extensions/map_extensions.dart @@ -0,0 +1,4 @@ +extension MapExtension on Map> { + List valuesWhere(bool Function(E element) test) => + values.expand((element) => element.where(test)).toList(); +} diff --git a/app/lib/domain/formatter/date_formatter.dart b/app/lib/domain/formatter/date_formatter.dart new file mode 100644 index 0000000..ad8d842 --- /dev/null +++ b/app/lib/domain/formatter/date_formatter.dart @@ -0,0 +1,54 @@ +import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:intl/intl.dart'; + +enum DateFormatType { + dayMonthYear("d MMMM, y"), + dayMonthYearShort("d MMM, y"), + monthYear("MMMM y"), + monthYearShort("MMM y"), + dayMonth("d MMMM"), + dayMonthShort("d MMM"), + year("y"), + month("MMMM"), + monthShort("MMM"), + week("EEEE"), + time("HH:mm:ss a"), + timeShort("HH:mm a"), + relative("relative"); + + final String formatPattern; + + const DateFormatType(this.formatPattern); + + bool get isRelative => this == DateFormatType.relative; +} + +extension DateFormatter on DateTime { + DateTime get dateOnly => DateTime(year, month, day); + + String format(BuildContext context, DateFormatType type) { + if (isUtc) return toLocal().format(context, type); + if (type.isRelative) return relativeFormat(context); + return DateFormat(type.formatPattern).format(this); + } + + String relativeFormat(BuildContext context) { + if (isUtc) return toLocal().relativeFormat(context); + final now = DateTime.now().toLocal(); + final diff = now.difference(this); + if (diff.inDays == 0) { + return context.l10n.common_today; + } else if (diff.inDays == 1) { + return context.l10n.common_yesterday; + } else if (diff.inDays == -1) { + return context.l10n.common_tomorrow; + } else if (diff.inDays < 7) { + return DateFormat(DateFormatType.week.formatPattern).format(this); + } else if (now.year == year) { + return DateFormat(DateFormatType.dayMonth.formatPattern).format(this); + } else { + return DateFormat(DateFormatType.dayMonthYear.formatPattern).format(this); + } + } +} diff --git a/app/lib/domain/formatter/duration_formatter.dart b/app/lib/domain/formatter/duration_formatter.dart new file mode 100644 index 0000000..b66f2d6 --- /dev/null +++ b/app/lib/domain/formatter/duration_formatter.dart @@ -0,0 +1,18 @@ +extension DurationFormatter on Duration { + String get format { + String twoDigits(int n) { + if (n >= 10) return "$n"; + return "0$n"; + } + + String twoDigitHours = twoDigits(inHours.remainder(24)); + String twoDigitMinutes = twoDigits(inMinutes.remainder(60)); + String twoDigitSeconds = twoDigits(inSeconds.remainder(60)); + + if (inHours > 0) { + return "$twoDigitHours:$twoDigitMinutes:$twoDigitSeconds"; + } else { + return "$twoDigitMinutes:$twoDigitSeconds"; + } + } +} diff --git a/app/lib/ui/flow/accounts/accounts_screen.dart b/app/lib/ui/flow/accounts/accounts_screen.dart index 5cc6cb4..3fdf06c 100644 --- a/app/lib/ui/flow/accounts/accounts_screen.dart +++ b/app/lib/ui/flow/accounts/accounts_screen.dart @@ -39,56 +39,58 @@ class _AccountsScreenState extends ConsumerState { return AppPage( title: context.l10n.common_accounts, - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - if (googleAccount != null) - AccountsTab( - name: googleAccount.displayName ?? googleAccount.email, - serviceDescription: context.l10n.common_google_drive, - profileImage: googleAccount.photoUrl, - actionList: ActionList(buttons: [ - ActionListButton( - title: context.l10n.common_auto_back_up, - trailing: Consumer( - builder: (context, ref, child) { - final googleDriveAutoBackUp = ref - .watch(AppPreferences.canTakeAutoBackUpInGoogleDrive); - return AppSwitch( - value: googleDriveAutoBackUp, - onChanged: (bool value) { - ref - .read(AppPreferences - .canTakeAutoBackUpInGoogleDrive.notifier) - .state = value; - }, - ); - }, + bodyBuilder: (context) { + return ListView( + padding: context.systemPadding + const EdgeInsets.all(16), + children: [ + if (googleAccount != null) + AccountsTab( + name: googleAccount.displayName ?? googleAccount.email, + serviceDescription: context.l10n.common_google_drive, + profileImage: googleAccount.photoUrl, + actionList: ActionList(buttons: [ + ActionListButton( + title: context.l10n.common_auto_back_up, + trailing: Consumer( + builder: (context, ref, child) { + final googleDriveAutoBackUp = ref.watch( + AppPreferences.canTakeAutoBackUpInGoogleDrive); + return AppSwitch( + value: googleDriveAutoBackUp, + onChanged: (bool value) { + ref + .read(AppPreferences + .canTakeAutoBackUpInGoogleDrive.notifier) + .state = value; + }, + ); + }, + ), ), + ActionListButton( + title: context.l10n.common_sign_out, + onPressed: notifier.signOutWithGoogle, + ), + ]), + backgroundColor: AppColors.googleDriveColor.withAlpha(50), + ), + if (googleAccount == null) + OnTapScale( + onTap: () { + notifier.signInWithGoogle(); + }, + child: AccountsTab( + name: context.l10n.add_account_title, + backgroundColor: context.colorScheme.containerNormal, ), - ActionListButton( - title: context.l10n.common_sign_out, - onPressed: notifier.signOutWithGoogle, - ), - ]), - backgroundColor: AppColors.googleDriveColor.withAlpha(50), - ), - if (googleAccount == null) - OnTapScale( - onTap: () { - notifier.signInWithGoogle(); - }, - child: AccountsTab( - name: context.l10n.add_account_title, - backgroundColor: context.colorScheme.containerNormal, ), - ), - const SizedBox(height: 16), - const SettingsActionList(), - const SizedBox(height: 16), - _buildVersion(context: context), - ], - ), + const SizedBox(height: 16), + const SettingsActionList(), + const SizedBox(height: 16), + _buildVersion(context: context), + ], + ); + }, ); } diff --git a/app/lib/ui/flow/home/components/app_media_item.dart b/app/lib/ui/flow/home/components/app_media_item.dart index f1ba4c7..c7e3b03 100644 --- a/app/lib/ui/flow/home/components/app_media_item.dart +++ b/app/lib/ui/flow/home/components/app_media_item.dart @@ -1,10 +1,14 @@ -import 'dart:io'; +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'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_svg/svg.dart'; import 'package:style/extensions/context_extensions.dart'; import 'package:style/indicators/circular_progress_indicator.dart'; +import 'package:style/text/app_text_style.dart'; import '../../../../domain/assets/assets_paths.dart'; import 'package:style/animations/item_selector.dart'; @@ -13,7 +17,7 @@ class AppMediaItem extends StatefulWidget { final void Function()? onTap; final void Function()? onLongTap; final bool isSelected; - final bool isUploading; + final UploadStatus? status; const AppMediaItem({ super.key, @@ -21,7 +25,7 @@ class AppMediaItem extends StatefulWidget { this.onTap, this.onLongTap, this.isSelected = false, - this.isUploading = false, + this.status, }); @override @@ -30,145 +34,158 @@ class AppMediaItem extends StatefulWidget { class _AppMediaItemState extends State with AutomaticKeepAliveClientMixin { - //VideoPlayerController? _videoPlayerController; + late Future thumbnailByte; @override void initState() { - if (widget.media.type.isVideo && - widget.media.sources.contains(AppMediaSource.local)) { - // if (widget.media.sources.contains(AppMediaSource.local)) { - // _videoPlayerController = - // VideoPlayerController.file(File(widget.media.path)) - // ..initialize().then((_) { - // setState(() {}); - // }); - // } + if (widget.media.sources.contains(AppMediaSource.local)) { + _loadImage(); } super.initState(); } - @override - void dispose() { - // _videoPlayerController?.dispose(); - super.dispose(); + _loadImage() async { + thumbnailByte = widget.media.thumbnailDataWithSize(const Size(300, 300)); } @override Widget build(BuildContext context) { super.build(context); - return ItemSelector( - onTap: widget.onTap, - onLongTap: widget.onLongTap, - isSelected: widget.isSelected, - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Stack( - alignment: Alignment.bottomLeft, - children: [ - widget.media.type.isVideo && - widget.media.sources.contains(AppMediaSource.local) - ? _buildVideoView(context: context) - : _buildImageView(context: context), - if (widget.media.sources.contains(AppMediaSource.googleDrive) || - widget.isUploading) + return LayoutBuilder( + builder: (context, constraints) => ItemSelector( + onTap: widget.onTap, + onLongTap: widget.onLongTap, + isSelected: widget.isSelected, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Stack( + alignment: Alignment.bottomLeft, + children: [ + _buildMediaView(context: context, constraints: constraints), + if (widget.media.type.isVideo) _videoDuration(context), _sourceIndicators(context: context), - ], + ], + ), ), ), ); } - Widget _sourceIndicators({required BuildContext context}) { - return Container( - margin: const EdgeInsets.all(4), - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: context.colorScheme.surface, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.media.sources.contains(AppMediaSource.googleDrive)) - SvgPicture.asset( - Assets.images.icons.googlePhotos, - height: 12, - width: 12, - ), - if (widget.isUploading) - const Padding( - padding: EdgeInsets.all(2), - child: AppCircularProgressIndicator(size: 12), - ), - ], - ), - ); - } - - Widget _buildImageView({required BuildContext context}) { - return LayoutBuilder(builder: (context, constraints) { - return Image( - image: widget.media.sources.contains(AppMediaSource.local) - ? ResizeImage( - FileImage(File(widget.media.path)), - height: constraints.maxHeight.toInt(), - width: constraints.maxWidth.toInt(), - policy: ResizeImagePolicy.fit, - ) - : CachedNetworkImageProvider(widget.media.thumbnailPath!) - as ImageProvider, - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) { - return child; - } - return Container( - width: double.maxFinite, - height: double.maxFinite, - color: context.colorScheme.containerNormalOnSurface, - child: Center( - child: AppCircularProgressIndicator( - value: (loadingProgress.expectedTotalBytes != null && - (loadingProgress.expectedTotalBytes ?? 0) > 0) - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, + Widget _videoDuration(BuildContext context) => Align( + alignment: Alignment.bottomRight, + child: _BackgroundContainer( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.play_fill, + color: context.colorScheme.surfaceInverse, + size: 14, ), - ), - ); - }, - errorBuilder: (context, error, stackTrace) { - return Container( - width: double.maxFinite, - height: double.maxFinite, - color: context.colorScheme.containerNormalOnSurface, - child: Center( - child: Icon( - CupertinoIcons.photo, - color: context.colorScheme.onPrimary, - size: 32, + const SizedBox(width: 2), + Text( + (widget.media.videoDuration ?? Duration.zero).format, + style: AppTextStyles.caption.copyWith( + color: context.colorScheme.surfaceInverse, + ), ), - ), - ); + ], + ), + ), + ); + + Widget _buildMediaView( + {required BuildContext context, required BoxConstraints constraints}) { + if (widget.media.sources.contains(AppMediaSource.local)) { + return FutureBuilder( + future: thumbnailByte, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done && + snapshot.hasData) { + return Hero( + tag: widget.media, + child: Image.memory( + snapshot.data!, + width: constraints.maxWidth, + height: constraints.maxHeight, + fit: BoxFit.cover, + ), + ); + } else { + return _buildPlaceholder(context: context, showLoader: false); + } }, - width: double.maxFinite, - height: double.maxFinite, ); - }); + } else { + return Hero( + tag: widget.media, + child: CachedNetworkImage( + imageUrl: widget.media.thumbnailLink!, + width: constraints.maxWidth, + height: constraints.maxHeight, + fit: BoxFit.cover, + errorWidget: (context, url, error) => _buildErrorWidget(context), + progressIndicatorBuilder: (context, url, progress) => + _buildPlaceholder( + context: context, + value: progress.progress, + )), + ); + } } - Widget _buildVideoView({required BuildContext context}) { - return Stack( - alignment: Alignment.center, + 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, + ), + ); + + Widget _sourceIndicators({required BuildContext context}) { + return Row( children: [ - Container( - decoration: BoxDecoration( - color: context.colorScheme.containerNormalOnSurface, + if (widget.media.sources.contains(AppMediaSource.googleDrive)) + _BackgroundContainer( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.media.sources.contains(AppMediaSource.googleDrive)) + SvgPicture.asset( + Assets.images.icons.googlePhotos, + height: 14, + width: 14, + ), + ], + ), + ), + if (widget.status == UploadStatus.uploading) + _BackgroundContainer( + child: AppCircularProgressIndicator( + size: 16, + color: context.colorScheme.surfaceInverse, + ), + ), + if (widget.status == UploadStatus.waiting) + _BackgroundContainer( + child: Icon( + CupertinoIcons.time, + size: 16, + color: context.colorScheme.surfaceInverse, + ), ), - // child: VideoPlayer(_videoPlayerController!), - ), - Icon(CupertinoIcons.play_arrow_solid, - color: context.colorScheme.onPrimary), ], ); } @@ -176,3 +193,22 @@ class _AppMediaItemState extends State @override bool get wantKeepAlive => true; } + +class _BackgroundContainer extends StatelessWidget { + final Widget child; + + const _BackgroundContainer({required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.all(4), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: context.colorScheme.surface.withOpacity(0.6), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + child: child, + ); + } +} 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 d3ebd2f..10b9ebe 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 @@ -42,7 +42,7 @@ class NoLocalMediasAccessScreen extends ConsumerWidget { PrimaryButton( onPressed: () async { await openAppSettings(); - await notifier.loadMedias(); + await notifier.loadLocalMedia(); }, text: 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 2a21319..7752fef 100644 --- a/app/lib/ui/flow/home/home_screen.dart +++ b/app/lib/ui/flow/home/home_screen.dart @@ -1,12 +1,17 @@ +import 'dart:io'; + import 'package:cloud_gallery/components/app_page.dart'; +import 'package:cloud_gallery/domain/extensions/widget_extensions.dart'; +import 'package:cloud_gallery/domain/formatter/date_formatter.dart'; +import 'package:cloud_gallery/ui/flow/media_preview/media_preview.dart'; import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; import 'package:cloud_gallery/ui/flow/home/components/no_local_medias_access_screen.dart'; import 'package:cloud_gallery/ui/flow/home/home_screen_view_model.dart'; +import 'package:collection/collection.dart'; import 'package:data/models/media/media.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; import 'package:style/extensions/context_extensions.dart'; import 'package:style/indicators/circular_progress_indicator.dart'; import 'package:style/text/app_text_style.dart'; @@ -16,7 +21,7 @@ import '../../navigation/app_router.dart'; import 'components/app_media_item.dart'; import 'components/hints.dart'; import 'components/multi_selection_done_button.dart'; -import 'package:style/slivers/sticky_header_delegate.dart'; +import 'package:style/buttons/action_button.dart'; class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @@ -53,15 +58,11 @@ class _HomeScreenState extends ConsumerState { @override Widget build(BuildContext context) { _errorObserver(); - return AppPage( + //barBackgroundColor: context.colorScheme.surface, titleWidget: _titleWidget(context: context), actions: [ - IconButton( - style: IconButton.styleFrom( - backgroundColor: context.colorScheme.containerNormalOnSurface, - minimumSize: const Size(28, 28), - ), + ActionButton( onPressed: () { AppRouter.accounts.push(context); }, @@ -77,126 +78,138 @@ class _HomeScreenState extends ConsumerState { } Widget _body({required BuildContext context}) { - //States - final medias = - ref.watch(homeViewStateNotifier.select((state) => state.medias)); - final isLoading = - ref.watch(homeViewStateNotifier.select((state) => state.loading)); - - final selectedMedias = ref - .watch(homeViewStateNotifier.select((state) => state.selectedMedias)); - - final uploadingMedias = ref - .watch(homeViewStateNotifier.select((state) => state.uploadingMedias)); - - final hasLocalMediaAccess = ref.watch( - homeViewStateNotifier.select((state) => state.hasLocalMediaAccess)); + //View State + final ({ + Map> medias, + List uploadingMedias, + List selectedMedias, + bool isLoading, + bool hasLocalMediaAccess, + String? lastLocalMediaId + }) state = ref.watch(homeViewStateNotifier.select((value) => ( + medias: value.medias, + uploadingMedias: value.uploadingMedias, + selectedMedias: value.selectedMedias, + isLoading: value.loading, + hasLocalMediaAccess: value.hasLocalMediaAccess, + lastLocalMediaId: value.lastLocalMediaId, + ))); //View - if (isLoading) { + if (state.isLoading) { return const Center(child: AppCircularProgressIndicator()); - } else if (medias.isEmpty && !hasLocalMediaAccess) { + } else if (state.medias.isEmpty && !state.hasLocalMediaAccess) { return const NoLocalMediasAccessScreen(); } - return RefreshIndicator.adaptive( - onRefresh: () async { - await notifier.loadMedias(); - }, - child: Stack( - alignment: Alignment.bottomRight, - children: [ - _buildMediaList( - context: context, - medias: medias, - uploadingMedias: uploadingMedias, - selectedMedias: selectedMedias, + return Stack( + alignment: Alignment.bottomRight, + children: [ + _buildMediaList( + context: context, + medias: state.medias, + uploadingMedias: state.uploadingMedias, + selectedMedias: state.selectedMedias, + lastLocalMediaId: state.lastLocalMediaId, + ), + if (state.selectedMedias.isNotEmpty) + Padding( + padding: context.systemPadding + const EdgeInsets.all(16), + child: const MultiSelectionDoneButton(), ), - if (selectedMedias.isNotEmpty) - Padding( - padding: context.systemPadding + const EdgeInsets.all(16), - child: const MultiSelectionDoneButton(), - ), - ], - ), + ], ); } Widget _buildMediaList( {required BuildContext context, required Map> medias, - required List uploadingMedias, + required List uploadingMedias, + required String? lastLocalMediaId, required List selectedMedias}) { return Scrollbar( controller: _scrollController, interactive: true, - child: CustomScrollView(controller: _scrollController, slivers: [ - const SliverToBoxAdapter( - child: HomeScreenHints(), - ), - ...medias.entries - .map( - (e) => SliverMainAxisGroup( - slivers: [ - SliverPersistentHeader( - delegate: SliverStickyHeaderDelegate( - header: Container( - padding: const EdgeInsets.only(left: 16), - alignment: Alignment.centerLeft, - decoration: BoxDecoration( - color: context.colorScheme.surface, - ), - child: Text( - DateFormat("d MMMM, y").format(e.key), - style: AppTextStyles.subtitle1.copyWith( - color: context.colorScheme.textPrimary, - ), - ), - ), - ), - pinned: true, + child: ListView.builder( + controller: _scrollController, + itemCount: medias.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return const HomeScreenHints(); + } else { + final gridEntry = medias.entries.elementAt(index - 1); + return Column( + children: [ + Container( + height: 45, + padding: const EdgeInsets.only(left: 16, top: 5), + margin: EdgeInsets.zero, + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + color: context.colorScheme.surface, ), - SliverPadding( - padding: - const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - sliver: SliverGrid.builder( - itemBuilder: (context, index) { - final media = e.value[index]; - return AppMediaItem( - key: ValueKey(media.id), - onTap: () { - if (selectedMedias.isNotEmpty) { - notifier.toggleMediaSelection(media); - } - }, - onLongTap: () { - notifier.toggleMediaSelection(media); - }, - isSelected: selectedMedias.contains(media), - isUploading: uploadingMedias.contains(media.id), - media: media, - ); - }, - itemCount: e.value.length, - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 4, - mainAxisSpacing: 4, - ), + child: Text( + gridEntry.key.format(context, DateFormatType.relative), + style: AppTextStyles.subtitle1.copyWith( + color: context.colorScheme.textPrimary, ), ), - ], - ), - ) - .toList(), - ]), + ), + GridView.builder( + padding: const EdgeInsets.all(4), + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: context.mediaQuerySize.width > 600 + ? context.mediaQuerySize.width ~/ 180 + : context.mediaQuerySize.width ~/ 100, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + ), + itemCount: gridEntry.value.length, + itemBuilder: (context, index) { + final media = gridEntry.value[index]; + if (media.id == lastLocalMediaId) { + runPostFrame(() { + notifier.loadLocalMedia(append: true); + }); + } + return AppMediaItem( + key: ValueKey(media.id), + onTap: () { + if (selectedMedias.isNotEmpty) { + notifier.toggleMediaSelection(media); + } else { + AppMediaView.showPreview( + context: context, + media: media, + ); + } + }, + onLongTap: () { + notifier.toggleMediaSelection(media); + }, + isSelected: selectedMedias.contains(media), + status: uploadingMedias + .firstWhereOrNull( + (element) => element.mediaId == media.id) + ?.status, + media: media, + ); + }, + ), + ], + ); + } + }, + ), ); } Widget _titleWidget({required BuildContext context}) { return Row( children: [ - const SizedBox(width: 16), + if(Platform.isIOS) + const SizedBox(width: 10), Image.asset( Assets.images.appIcon, width: 28, diff --git a/app/lib/ui/flow/home/home_screen_view_model.dart b/app/lib/ui/flow/home/home_screen_view_model.dart index 2fe72c7..85d0222 100644 --- a/app/lib/ui/flow/home/home_screen_view_model.dart +++ b/app/lib/ui/flow/home/home_screen_view_model.dart @@ -1,11 +1,12 @@ import 'dart:async'; -import 'package:cloud_gallery/domain/extensions/date_extensions.dart'; +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'; import 'package:data/services/google_drive_service.dart'; import 'package:data/services/local_media_service.dart'; -import 'package:data/storage/app_preferences.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:google_sign_in/google_sign_in.dart'; @@ -16,20 +17,11 @@ part 'home_screen_view_model.freezed.dart'; final homeViewStateNotifier = StateNotifierProvider.autoDispose( (ref) { - final homeViewStateNotifier = HomeViewStateNotifier( + return HomeViewStateNotifier( ref.read(localMediaServiceProvider), ref.read(googleDriveServiceProvider), ref.read(authServiceProvider), - ref.read(AppPreferences.canTakeAutoBackUpInGoogleDrive), ); - final subscription = ref.listen(AppPreferences.canTakeAutoBackUpInGoogleDrive, - (previous, next) { - homeViewStateNotifier.updateAutoBackUpStatus(next); - }); - ref.onDispose(() { - subscription.close(); - }); - return homeViewStateNotifier; }); class HomeViewStateNotifier extends StateNotifier { @@ -37,98 +29,39 @@ class HomeViewStateNotifier extends StateNotifier { final AuthService _authService; final LocalMediaService _localMediaService; StreamSubscription? _googleAccountSubscription; - bool _isAutoBackUpEnabled = false; - bool _isAutoBackUpWorking = false; + List _uploadedMedia = []; String? _backUpFolderId; - List _localMedias = []; - int? _localMediaCount; - bool _loading = false; - List _googleDriveMedias = []; + bool _isGoogleDriveLoading = false; + bool _isLocalMediaLoading = false; + bool _isMaxLocalMediaLoaded = false; - HomeViewStateNotifier(this._localMediaService, this._googleDriveService, - this._authService, this._isAutoBackUpEnabled) + HomeViewStateNotifier( + this._localMediaService, this._googleDriveService, this._authService) : super(const HomeViewState()) { _googleAccountSubscription = - _authService.onGoogleAccountChange.listen((event) { + _authService.onGoogleAccountChange.listen((event) async { state = state.copyWith(googleAccount: event); - loadMedias(); - }); - if (_isAutoBackUpEnabled) { - _autoBackUpMedias(); - } - loadMedias(); - } - - @override - Future dispose() async { - await _googleAccountSubscription?.cancel(); - super.dispose(); - } - - Future _autoBackUpMedias() async { - _backUpFolderId ??= await _googleDriveService.getBackupFolderId(); - _isAutoBackUpWorking = true; - for (final media in state.medias.values.expand((element) => element)) { - if (!_isAutoBackUpEnabled) { - _isAutoBackUpWorking = false; - return; - } - if (!media.sources.contains(AppMediaSource.googleDrive)) { - state = state.copyWith( - uploadingMedias: state.uploadingMedias.toList()..add(media.id)); - await _googleDriveService.uploadInGoogleDrive( - media: media, - folderID: _backUpFolderId!, - ); + await loadGoogleDriveMedia(); + if (event == null) { + _uploadedMedia.clear(); state = state.copyWith( - uploadingMedias: state.uploadingMedias.toList()..remove(media.id), - medias: state.medias.map((key, value) { - value.updateElement( - newElement: media.copyWith( - sources: media.sources.toList() - ..add(AppMediaSource.googleDrive)), - oldElement: media); - return MapEntry(key, value); - }), + medias: _sortMedias( + medias: _removeGoogleDriveRefFromMedias(state.medias)), ); } - } - _isAutoBackUpWorking = false; - } - - Future updateAutoBackUpStatus(bool status) async { - _isAutoBackUpEnabled = status; - if (_isAutoBackUpEnabled && !_isAutoBackUpWorking) { - _autoBackUpMedias(); - } + }); + _loadInitialMedia(); } - Future loadMedias() async { - if (_loading == true) return; - _loading = true; - _googleDriveMedias = []; - _localMedias = []; - state = state.copyWith(loading: state.medias.isEmpty, error: null); - try { - _localMediaCount ??= await _getLocalMediaCount(); - if (_localMediaCount != null) { - await Future.wait([ - _getGoogleDriveMedias(), - _getLocalMedias(), - ]); - } else { - await _getGoogleDriveMedias(); - } - state = state.copyWith( - loading: false, - medias: _sortMedias(medias: _getAllMedias()), - hasLocalMediaAccess: _localMediaCount != null, - ); - } catch (error) { - state = state.copyWith(loading: false, error: error); - } finally { - _loading = false; + void _loadInitialMedia() async { + state = state.copyWith(loading: true, error: null); + final hasAccess = await _localMediaService.requestPermission(); + state = state.copyWith(hasLocalMediaAccess: hasAccess, loading: false); + if (hasAccess) { + await Future.wait([loadLocalMedia(), loadGoogleDriveMedia()]); + } else { + await loadGoogleDriveMedia(); } } @@ -141,58 +74,96 @@ class HomeViewStateNotifier extends StateNotifier { } } - List _getAllMedias() { - final commonMedias = []; - - for (AppMedia localMedia in _localMedias.toList()) { - _googleDriveMedias - .toList() - .where((element) => element.path == localMedia.path) - .forEach((googleDriveMedia) { - _googleDriveMedias - .removeWhere((media) => media.id == googleDriveMedia.id); - _localMedias.removeWhere((media) => media.id == localMedia.id); - commonMedias.add(localMedia.copyWith( - sources: [AppMediaSource.local, AppMediaSource.googleDrive], - thumbnailPath: googleDriveMedia.thumbnailPath)); - }); + Future loadLocalMedia({bool append = false}) async { + if (_isLocalMediaLoading || (_isMaxLocalMediaLoaded && append)) return; + if (_isMaxLocalMediaLoaded && !append) { + _isMaxLocalMediaLoaded = false; } + _isLocalMediaLoading = true; + try { + state = state.copyWith(loading: state.medias.isEmpty, error: null); - return [..._localMedias, ..._googleDriveMedias, ...commonMedias]; - } + final loadedLocalMediaCount = state.medias + .valuesWhere((e) => e.sources.contains(AppMediaSource.local)) + .length; - 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, - ); - } + final localMedia = await _localMediaService.getLocalMedia( + start: append ? loadedLocalMediaCount : 0, + end: append + ? loadedLocalMediaCount + 30 + : loadedLocalMediaCount < 30 + ? 30 + : loadedLocalMediaCount, + ); - Future _getGoogleDriveMedias() async { - if (_authService.signedInWithGoogle) { - _backUpFolderId ??= await _googleDriveService.getBackupFolderId(); - _googleDriveMedias = await _googleDriveService.getDriveMedias( - backUpFolderId: _backUpFolderId!); - } - } + if (localMedia.length < 30) { + _isMaxLocalMediaLoaded = true; + } - Future _getLocalMediaCount() async { - final hasAccess = await _localMediaService.requestPermission(); - if (hasAccess) { - return await _localMediaService.getMediaCount(); + final mergedMedia = _mergeCommonMedia( + localMedias: localMedia, + googleDriveMedias: _uploadedMedia, + ); + + state = state.copyWith( + medias: _sortMedias( + medias: append + ? [ + ...state.medias.values.expand((element) => element).toList(), + ...mergedMedia + ] + : mergedMedia, + ), + loading: false, + lastLocalMediaId: mergedMedia.length > 10 + ? mergedMedia.elementAt(mergedMedia.length - 10).id + : state.lastLocalMediaId, + ); + } catch (e) { + state = state.copyWith(loading: false, error: e); + } finally { + _isLocalMediaLoading = false; } - return null; } - Future _getLocalMedias() async { - if (_localMediaCount != null) { - _localMedias = await _localMediaService.getLocalMedia( - start: 0, - end: _localMediaCount!, + Future loadGoogleDriveMedia() async { + if (state.googleAccount == null || _isGoogleDriveLoading) return; + _isGoogleDriveLoading = true; + try { + _backUpFolderId ??= await _googleDriveService.getBackupFolderId(); + + state = state.copyWith(loading: state.medias.isEmpty, error: null); + final driveMedias = await _googleDriveService.getDriveMedias( + backUpFolderId: _backUpFolderId!, + ); + + // Separate media by its local existence + List googleDriveMedia = []; + List uploadedMedia = []; + for (var media in driveMedias) { + if (await media.isExist) { + uploadedMedia.add(media); + } else { + googleDriveMedia.add(media); + } + } + _uploadedMedia = uploadedMedia; + + //override google drive media if exist. + state = state.copyWith( + medias: _sortMedias(medias: [ + ..._mergeCommonMedia( + localMedias: _removeGoogleDriveRefFromMedias(state.medias), + googleDriveMedias: uploadedMedia, + ), + ...googleDriveMedia + ]), + loading: false, ); + } catch (e) { + state = state.copyWith(loading: false, error: e); + } finally { + _isGoogleDriveLoading = false; } } @@ -206,20 +177,34 @@ class HomeViewStateNotifier extends StateNotifier { try { if (!_authService.signedInWithGoogle) { await _authService.signInWithGoogle(); - loadMedias(); + await loadGoogleDriveMedia(); } + List uploadingMedias = state.selectedMedias .where((element) => !element.sources.contains(AppMediaSource.googleDrive)) .toList(); state = state.copyWith( - uploadingMedias: uploadingMedias.map((e) => e.id).toList(), + 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!, @@ -234,15 +219,87 @@ class HomeViewStateNotifier extends StateNotifier { oldElement: media); return MapEntry(key, value); }), - uploadingMedias: state.uploadingMedias.toList()..remove(media.id), + uploadingMedias: state.uploadingMedias.toList() + ..removeWhere((element) => element.mediaId == media.id), ); } state = state.copyWith(uploadingMedias: [], selectedMedias: []); } catch (error) { + if (error is BackUpFolderNotFound) { + _backUpFolderId = await _googleDriveService.getBackupFolderId(); + uploadMediaOnGoogleDrive(); + return; + } state = state.copyWith(error: error, uploadingMedias: []); } } + + //Helper functions + List _mergeCommonMedia({ + required List localMedias, + required List googleDriveMedias, + }) { + // If one of the lists is empty, return the other list. + if (googleDriveMedias.isEmpty) return localMedias; + if (localMedias.isEmpty) return []; + + // Convert the lists to mutable lists. + localMedias = localMedias.toList(); + googleDriveMedias = googleDriveMedias.toList(); + + final mergedMedias = []; + + // Add common media to mergedMedias and remove them from the lists. + for (AppMedia localMedia in localMedias.toList()) { + googleDriveMedias + .toList() + .where((googleDriveMedia) => googleDriveMedia.path == localMedia.path) + .forEach((googleDriveMedia) { + localMedias.removeWhere((media) => media.id == localMedia.id); + + mergedMedias.add(localMedia.copyWith( + sources: [AppMediaSource.local, AppMediaSource.googleDrive], + thumbnailLink: googleDriveMedia.thumbnailLink, + )); + }); + } + + return [...mergedMedias, ...localMedias]; + } + + Map> _sortMedias({required List medias}) { + medias.sort((a, b) => (b.createdTime ?? DateTime.now()) + .compareTo(a.createdTime ?? DateTime.now())); + return groupBy( + medias, + (AppMedia media) => + media.createdTime?.dateOnly ?? DateTime.now().dateOnly, + ); + } + + List _removeGoogleDriveRefFromMedias( + Map> medias) { + final allMedias = medias.values.expand((element) => element).toList(); + for (int index = 0; index < allMedias.length; index++) { + if (allMedias[index].sources.length > 1) { + allMedias[index] = allMedias[index].copyWith( + sources: allMedias[index].sources.toList() + ..remove(AppMediaSource.googleDrive), + thumbnailLink: null, + ); + } else if (allMedias.contains(AppMediaSource.googleDrive)) { + allMedias.removeAt(index); + } + } + return allMedias; + } + + @override + Future dispose() async { + await _googleAccountSubscription?.cancel(); + super.dispose(); + } } @freezed @@ -252,8 +309,9 @@ class HomeViewState with _$HomeViewState { @Default(false) bool hasLocalMediaAccess, @Default(false) bool loading, GoogleSignInAccount? googleAccount, + String? lastLocalMediaId, @Default({}) Map> medias, @Default([]) List selectedMedias, - @Default([]) List uploadingMedias, + @Default([]) List uploadingMedias, }) = _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 7f07220..74356fb 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,10 +20,12 @@ mixin _$HomeViewState { bool get hasLocalMediaAccess => throw _privateConstructorUsedError; bool get loading => throw _privateConstructorUsedError; GoogleSignInAccount? get googleAccount => throw _privateConstructorUsedError; + String? get lastLocalMediaId => throw _privateConstructorUsedError; Map> get medias => throw _privateConstructorUsedError; List get selectedMedias => throw _privateConstructorUsedError; - List get uploadingMedias => throw _privateConstructorUsedError; + List get uploadingMedias => + throw _privateConstructorUsedError; @JsonKey(ignore: true) $HomeViewStateCopyWith get copyWith => @@ -41,9 +43,10 @@ abstract class $HomeViewStateCopyWith<$Res> { bool hasLocalMediaAccess, bool loading, GoogleSignInAccount? googleAccount, + String? lastLocalMediaId, Map> medias, List selectedMedias, - List uploadingMedias}); + List uploadingMedias}); } /// @nodoc @@ -63,6 +66,7 @@ class _$HomeViewStateCopyWithImpl<$Res, $Val extends HomeViewState> Object? hasLocalMediaAccess = null, Object? loading = null, Object? googleAccount = freezed, + Object? lastLocalMediaId = freezed, Object? medias = null, Object? selectedMedias = null, Object? uploadingMedias = null, @@ -81,6 +85,10 @@ class _$HomeViewStateCopyWithImpl<$Res, $Val extends HomeViewState> ? _value.googleAccount : googleAccount // ignore: cast_nullable_to_non_nullable as GoogleSignInAccount?, + lastLocalMediaId: freezed == lastLocalMediaId + ? _value.lastLocalMediaId + : lastLocalMediaId // ignore: cast_nullable_to_non_nullable + as String?, medias: null == medias ? _value.medias : medias // ignore: cast_nullable_to_non_nullable @@ -92,7 +100,7 @@ class _$HomeViewStateCopyWithImpl<$Res, $Val extends HomeViewState> uploadingMedias: null == uploadingMedias ? _value.uploadingMedias : uploadingMedias // ignore: cast_nullable_to_non_nullable - as List, + as List, ) as $Val); } } @@ -110,9 +118,10 @@ abstract class _$$HomeViewStateImplCopyWith<$Res> bool hasLocalMediaAccess, bool loading, GoogleSignInAccount? googleAccount, + String? lastLocalMediaId, Map> medias, List selectedMedias, - List uploadingMedias}); + List uploadingMedias}); } /// @nodoc @@ -130,6 +139,7 @@ class __$$HomeViewStateImplCopyWithImpl<$Res> Object? hasLocalMediaAccess = null, Object? loading = null, Object? googleAccount = freezed, + Object? lastLocalMediaId = freezed, Object? medias = null, Object? selectedMedias = null, Object? uploadingMedias = null, @@ -148,6 +158,10 @@ class __$$HomeViewStateImplCopyWithImpl<$Res> ? _value.googleAccount : googleAccount // ignore: cast_nullable_to_non_nullable as GoogleSignInAccount?, + lastLocalMediaId: freezed == lastLocalMediaId + ? _value.lastLocalMediaId + : lastLocalMediaId // ignore: cast_nullable_to_non_nullable + as String?, medias: null == medias ? _value._medias : medias // ignore: cast_nullable_to_non_nullable @@ -159,7 +173,7 @@ class __$$HomeViewStateImplCopyWithImpl<$Res> uploadingMedias: null == uploadingMedias ? _value._uploadingMedias : uploadingMedias // ignore: cast_nullable_to_non_nullable - as List, + as List, )); } } @@ -172,9 +186,10 @@ class _$HomeViewStateImpl implements _HomeViewState { this.hasLocalMediaAccess = false, this.loading = false, this.googleAccount, + this.lastLocalMediaId, final Map> medias = const {}, final List selectedMedias = const [], - final List uploadingMedias = const []}) + final List uploadingMedias = const []}) : _medias = medias, _selectedMedias = selectedMedias, _uploadingMedias = uploadingMedias; @@ -189,6 +204,8 @@ class _$HomeViewStateImpl implements _HomeViewState { final bool loading; @override final GoogleSignInAccount? googleAccount; + @override + final String? lastLocalMediaId; final Map> _medias; @override @JsonKey() @@ -207,10 +224,10 @@ class _$HomeViewStateImpl implements _HomeViewState { return EqualUnmodifiableListView(_selectedMedias); } - final List _uploadingMedias; + final List _uploadingMedias; @override @JsonKey() - List get uploadingMedias { + List get uploadingMedias { if (_uploadingMedias is EqualUnmodifiableListView) return _uploadingMedias; // ignore: implicit_dynamic_type return EqualUnmodifiableListView(_uploadingMedias); @@ -218,7 +235,7 @@ class _$HomeViewStateImpl implements _HomeViewState { @override String toString() { - return 'HomeViewState(error: $error, hasLocalMediaAccess: $hasLocalMediaAccess, loading: $loading, googleAccount: $googleAccount, medias: $medias, selectedMedias: $selectedMedias, uploadingMedias: $uploadingMedias)'; + return 'HomeViewState(error: $error, hasLocalMediaAccess: $hasLocalMediaAccess, loading: $loading, googleAccount: $googleAccount, lastLocalMediaId: $lastLocalMediaId, medias: $medias, selectedMedias: $selectedMedias, uploadingMedias: $uploadingMedias)'; } @override @@ -232,6 +249,8 @@ class _$HomeViewStateImpl implements _HomeViewState { (identical(other.loading, loading) || other.loading == loading) && (identical(other.googleAccount, googleAccount) || other.googleAccount == googleAccount) && + (identical(other.lastLocalMediaId, lastLocalMediaId) || + other.lastLocalMediaId == lastLocalMediaId) && const DeepCollectionEquality().equals(other._medias, _medias) && const DeepCollectionEquality() .equals(other._selectedMedias, _selectedMedias) && @@ -246,6 +265,7 @@ class _$HomeViewStateImpl implements _HomeViewState { hasLocalMediaAccess, loading, googleAccount, + lastLocalMediaId, const DeepCollectionEquality().hash(_medias), const DeepCollectionEquality().hash(_selectedMedias), const DeepCollectionEquality().hash(_uploadingMedias)); @@ -263,9 +283,10 @@ abstract class _HomeViewState implements HomeViewState { final bool hasLocalMediaAccess, final bool loading, final GoogleSignInAccount? googleAccount, + final String? lastLocalMediaId, final Map> medias, final List selectedMedias, - final List uploadingMedias}) = _$HomeViewStateImpl; + final List uploadingMedias}) = _$HomeViewStateImpl; @override Object? get error; @@ -276,11 +297,13 @@ abstract class _HomeViewState implements HomeViewState { @override GoogleSignInAccount? get googleAccount; @override + String? get lastLocalMediaId; + @override Map> get medias; @override List get selectedMedias; @override - List get uploadingMedias; + List get uploadingMedias; @override @JsonKey(ignore: true) _$$HomeViewStateImplCopyWith<_$HomeViewStateImpl> get copyWith => 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/image_preview/components/network_image_preview/network_image_preview.dart new file mode 100644 index 0000000..c949a09 --- /dev/null +++ b/app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview.dart @@ -0,0 +1,30 @@ +import 'dart:typed_data'; +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 'network_image_preview_view_model.dart'; + +class NetworkImagePreview extends ConsumerWidget { + final AppMedia media; + + const NetworkImagePreview({super.key, required this.media}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(networkImagePreviewStateNotifierProvider); + + if (state.loading) { + return const Center(child: AppCircularProgressIndicator()); + } else if (state.mediaBytes != null) { + return Hero( + tag: media, + child: Image.memory(Uint8List.fromList(state.mediaBytes!), + fit: BoxFit.fitWidth), + ); + } else if (state.error != null) { + return const Center(child: Text('Error')); + } + return const Placeholder(); + } +} diff --git a/app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview_view_model.dart b/app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview_view_model.dart new file mode 100644 index 0000000..571b17f --- /dev/null +++ b/app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview_view_model.dart @@ -0,0 +1,44 @@ +import 'package:data/models/media_content/media_content.dart'; +import 'package:data/services/google_drive_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'network_image_preview_view_model.freezed.dart'; + +final networkImagePreviewStateNotifierProvider = StateNotifierProvider< + NetworkImagePreviewStateNotifier, NetworkImagePreviewState>((ref) { + return NetworkImagePreviewStateNotifier(ref.read(googleDriveServiceProvider)); +}); + +class NetworkImagePreviewStateNotifier + extends StateNotifier { + final GoogleDriveService _googleDriveServices; + + NetworkImagePreviewStateNotifier(this._googleDriveServices) + : super(const NetworkImagePreviewState()); + + Future loadImage(String mediaId) async { + try { + 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); + } catch (error) { + state = state.copyWith(error: error, loading: false); + } + } +} + +@freezed +class NetworkImagePreviewState with _$NetworkImagePreviewState { + const factory NetworkImagePreviewState({ + @Default(false) bool loading, + AppMediaContent? mediaContent, + List? mediaBytes, + Object? error, + }) = _NetworkImagePreviewState; +} diff --git a/app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview_view_model.freezed.dart b/app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview_view_model.freezed.dart new file mode 100644 index 0000000..b953966 --- /dev/null +++ b/app/lib/ui/flow/media_preview/image_preview/components/network_image_preview/network_image_preview_view_model.freezed.dart @@ -0,0 +1,229 @@ +// 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 'network_image_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 _$NetworkImagePreviewState { + bool get loading => throw _privateConstructorUsedError; + AppMediaContent? get mediaContent => throw _privateConstructorUsedError; + List? get mediaBytes => throw _privateConstructorUsedError; + Object? get error => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $NetworkImagePreviewStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $NetworkImagePreviewStateCopyWith<$Res> { + factory $NetworkImagePreviewStateCopyWith(NetworkImagePreviewState value, + $Res Function(NetworkImagePreviewState) then) = + _$NetworkImagePreviewStateCopyWithImpl<$Res, NetworkImagePreviewState>; + @useResult + $Res call( + {bool loading, + AppMediaContent? mediaContent, + List? mediaBytes, + Object? error}); + + $AppMediaContentCopyWith<$Res>? get mediaContent; +} + +/// @nodoc +class _$NetworkImagePreviewStateCopyWithImpl<$Res, + $Val extends NetworkImagePreviewState> + implements $NetworkImagePreviewStateCopyWith<$Res> { + _$NetworkImagePreviewStateCopyWithImpl(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? loading = null, + Object? mediaContent = freezed, + Object? mediaBytes = freezed, + Object? error = freezed, + }) { + return _then(_value.copyWith( + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + mediaContent: freezed == mediaContent + ? _value.mediaContent + : mediaContent // ignore: cast_nullable_to_non_nullable + as AppMediaContent?, + mediaBytes: freezed == mediaBytes + ? _value.mediaBytes + : mediaBytes // ignore: cast_nullable_to_non_nullable + as List?, + error: freezed == error ? _value.error : error, + ) as $Val); + } + + @override + @pragma('vm:prefer-inline') + $AppMediaContentCopyWith<$Res>? get mediaContent { + if (_value.mediaContent == null) { + return null; + } + + return $AppMediaContentCopyWith<$Res>(_value.mediaContent!, (value) { + return _then(_value.copyWith(mediaContent: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$NetworkImagePreviewStateImplCopyWith<$Res> + implements $NetworkImagePreviewStateCopyWith<$Res> { + factory _$$NetworkImagePreviewStateImplCopyWith( + _$NetworkImagePreviewStateImpl value, + $Res Function(_$NetworkImagePreviewStateImpl) then) = + __$$NetworkImagePreviewStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {bool loading, + AppMediaContent? mediaContent, + List? mediaBytes, + Object? error}); + + @override + $AppMediaContentCopyWith<$Res>? get mediaContent; +} + +/// @nodoc +class __$$NetworkImagePreviewStateImplCopyWithImpl<$Res> + extends _$NetworkImagePreviewStateCopyWithImpl<$Res, + _$NetworkImagePreviewStateImpl> + implements _$$NetworkImagePreviewStateImplCopyWith<$Res> { + __$$NetworkImagePreviewStateImplCopyWithImpl( + _$NetworkImagePreviewStateImpl _value, + $Res Function(_$NetworkImagePreviewStateImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? loading = null, + Object? mediaContent = freezed, + Object? mediaBytes = freezed, + Object? error = freezed, + }) { + return _then(_$NetworkImagePreviewStateImpl( + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + mediaContent: freezed == mediaContent + ? _value.mediaContent + : mediaContent // ignore: cast_nullable_to_non_nullable + as AppMediaContent?, + mediaBytes: freezed == mediaBytes + ? _value._mediaBytes + : mediaBytes // ignore: cast_nullable_to_non_nullable + as List?, + error: freezed == error ? _value.error : error, + )); + } +} + +/// @nodoc + +class _$NetworkImagePreviewStateImpl implements _NetworkImagePreviewState { + const _$NetworkImagePreviewStateImpl( + {this.loading = false, + this.mediaContent, + final List? mediaBytes, + this.error}) + : _mediaBytes = mediaBytes; + + @override + @JsonKey() + final bool loading; + @override + final AppMediaContent? mediaContent; + final List? _mediaBytes; + @override + List? get mediaBytes { + final value = _mediaBytes; + if (value == null) return null; + if (_mediaBytes is EqualUnmodifiableListView) return _mediaBytes; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); + } + + @override + final Object? error; + + @override + String toString() { + return 'NetworkImagePreviewState(loading: $loading, mediaContent: $mediaContent, mediaBytes: $mediaBytes, error: $error)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$NetworkImagePreviewStateImpl && + (identical(other.loading, loading) || other.loading == loading) && + (identical(other.mediaContent, mediaContent) || + other.mediaContent == mediaContent) && + const DeepCollectionEquality() + .equals(other._mediaBytes, _mediaBytes) && + const DeepCollectionEquality().equals(other.error, error)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + loading, + mediaContent, + const DeepCollectionEquality().hash(_mediaBytes), + const DeepCollectionEquality().hash(error)); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$NetworkImagePreviewStateImplCopyWith<_$NetworkImagePreviewStateImpl> + get copyWith => __$$NetworkImagePreviewStateImplCopyWithImpl< + _$NetworkImagePreviewStateImpl>(this, _$identity); +} + +abstract class _NetworkImagePreviewState implements NetworkImagePreviewState { + const factory _NetworkImagePreviewState( + {final bool loading, + final AppMediaContent? mediaContent, + final List? mediaBytes, + final Object? error}) = _$NetworkImagePreviewStateImpl; + + @override + bool get loading; + @override + AppMediaContent? get mediaContent; + @override + List? get mediaBytes; + @override + Object? get error; + @override + @JsonKey(ignore: true) + _$$NetworkImagePreviewStateImplCopyWith<_$NetworkImagePreviewStateImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/app/lib/ui/flow/media_preview/image_preview/image_preview_screen.dart b/app/lib/ui/flow/media_preview/image_preview/image_preview_screen.dart new file mode 100644 index 0000000..b075768 --- /dev/null +++ b/app/lib/ui/flow/media_preview/image_preview/image_preview_screen.dart @@ -0,0 +1,128 @@ +import 'dart:io'; +import 'dart:math'; +import 'package:data/models/media/media.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:style/extensions/context_extensions.dart'; +import '../../../../components/app_page.dart'; +import '../../../../domain/extensions/widget_extensions.dart'; +import 'components/network_image_preview/network_image_preview.dart'; +import 'components/network_image_preview/network_image_preview_view_model.dart'; + +class ImagePreviewScreen extends ConsumerStatefulWidget { + final AppMedia media; + + const ImagePreviewScreen({ + super.key, + required this.media, + }); + + @override + ConsumerState createState() => _ImagePreviewScreenState(); +} + +class _ImagePreviewScreenState extends ConsumerState { + final _transformationController = TransformationController(); + double _translateY = 0; + double _scale = 1; + + late NetworkImagePreviewStateNotifier notifier; + + @override + void initState() { + if (!widget.media.sources.contains(AppMediaSource.local)) { + notifier = ref.read(networkImagePreviewStateNotifierProvider.notifier); + runPostFrame(() { + notifier.loadImage(widget.media.id); + }); + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onVerticalDragStart: (details) { + _translateY = 0; + _scale = 1; + }, + onVerticalDragUpdate: (details) { + if (_transformationController.value.getMaxScaleOnAxis() > 1) { + return; + } + + setState(() { + _translateY = max(0, _translateY + (details.primaryDelta ?? 0)); + _scale = 1 - (_translateY / 1000); + }); + }, + onVerticalDragEnd: (details) { + if (_transformationController.value.getMaxScaleOnAxis() > 1 || + _translateY == 0) { + return; + } + + final velocity = details.primaryVelocity ?? 0; + if (velocity > 1000) { + Navigator.of(context).pop(); + } else if (_translateY > 100) { + Navigator.of(context).pop(); + } else { + setState(() { + _translateY = 0; + _scale = 1; + }); + } + }, + child: AppPage( + backgroundColor: _scale == 1 + ? context.colorScheme.surface + : context.colorScheme.surface.withOpacity(_scale / 2), + body: Stack( + children: [ + Transform.translate( + offset: Offset(0, _translateY), + child: Transform.scale( + scale: _scale, + child: InteractiveViewer( + transformationController: _transformationController, + maxScale: 100, + child: Center( + child: SizedBox( + width: double.infinity, + child: widget.media.sources.contains(AppMediaSource.local) + ? _displayLocalImage(context: context) + : NetworkImagePreview( + media: widget.media, + ), + ), + ), + ), + ), + ), + if (_scale == 1) + Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + AdaptiveAppBar( + iosTransitionBetweenRoutes: false, + text: widget.media.name ?? '', + ), + ], + ), + ], + ), + ), + ); + } + + Widget _displayLocalImage({required BuildContext context}) { + return Hero( + tag: widget.media, + child: Image.file( + File(widget.media.path), + fit: BoxFit.contain, + ), + ); + } +} diff --git a/app/lib/ui/flow/media_preview/media_preview.dart b/app/lib/ui/flow/media_preview/media_preview.dart new file mode 100644 index 0000000..2513eb1 --- /dev/null +++ b/app/lib/ui/flow/media_preview/media_preview.dart @@ -0,0 +1,22 @@ +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/video_preview_screen.dart b/app/lib/ui/flow/media_preview/video_preview_screen.dart new file mode 100644 index 0000000..fa98229 --- /dev/null +++ b/app/lib/ui/flow/media_preview/video_preview_screen.dart @@ -0,0 +1,21 @@ +import 'package:cloud_gallery/components/app_page.dart'; +import 'package:flutter/cupertino.dart'; + +class VideoPreviewScreen extends StatefulWidget { + const VideoPreviewScreen({super.key}); + + @override + State createState() => _VideoPreviewScreenState(); +} + +class _VideoPreviewScreenState extends State { + @override + Widget build(BuildContext context) { + return const AppPage( + title: '', + body: Center( + child: Text('Video Preview Screen'), + ), + ); + } +} diff --git a/app/lib/ui/navigation/app_router.dart b/app/lib/ui/navigation/app_router.dart index e4b55f6..dcc76b7 100644 --- a/app/lib/ui/navigation/app_router.dart +++ b/app/lib/ui/navigation/app_router.dart @@ -1,5 +1,9 @@ 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 'app_route.dart'; @@ -20,10 +24,38 @@ 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}) => + AppRoute( + AppRoutePath.videoPreview, + builder: (context) => const VideoPreviewScreen(), + ); + static final routes = [ home.goRoute, onBoard.goRoute, accounts.goRoute, + GoRoute( + path: AppRoutePath.imagePreview, + pageBuilder: (context, state) { + return CustomTransitionPage( + opaque: false, + key: state.pageKey, + child: state.widget(context), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition(opacity: animation, child: child); + }, + ); + }, + ), + GoRoute( + path: AppRoutePath.videoPreview, + builder: (context, state) => state.widget(context), + ), ]; } @@ -31,4 +63,6 @@ class AppRoutePath { static const home = '/'; static const onBoard = '/on-board'; static const accounts = '/accounts'; + static const imagePreview = '/image_preview'; + static const videoPreview = '/video_preview'; } diff --git a/app/pubspec.lock b/app/pubspec.lock index f256aad..6ddf9e0 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1156,10 +1156,10 @@ packages: dependency: "direct main" description: name: video_player - sha256: fbf28ce8bcfe709ad91b5789166c832cb7a684d14f571a81891858fefb5bb1c2 + sha256: afc65f4b8bcb2c188f64a591f84fb471f4f2e19fc607c65fd8d2f8fedb3dec23 url: "https://pub.dev" source: hosted - version: "2.8.2" + version: "2.8.3" video_player_android: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index dd9198a..3989ba8 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -28,7 +28,7 @@ dependencies: flutter_svg: ^2.0.9 fluttertoast: ^8.2.4 cached_network_image: ^3.3.1 - video_player: ^2.8.2 + video_player: ^2.8.3 visibility_detector: ^0.4.0+2 flutter_displaymode: ^0.6.0 diff --git a/data/.flutter-plugins-dependencies b/data/.flutter-plugins-dependencies index 24d471e..1ebfe74 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-05 17:32:12.442322","version":"3.19.1"} \ 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-26 16:38:34.855593","version":"3.19.3"} \ No newline at end of file diff --git a/data/lib/errors/app_error.dart b/data/lib/errors/app_error.dart index b7f638d..46f196e 100644 --- a/data/lib/errors/app_error.dart +++ b/data/lib/errors/app_error.dart @@ -41,6 +41,13 @@ class UserGoogleSignInAccountNotFound extends AppError { "User google signed in account not found. Please sign in again"); } +class BackUpFolderNotFound extends AppError { + const BackUpFolderNotFound() + : super( + l10nCode: AppErrorL10nCodes.backUpFolderNotFound, + message: "Back up folder not found"); +} + class SomethingWentWrongError extends AppError { const SomethingWentWrongError({String? message, String? statusCode}) : super( diff --git a/data/lib/errors/l10n_error_codes.dart b/data/lib/errors/l10n_error_codes.dart index 4b6136d..fcad6a8 100644 --- a/data/lib/errors/l10n_error_codes.dart +++ b/data/lib/errors/l10n_error_codes.dart @@ -2,4 +2,5 @@ class AppErrorL10nCodes { static const noInternetConnection = 'no-internet-connection'; static const somethingWentWrongError = 'something-went-wrong'; static const googleSignInUserNotFoundError = 'google-sing-in-user-not-found'; + static const backUpFolderNotFound = 'back-up-folder-not-found'; } diff --git a/data/lib/models/media/media.dart b/data/lib/models/media/media.dart index 865f80d..48b7175 100644 --- a/data/lib/models/media/media.dart +++ b/data/lib/models/media/media.dart @@ -1,15 +1,38 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' show Size; 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; +import 'package:photo_manager/photo_manager.dart' + show AssetEntity, ThumbnailFormat, ThumbnailSize; part 'media.freezed.dart'; part 'media.g.dart'; +enum UploadStatus { uploading, waiting, none, failed, success } + +class UploadProgress { + final String mediaId; + final UploadStatus status; + + UploadProgress({required this.mediaId, required this.status}); + + @override + bool operator ==(Object other) { + return other is UploadProgress && + other.mediaId == mediaId && + other.status == status; + } + + @override + int get hashCode => mediaId.hashCode ^ status.hashCode; +} + enum AppMediaType { + other, image, - video, - other; + video; bool get isImage => this == AppMediaType.image; @@ -77,7 +100,7 @@ class AppMedia with _$AppMedia { required String id, String? name, required String path, - String? thumbnailPath, + String? thumbnailLink, double? displayHeight, double? displayWidth, required AppMediaType type, @@ -97,8 +120,7 @@ class AppMedia with _$AppMedia { factory AppMedia.fromGoogleDriveFile(drive.File file) { final type = AppMediaType.getType( - mimeType: file.mimeType, - location: file.thumbnailLink ?? file.description ?? ''); + mimeType: file.mimeType, location: file.description ?? ''); final height = type.isImage ? file.imageMediaMetadata?.height?.toDouble() @@ -120,11 +142,10 @@ class AppMedia with _$AppMedia { milliseconds: int.parse(file.videoMediaMetadata?.durationMillis ?? '0')) : null; - return AppMedia( id: file.id!, path: file.description ?? file.thumbnailLink ?? '', - thumbnailPath: file.thumbnailLink, + thumbnailLink: file.thumbnailLink, name: file.name, createdTime: file.createdTime, modifiedTime: file.modifiedTime, @@ -135,6 +156,8 @@ class AppMedia with _$AppMedia { displayWidth: width, videoDuration: videoDuration, orientation: orientation, + latitude: file.imageMediaMetadata?.location?.latitude, + longitude: file.imageMediaMetadata?.location?.longitude, sources: [AppMediaSource.googleDrive], ); } @@ -149,6 +172,7 @@ class AppMedia with _$AppMedia { return AppMedia( id: asset.id, path: file.path, + name: asset.title, mimeType: asset.mimeType, size: length.toString(), type: type, @@ -166,3 +190,18 @@ class AppMedia with _$AppMedia { ); } } + +extension AppMediaExtension on AppMedia { + Future get isExist async { + return await File(path).exists(); + } + + Future thumbnailDataWithSize(Size size) async { + return await AssetEntity(id: id, typeInt: type.index, width: 0, height: 0) + .thumbnailDataWithSize( + ThumbnailSize(size.width.toInt(), size.height.toInt()), + format: ThumbnailFormat.jpeg, + quality: 70, + ); + } +} diff --git a/data/lib/models/media/media.freezed.dart b/data/lib/models/media/media.freezed.dart index 7c6a696..a5ee01b 100644 --- a/data/lib/models/media/media.freezed.dart +++ b/data/lib/models/media/media.freezed.dart @@ -23,7 +23,7 @@ mixin _$AppMedia { String get id => throw _privateConstructorUsedError; String? get name => throw _privateConstructorUsedError; String get path => throw _privateConstructorUsedError; - String? get thumbnailPath => throw _privateConstructorUsedError; + String? get thumbnailLink => throw _privateConstructorUsedError; double? get displayHeight => throw _privateConstructorUsedError; double? get displayWidth => throw _privateConstructorUsedError; AppMediaType get type => throw _privateConstructorUsedError; @@ -52,7 +52,7 @@ abstract class $AppMediaCopyWith<$Res> { {String id, String? name, String path, - String? thumbnailPath, + String? thumbnailLink, double? displayHeight, double? displayWidth, AppMediaType type, @@ -83,7 +83,7 @@ class _$AppMediaCopyWithImpl<$Res, $Val extends AppMedia> Object? id = null, Object? name = freezed, Object? path = null, - Object? thumbnailPath = freezed, + Object? thumbnailLink = freezed, Object? displayHeight = freezed, Object? displayWidth = freezed, Object? type = null, @@ -110,9 +110,9 @@ class _$AppMediaCopyWithImpl<$Res, $Val extends AppMedia> ? _value.path : path // ignore: cast_nullable_to_non_nullable as String, - thumbnailPath: freezed == thumbnailPath - ? _value.thumbnailPath - : thumbnailPath // ignore: cast_nullable_to_non_nullable + thumbnailLink: freezed == thumbnailLink + ? _value.thumbnailLink + : thumbnailLink // ignore: cast_nullable_to_non_nullable as String?, displayHeight: freezed == displayHeight ? _value.displayHeight @@ -178,7 +178,7 @@ abstract class _$$AppMediaImplCopyWith<$Res> {String id, String? name, String path, - String? thumbnailPath, + String? thumbnailLink, double? displayHeight, double? displayWidth, AppMediaType type, @@ -207,7 +207,7 @@ class __$$AppMediaImplCopyWithImpl<$Res> Object? id = null, Object? name = freezed, Object? path = null, - Object? thumbnailPath = freezed, + Object? thumbnailLink = freezed, Object? displayHeight = freezed, Object? displayWidth = freezed, Object? type = null, @@ -234,9 +234,9 @@ class __$$AppMediaImplCopyWithImpl<$Res> ? _value.path : path // ignore: cast_nullable_to_non_nullable as String, - thumbnailPath: freezed == thumbnailPath - ? _value.thumbnailPath - : thumbnailPath // ignore: cast_nullable_to_non_nullable + thumbnailLink: freezed == thumbnailLink + ? _value.thumbnailLink + : thumbnailLink // ignore: cast_nullable_to_non_nullable as String?, displayHeight: freezed == displayHeight ? _value.displayHeight @@ -297,7 +297,7 @@ class _$AppMediaImpl implements _AppMedia { {required this.id, this.name, required this.path, - this.thumbnailPath, + this.thumbnailLink, this.displayHeight, this.displayWidth, required this.type, @@ -322,7 +322,7 @@ class _$AppMediaImpl implements _AppMedia { @override final String path; @override - final String? thumbnailPath; + final String? thumbnailLink; @override final double? displayHeight; @override @@ -356,7 +356,7 @@ class _$AppMediaImpl implements _AppMedia { @override String toString() { - return 'AppMedia(id: $id, name: $name, path: $path, thumbnailPath: $thumbnailPath, 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, 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 @@ -367,8 +367,8 @@ class _$AppMediaImpl implements _AppMedia { (identical(other.id, id) || other.id == id) && (identical(other.name, name) || other.name == name) && (identical(other.path, path) || other.path == path) && - (identical(other.thumbnailPath, thumbnailPath) || - other.thumbnailPath == thumbnailPath) && + (identical(other.thumbnailLink, thumbnailLink) || + other.thumbnailLink == thumbnailLink) && (identical(other.displayHeight, displayHeight) || other.displayHeight == displayHeight) && (identical(other.displayWidth, displayWidth) || @@ -399,7 +399,7 @@ class _$AppMediaImpl implements _AppMedia { id, name, path, - thumbnailPath, + thumbnailLink, displayHeight, displayWidth, type, @@ -432,7 +432,7 @@ abstract class _AppMedia implements AppMedia { {required final String id, final String? name, required final String path, - final String? thumbnailPath, + final String? thumbnailLink, final double? displayHeight, final double? displayWidth, required final AppMediaType type, @@ -456,7 +456,7 @@ abstract class _AppMedia implements AppMedia { @override String get path; @override - String? get thumbnailPath; + String? get thumbnailLink; @override double? get displayHeight; @override diff --git a/data/lib/models/media/media.g.dart b/data/lib/models/media/media.g.dart index 8f0c93e..ef59dee 100644 --- a/data/lib/models/media/media.g.dart +++ b/data/lib/models/media/media.g.dart @@ -11,7 +11,7 @@ _$AppMediaImpl _$$AppMediaImplFromJson(Map json) => id: json['id'] as String, name: json['name'] as String?, path: json['path'] as String, - thumbnailPath: json['thumbnailPath'] as String?, + thumbnailLink: json['thumbnailLink'] as String?, displayHeight: (json['displayHeight'] as num?)?.toDouble(), displayWidth: (json['displayWidth'] as num?)?.toDouble(), type: $enumDecode(_$AppMediaTypeEnumMap, json['type']), @@ -41,7 +41,7 @@ Map _$$AppMediaImplToJson(_$AppMediaImpl instance) => 'id': instance.id, 'name': instance.name, 'path': instance.path, - 'thumbnailPath': instance.thumbnailPath, + 'thumbnailLink': instance.thumbnailLink, 'displayHeight': instance.displayHeight, 'displayWidth': instance.displayWidth, 'type': _$AppMediaTypeEnumMap[instance.type]!, @@ -58,9 +58,9 @@ Map _$$AppMediaImplToJson(_$AppMediaImpl instance) => }; const _$AppMediaTypeEnumMap = { + AppMediaType.other: 'other', AppMediaType.image: 'image', AppMediaType.video: 'video', - AppMediaType.other: 'other', }; const _$AppMediaOrientationEnumMap = { diff --git a/data/lib/models/media_content/media_content.dart b/data/lib/models/media_content/media_content.dart new file mode 100644 index 0000000..882786d --- /dev/null +++ b/data/lib/models/media_content/media_content.dart @@ -0,0 +1,18 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:googleapis/drive/v3.dart' as drive show Media; + +part 'media_content.freezed.dart'; + +@freezed +class AppMediaContent with _$AppMediaContent { + const factory AppMediaContent( + {required Stream> stream, + required int? length, + required String contentType}) = _AppMediaContent; + + factory AppMediaContent.fromGoogleDrive(drive.Media media) => AppMediaContent( + stream: media.stream, + length: media.length, + contentType: media.contentType, + ); +} diff --git a/data/lib/models/media_content/media_content.freezed.dart b/data/lib/models/media_content/media_content.freezed.dart new file mode 100644 index 0000000..7bd4880 --- /dev/null +++ b/data/lib/models/media_content/media_content.freezed.dart @@ -0,0 +1,170 @@ +// 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_content.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 _$AppMediaContent { + Stream> get stream => throw _privateConstructorUsedError; + int? get length => throw _privateConstructorUsedError; + String get contentType => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $AppMediaContentCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AppMediaContentCopyWith<$Res> { + factory $AppMediaContentCopyWith( + AppMediaContent value, $Res Function(AppMediaContent) then) = + _$AppMediaContentCopyWithImpl<$Res, AppMediaContent>; + @useResult + $Res call({Stream> stream, int? length, String contentType}); +} + +/// @nodoc +class _$AppMediaContentCopyWithImpl<$Res, $Val extends AppMediaContent> + implements $AppMediaContentCopyWith<$Res> { + _$AppMediaContentCopyWithImpl(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? stream = null, + Object? length = freezed, + Object? contentType = null, + }) { + return _then(_value.copyWith( + stream: null == stream + ? _value.stream + : stream // ignore: cast_nullable_to_non_nullable + as Stream>, + length: freezed == length + ? _value.length + : length // ignore: cast_nullable_to_non_nullable + as int?, + contentType: null == contentType + ? _value.contentType + : contentType // ignore: cast_nullable_to_non_nullable + as String, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$AppMediaContentImplCopyWith<$Res> + implements $AppMediaContentCopyWith<$Res> { + factory _$$AppMediaContentImplCopyWith(_$AppMediaContentImpl value, + $Res Function(_$AppMediaContentImpl) then) = + __$$AppMediaContentImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({Stream> stream, int? length, String contentType}); +} + +/// @nodoc +class __$$AppMediaContentImplCopyWithImpl<$Res> + extends _$AppMediaContentCopyWithImpl<$Res, _$AppMediaContentImpl> + implements _$$AppMediaContentImplCopyWith<$Res> { + __$$AppMediaContentImplCopyWithImpl( + _$AppMediaContentImpl _value, $Res Function(_$AppMediaContentImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? stream = null, + Object? length = freezed, + Object? contentType = null, + }) { + return _then(_$AppMediaContentImpl( + stream: null == stream + ? _value.stream + : stream // ignore: cast_nullable_to_non_nullable + as Stream>, + length: freezed == length + ? _value.length + : length // ignore: cast_nullable_to_non_nullable + as int?, + contentType: null == contentType + ? _value.contentType + : contentType // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$AppMediaContentImpl implements _AppMediaContent { + const _$AppMediaContentImpl( + {required this.stream, required this.length, required this.contentType}); + + @override + final Stream> stream; + @override + final int? length; + @override + final String contentType; + + @override + String toString() { + return 'AppMediaContent(stream: $stream, length: $length, contentType: $contentType)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AppMediaContentImpl && + (identical(other.stream, stream) || other.stream == stream) && + (identical(other.length, length) || other.length == length) && + (identical(other.contentType, contentType) || + other.contentType == contentType)); + } + + @override + int get hashCode => Object.hash(runtimeType, stream, length, contentType); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$AppMediaContentImplCopyWith<_$AppMediaContentImpl> get copyWith => + __$$AppMediaContentImplCopyWithImpl<_$AppMediaContentImpl>( + this, _$identity); +} + +abstract class _AppMediaContent implements AppMediaContent { + const factory _AppMediaContent( + {required final Stream> stream, + required final int? length, + required final String contentType}) = _$AppMediaContentImpl; + + @override + Stream> get stream; + @override + int? get length; + @override + String get contentType; + @override + @JsonKey(ignore: true) + _$$AppMediaContentImplCopyWith<_$AppMediaContentImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/data/lib/services/google_drive_service.dart b/data/lib/services/google_drive_service.dart index 98893f5..db8ebcb 100644 --- a/data/lib/services/google_drive_service.dart +++ b/data/lib/services/google_drive_service.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:data/models/media/media.dart'; +import 'package:data/models/media_content/media_content.dart'; import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_sign_in/google_sign_in.dart'; @@ -13,8 +14,6 @@ final googleDriveServiceProvider = Provider( class GoogleDriveService { final String _backUpFolderName = "Cloud Gallery Backup"; - final String _backUpFolderDescription = - "This folder is used to backup media from Cloud Gallery"; final GoogleSignIn _googleSignIn; @@ -25,7 +24,9 @@ class GoogleDriveService { throw const UserGoogleSignInAccountNotFound(); } final client = await _googleSignIn.authenticatedClient(); - return drive.DriveApi(client!); + final api = drive.DriveApi(client!); + client.close(); + return api; } Future getBackupFolderId() async { @@ -33,16 +34,14 @@ class GoogleDriveService { final driveApi = await _getGoogleDriveAPI(); final response = await driveApi.files.list( - q: "name='$_backUpFolderName' and mimeType='application/vnd.google-apps.folder'", + q: "name='$_backUpFolderName' and trashed=false and mimeType='application/vnd.google-apps.folder'", ); - if (response.files?.isNotEmpty ?? - false || response.files?.first.trashed == false) { + if (response.files?.isNotEmpty ?? false) { return response.files?.first.id; } else { final folder = drive.File( name: _backUpFolderName, - description: _backUpFolderDescription, mimeType: 'application/vnd.google-apps.folder', ); final googleDriveFolder = await driveApi.files.create(folder); @@ -61,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, webContentLink, createdTime, modifiedTime, size, imageMediaMetadata, videoMediaMetadata)", + "files(id, name, description, mimeType, thumbnailLink, createdTime, modifiedTime, size, imageMediaMetadata, videoMediaMetadata)", ); return (response.files ?? []) @@ -90,7 +89,17 @@ class GoogleDriveService { uploadMedia: drive.Media(localFile.openRead(), localFile.lengthSync()), ); } catch (error) { + if (error is drive.DetailedApiRequestError && error.status == 404) { + throw const BackUpFolderNotFound(); + } throw AppError.fromError(error); } } + + Future fetchMediaBytes(String mediaId) async { + final api = await _getGoogleDriveAPI(); + final media = await api.files.get(mediaId, + downloadOptions: drive.DownloadOptions.fullMedia) as drive.Media; + return AppMediaContent.fromGoogleDrive(media); + } } diff --git a/data/lib/services/local_media_service.dart b/data/lib/services/local_media_service.dart index 5b02374..982d3e5 100644 --- a/data/lib/services/local_media_service.dart +++ b/data/lib/services/local_media_service.dart @@ -3,6 +3,8 @@ import 'package:data/models/media/media.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:photo_manager/photo_manager.dart'; +import '../errors/app_error.dart'; + final localMediaServiceProvider = Provider( (ref) => const LocalMediaService(), ); @@ -25,18 +27,22 @@ class LocalMediaService { Future> getLocalMedia( {required int start, required int end}) async { - final assets = await PhotoManager.getAssetListRange( - start: start, - end: end, - filterOption: FilterOptionGroup( - orders: [const OrderOption(type: OrderOptionType.createDate)], - ), - ); - final files = await Future.wait( - assets.map( - (asset) => AppMedia.fromAssetEntity(asset), - ), - ); - return files.whereNotNull().toList(); + try { + final assets = await PhotoManager.getAssetListRange( + start: start, + end: end, + filterOption: FilterOptionGroup( + orders: [const OrderOption(type: OrderOptionType.createDate)], + ), + ); + final files = await Future.wait( + assets.map( + (asset) => AppMedia.fromAssetEntity(asset), + ), + ); + return files.whereNotNull().toList(); + } catch (e) { + throw AppError.fromError(e); + } } } diff --git a/style/lib/buttons/action_button.dart b/style/lib/buttons/action_button.dart new file mode 100644 index 0000000..cdef9d5 --- /dev/null +++ b/style/lib/buttons/action_button.dart @@ -0,0 +1,48 @@ +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 Color? backgroundColor; + final double size; + final EdgeInsets padding; + + const ActionButton( + {super.key, + required this.onPressed, + required this.icon, + this.size = 30, + this.backgroundColor, + this.progress = false, + this.padding = const EdgeInsets.all(0)}); + + @override + Widget build(BuildContext context) { + if (Platform.isIOS) { + return CupertinoButton( + minSize: size, + borderRadius: BorderRadius.circular(size), + color: backgroundColor ?? context.colorScheme.containerNormal, + onPressed: onPressed, + padding: padding, + child: progress ? const AppCircularProgressIndicator() : icon, + ); + } else { + return IconButton( + style: IconButton.styleFrom( + backgroundColor: + backgroundColor ?? context.colorScheme.containerNormal, + minimumSize: Size(size, size), + ), + onPressed: onPressed, + icon: progress ? const AppCircularProgressIndicator() : icon, + padding: padding, + ); + } + } +} diff --git a/style/lib/indicators/circular_progress_indicator.dart b/style/lib/indicators/circular_progress_indicator.dart index 54de2b6..756510b 100644 --- a/style/lib/indicators/circular_progress_indicator.dart +++ b/style/lib/indicators/circular_progress_indicator.dart @@ -19,6 +19,7 @@ class AppCircularProgressIndicator extends StatelessWidget { width: size, height: size, child: CircularProgressIndicator.adaptive( + strokeWidth: size / 8, value: value, valueColor: AlwaysStoppedAnimation( color ?? context.colorScheme.primary, diff --git a/style/lib/theme/app_theme_builder.dart b/style/lib/theme/app_theme_builder.dart index 6dd6ded..e4e8ea5 100644 --- a/style/lib/theme/app_theme_builder.dart +++ b/style/lib/theme/app_theme_builder.dart @@ -32,7 +32,6 @@ class AppThemeBuilder { surfaceTintColor: colorScheme.surface, foregroundColor: colorScheme.textPrimary, scrolledUnderElevation: 3, - ), ); } @@ -43,7 +42,7 @@ class AppThemeBuilder { brightness: colorScheme.brightness, primaryColor: colorScheme.primary, primaryContrastingColor: colorScheme.onPrimary, - barBackgroundColor: colorScheme.surface, + barBackgroundColor: colorScheme.barColor, scaffoldBackgroundColor: colorScheme.surface, textTheme: CupertinoTextThemeData( primaryColor: colorScheme.textPrimary, diff --git a/style/lib/theme/colors.dart b/style/lib/theme/colors.dart index 605718a..867f7f4 100644 --- a/style/lib/theme/colors.dart +++ b/style/lib/theme/colors.dart @@ -20,6 +20,9 @@ class AppColors { static const surfaceLightColor = Color(0xFFFFFFFF); static const surfaceDarkColor = Color(0xFF000000); + static const barLightColor = Color(0xCCFFFFFF); + static const barDarkColor = Color(0xCC000000); + static const textPrimaryLightColor = Color(0xDE000000); static const textSecondaryLightColor = Color(0x99000000); static const textDisabledLightColor = Color(0x66000000); diff --git a/style/lib/theme/theme.dart b/style/lib/theme/theme.dart index 81d5b62..71531a2 100644 --- a/style/lib/theme/theme.dart +++ b/style/lib/theme/theme.dart @@ -32,6 +32,7 @@ class AppColorScheme { final Color outline; final Color textPrimary; final Color textSecondary; + final Color barColor; final Color textDisabled; final Color outlineInverse; final Color textInversePrimary; @@ -65,6 +66,7 @@ class AppColorScheme { required this.textDisabled, required this.outlineInverse, required this.textInversePrimary, + required this.barColor, required this.textInverseSecondary, required this.textInverseDisabled, required this.containerNormalInverse, @@ -90,8 +92,6 @@ class AppColorScheme { Color get containerLowOnSurface => Color.alphaBlend(containerLow, surface); } - - final appColorSchemeLight = AppColorScheme( primary: AppColors.primaryColor, secondary: AppColors.secondaryLightColor, @@ -120,6 +120,7 @@ final appColorSchemeLight = AppColorScheme( onPrimary: AppColors.textPrimaryDarkColor, onSecondary: AppColors.textSecondaryDarkColor, onDisabled: AppColors.textDisabledLightColor, + barColor: AppColors.barLightColor, brightness: Brightness.light, ); @@ -151,5 +152,6 @@ final appColorSchemeDark = AppColorScheme( onPrimary: AppColors.textPrimaryDarkColor, onSecondary: AppColors.textSecondaryDarkColor, onDisabled: AppColors.textDisabledDarkColor, + barColor: AppColors.barDarkColor, brightness: Brightness.dark, );