diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml index 96671da..0df894e 100644 --- a/.idea/libraries/Dart_Packages.xml +++ b/.idea/libraries/Dart_Packages.xml @@ -310,6 +310,13 @@ + + + + + + @@ -577,6 +584,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -949,6 +998,7 @@ + @@ -988,6 +1038,12 @@ + + + + + + diff --git a/.idea/libraries/Flutter_Plugins.xml b/.idea/libraries/Flutter_Plugins.xml index 46bf14e..def2977 100644 --- a/.idea/libraries/Flutter_Plugins.xml +++ b/.idea/libraries/Flutter_Plugins.xml @@ -12,10 +12,13 @@ - - + + + + + diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index 5aeb50e..b5280ff 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -46,7 +46,7 @@ android { applicationId "com.canopas.cloud_gallery" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. - minSdkVersion flutter.minSdkVersion + minSdkVersion 21 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/app/assets/images/app_logo.png b/app/assets/images/app_logo.png index 540044f..1e006a6 100644 Binary files a/app/assets/images/app_logo.png and b/app/assets/images/app_logo.png differ diff --git a/app/assets/locales/app_en.arb b/app/assets/locales/app_en.arb index 6e944a6..be20eb7 100644 --- a/app/assets/locales/app_en.arb +++ b/app/assets/locales/app_en.arb @@ -3,10 +3,17 @@ "common_get_started":"Get Started", "common_done": "Done", + "common_local": "Local", + + "google_drive_title": "Google Drive", "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.", "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! 🚀", - "back_up_on_google_drive_text":"Back up on Google Drive" + "back_up_on_google_drive_text":"Back up on Google Drive", + + "cant_find_media_title": "Can't find your photos or videos", + "ask_for_media_permission_message": "Please give us permission to access your local media, so you can load and enjoy all your favorite photos and videos effortlessly.", + "load_local_media_button_text": "Load local media" } \ No newline at end of file diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 4332dfc..9ef5402 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -17,6 +17,9 @@ PODS: - FirebaseCoreInternal (10.20.0): - "GoogleUtilities/NSData+zlib (~> 7.8)" - Flutter (1.0.0) + - fluttertoast (0.0.2): + - Flutter + - Toast - google_sign_in_ios (0.0.1): - Flutter - FlutterMacOS @@ -34,6 +37,8 @@ PODS: - AppAuth/Core (~> 1.6) - GTMSessionFetcher/Core (< 4.0, >= 1.5) - GTMSessionFetcher/Core (3.3.1) + - permission_handler_apple (9.3.0): + - Flutter - photo_manager (2.0.0): - Flutter - FlutterMacOS @@ -41,11 +46,14 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - Toast (4.1.0) DEPENDENCIES: - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - Flutter (from `Flutter`) + - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -60,14 +68,19 @@ SPEC REPOS: - GTMAppAuth - GTMSessionFetcher - PromisesObjC + - Toast EXTERNAL SOURCES: firebase_core: :path: ".symlinks/plugins/firebase_core/ios" Flutter: :path: Flutter + fluttertoast: + :path: ".symlinks/plugins/fluttertoast/ios" google_sign_in_ios: :path: ".symlinks/plugins/google_sign_in_ios/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" photo_manager: :path: ".symlinks/plugins/photo_manager/ios" shared_preferences_foundation: @@ -80,14 +93,17 @@ SPEC CHECKSUMS: FirebaseCore: 2322423314d92f946219c8791674d2f3345b598f FirebaseCoreInternal: efeeb171ac02d623bdaefe121539939821e10811 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 google_sign_in_ios: 1bfaf6607b44cd1b24c4d4bc39719870440f9ce1 GoogleSignIn: b232380cf495a429b8095d3178a8d5855b42e842 GoogleUtilities: 0759d1a57ebb953965c2dfe0ba4c82e95ccc2e34 GTMAppAuth: 99fb010047ba3973b7026e45393f51f27ab965ae GTMSessionFetcher: 8a1b34ad97ebe6f909fb8b9b77fba99943007556 + permission_handler_apple: 036b856153a2b1f61f21030ff725f3e6fece2b78 photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604 PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 + Toast: ec33c32b8688982cecc6348adeae667c1b9938da PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 diff --git a/app/lib/components/action_sheet.dart b/app/lib/components/action_sheet.dart index 0400cc2..872f817 100644 --- a/app/lib/components/action_sheet.dart +++ b/app/lib/components/action_sheet.dart @@ -1,4 +1,4 @@ -import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; +import 'package:style/extensions/context_extensions.dart'; import 'package:flutter/cupertino.dart'; import 'package:style/animations/on_tap_scale.dart'; import 'package:style/text/app_text_style.dart'; diff --git a/app/lib/components/app_sheet.dart b/app/lib/components/app_sheet.dart index 64b0c0b..03d93c7 100644 --- a/app/lib/components/app_sheet.dart +++ b/app/lib/components/app_sheet.dart @@ -1,5 +1,5 @@ -import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; import 'package:flutter/material.dart'; +import 'package:style/extensions/context_extensions.dart'; Future showAppSheet( {required BuildContext context, required Widget child}) { diff --git a/app/lib/components/snack_bar.dart b/app/lib/components/snack_bar.dart new file mode 100644 index 0000000..175146a --- /dev/null +++ b/app/lib/components/snack_bar.dart @@ -0,0 +1,86 @@ +import 'dart:io'; +import 'package:cloud_gallery/domain/extensions/app_error_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/text/app_text_style.dart'; + +void showErrorSnackBar({required BuildContext context, required Object error}) { + final message = error.l10nMessage(context); + showSnackBar( + context: context, + text: message, + icon: Icon( + Icons.warning_amber_rounded, + color: context.colorScheme.alert, + )); +} + +final _toast = FToast(); + +void showSnackBar({ + required BuildContext context, + required String text, + Widget? icon, + Duration duration = const Duration(seconds: 4), +}) { + if (Platform.isIOS || Platform.isMacOS) { + _toast.init(context); + _toast.removeCustomToast(); + _toast.showToast( + fadeDuration: const Duration(milliseconds: 100), + toastDuration: duration, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.colorScheme.containerNormalOnSurface, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + if (icon != null) icon, + if (icon != null) const SizedBox(width: 10), + Flexible( + child: Text( + text, + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textPrimary, + ), + overflow: TextOverflow.visible, + ), + ), + ], + ), + ), + gravity: ToastGravity.TOP, + ); + } else { + final snackBar = SnackBar( + elevation: 0, + margin: const EdgeInsets.all(16), + content: Row( + children: [ + if (icon != null) icon, + if (icon != null) const SizedBox(width: 10), + Flexible( + child: Text( + text, + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textPrimary, + ), + overflow: TextOverflow.visible, + ), + ), + ], + ), + backgroundColor: context.colorScheme.containerNormalOnSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + duration: duration, + ); + ScaffoldMessenger.of(context).removeCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } +} diff --git a/app/lib/domain/extensions/app_error_extensions.dart b/app/lib/domain/extensions/app_error_extensions.dart index b90a6c0..c02ff99 100644 --- a/app/lib/domain/extensions/app_error_extensions.dart +++ b/app/lib/domain/extensions/app_error_extensions.dart @@ -3,15 +3,22 @@ import 'package:data/errors/app_error.dart'; import 'package:data/errors/l10n_error_codes.dart'; import 'package:flutter/cupertino.dart'; -extension AppErrorExtensions on AppError { +extension AppErrorExtensions on Object { String l10nMessage(BuildContext context) { - switch (l10nCode) { - case AppErrorL10nCodes.noInternetConnection: - return context.l10n.no_internet_connection_error; - case AppErrorL10nCodes.somethingWentWrongError: - return context.l10n.something_went_wrong_error; - default: - return message ?? context.l10n.something_went_wrong_error; + if (this is AppError) { + switch ((this as AppError).l10nCode) { + case AppErrorL10nCodes.noInternetConnection: + return context.l10n.no_internet_connection_error; + case AppErrorL10nCodes.somethingWentWrongError: + return context.l10n.something_went_wrong_error; + default: + return (this as AppError).message ?? + context.l10n.something_went_wrong_error; + } + } else if (this is String) { + return this as String; + } else { + return context.l10n.something_went_wrong_error; } } } diff --git a/app/lib/domain/extensions/context_extensions.dart b/app/lib/domain/extensions/context_extensions.dart index da67f3a..e8349c1 100644 --- a/app/lib/domain/extensions/context_extensions.dart +++ b/app/lib/domain/extensions/context_extensions.dart @@ -1,21 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:style/theme/theme.dart'; extension BuildContextExtensions on BuildContext { AppLocalizations get l10n => AppLocalizations.of(this); - - EdgeInsets get systemPadding => MediaQuery.of(this).padding; - - Size get mediaQuerySize => MediaQuery.of(this).size; - - AppColorScheme get colorScheme => appColorSchemeOf(this); - - Brightness get brightness => MediaQuery.of(this).platformBrightness; - - bool get isDarkMode => brightness == Brightness.dark; - - FocusScopeNode get focusScope => FocusScope.of(this); - - bool get use24Hour => MediaQuery.of(this).alwaysUse24HourFormat; } diff --git a/app/lib/ui/app.dart b/app/lib/ui/app.dart index 0ce7dc5..bcbca76 100644 --- a/app/lib/ui/app.dart +++ b/app/lib/ui/app.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:style/extensions/context_extensions.dart'; import 'package:style/theme/theme.dart'; import 'package:style/theme/app_theme_builder.dart'; import 'package:data/storage/app_preferences.dart'; diff --git a/app/lib/ui/flow/home/components/image_item.dart b/app/lib/ui/flow/home/components/image_item.dart index f13c043..d5de8ed 100644 --- a/app/lib/ui/flow/home/components/image_item.dart +++ b/app/lib/ui/flow/home/components/image_item.dart @@ -1,7 +1,7 @@ -import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; import 'package:flutter/cupertino.dart'; import 'package:style/animations/on_tap_scale.dart'; import 'package:style/animations/parallex_effect.dart'; +import 'package:style/extensions/context_extensions.dart'; class ImageItem extends StatefulWidget { final VoidCallback? onTap; @@ -89,18 +89,18 @@ class ItemSelector extends StatelessWidget { Align( alignment: Alignment.bottomRight, child: Container( - padding: const EdgeInsets.all(4), + padding: const EdgeInsets.all(2), decoration: BoxDecoration( shape: BoxShape.circle, color: context.colorScheme.surface, border: Border.all( - color: CupertinoColors.systemGreen, + color: const Color(0xff808080), ), ), - child: const Icon( + child: const Icon( CupertinoIcons.checkmark_alt, - color: CupertinoColors.systemGreen, - size: 18, + color: Color(0xff808080), + size: 16, ), ), ), diff --git a/app/lib/ui/flow/home/components/screen_source_segment_control.dart b/app/lib/ui/flow/home/components/screen_source_segment_control.dart new file mode 100644 index 0000000..9a91741 --- /dev/null +++ b/app/lib/ui/flow/home/components/screen_source_segment_control.dart @@ -0,0 +1,58 @@ +import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/text/app_text_style.dart'; +import '../home_screen_view_model.dart'; + +class ScreenSourceSegmentControl extends ConsumerWidget { + const ScreenSourceSegmentControl({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sources = + ref.watch(homeViewStateNotifier.select((state) => state.sourcePage)); + final notifier = ref.read(homeViewStateNotifier.notifier); + return Padding( + padding: const EdgeInsets.only(top: 10, left: 16, right: 16, bottom: 10), + child: SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: [ + ButtonSegment( + value: MediaSource.local, + label: + Text(context.l10n.common_local, style: AppTextStyles.body2), + ), + ButtonSegment( + value: MediaSource.googleDrive, + label: Text( + context.l10n.google_drive_title, + style: AppTextStyles.body2, + ), + ) + ], + selected: {sources.sourcePage}, + multiSelectionEnabled: false, + onSelectionChanged: (source) { + notifier.updateMediaSource( + source: source.first, isChangedByScroll: false); + }, + showSelectedIcon: false, + style: SegmentedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide.none, + ), + side: BorderSide.none, + foregroundColor: context.colorScheme.textPrimary, + selectedForegroundColor: context.colorScheme.onPrimary, + selectedBackgroundColor: context.colorScheme.primary, + backgroundColor: context.colorScheme.containerNormalOnSurface, + visualDensity: VisualDensity.compact, + ), + ), + ), + ); + } +} diff --git a/app/lib/ui/flow/home/google_drive/google_drive_medias_screen.dart b/app/lib/ui/flow/home/google_drive/google_drive_medias_screen.dart new file mode 100644 index 0000000..13a8b2d --- /dev/null +++ b/app/lib/ui/flow/home/google_drive/google_drive_medias_screen.dart @@ -0,0 +1,18 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class GoogleDriveMediasScreen extends ConsumerStatefulWidget { + const GoogleDriveMediasScreen({super.key}); + + @override + ConsumerState createState() => _GoogleDriveViewState(); +} + +class _GoogleDriveViewState extends ConsumerState { + @override + Widget build(BuildContext context) { + return const Center( + child: Text('Google Drive'), + ); + } +} diff --git a/app/lib/ui/flow/home/google_drive/google_drive_medias_screen_view_model.dart b/app/lib/ui/flow/home/google_drive/google_drive_medias_screen_view_model.dart new file mode 100644 index 0000000..e69de29 diff --git a/app/lib/ui/flow/home/home_screen.dart b/app/lib/ui/flow/home/home_screen.dart index 59e80cc..ef7bb9f 100644 --- a/app/lib/ui/flow/home/home_screen.dart +++ b/app/lib/ui/flow/home/home_screen.dart @@ -1,17 +1,13 @@ -import 'dart:io'; import 'package:cloud_gallery/components/app_page.dart'; -import 'package:cloud_gallery/components/app_sheet.dart'; import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; -import 'package:cloud_gallery/domain/extensions/widget_extensions.dart'; +import 'package:cloud_gallery/ui/flow/home/components/screen_source_segment_control.dart'; import 'package:cloud_gallery/ui/flow/home/home_screen_view_model.dart'; -import 'package:data/models/media/media.dart'; +import 'package:cloud_gallery/ui/flow/home/local/local_medias_screen.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:style/text/app_text_style.dart'; -import '../../../components/action_sheet.dart'; import '../../../domain/assets/assets_paths.dart'; -import 'components/image_item.dart'; +import 'google_drive/google_drive_medias_screen.dart'; class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @@ -22,127 +18,64 @@ class HomeScreen extends ConsumerStatefulWidget { class _HomeScreenState extends ConsumerState { late HomeViewStateNotifier notifier; - final ScrollController _controller = ScrollController(); + final _pageController = PageController(); @override void initState() { notifier = ref.read(homeViewStateNotifier.notifier); - runPostFrame(() async { - await notifier.loadMediaCount(); - await notifier.loadMedia(); - }); - super.initState(); } @override void dispose() { - _controller.dispose(); + _pageController.dispose(); super.dispose(); } + void _updatePageOnChangeSource() { + ref.listen(homeViewStateNotifier.select((value) => value.sourcePage), + (previous, next) { + if (!next.viewChangedByScroll) { + _pageController.jumpToPage(next.sourcePage.index); + } + }); + } + @override Widget build(BuildContext context) { - final medias = ref.watch( - homeViewStateNotifier.select((state) => state.medias), - ); - - final selectedMedia = ref.watch( - homeViewStateNotifier.select((state) => state.selectedMedias), - ); - - final isLoading = ref.watch( - homeViewStateNotifier.select((state) => state.loading), - ); - - final mediaCounts = ref.watch( - homeViewStateNotifier.select((state) => state.mediaCount), - ); - + _updatePageOnChangeSource(); return AppPage( - title: context.l10n.app_name, - actions: [ - if (selectedMedia.isNotEmpty) - TextButton( - onPressed: () { - showAppSheet( - context: context, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppSheetAction( - icon: SvgPicture.asset( - Assets.images.icons.googlePhotos, - height: 24, - width: 24, - ), - title: context.l10n.back_up_on_google_drive_text, - onPressed: notifier.uploadMediaOnGoogleDrive, - ), - ], - )); - }, - child: Text( - context.l10n.common_done, - style: AppTextStyles.button.copyWith( - color: context.colorScheme.primary, - ), - ), - ) - ], - body: Visibility( - visible: !isLoading, - replacement: const Center(child: CircularProgressIndicator()), - child: Scrollbar( - controller: _controller, - child: GridView.builder( - controller: _controller, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 8, - mainAxisSpacing: 8, + titleWidget: _titleWidget(context: context), + body: Column( + children: [ + const ScreenSourceSegmentControl(), + Expanded( + child: PageView( + onPageChanged: (value) { + notifier.updateMediaSource( + isChangedByScroll: true, source: MediaSource.values[value]); + }, + controller: _pageController, + children: const [ + LocalMediasScreen(), + GoogleDriveMediasScreen(), + ], ), - itemCount: mediaCounts, - itemBuilder: (context, index) { - if (index > medias.length - 6) { - runPostFrame(() { - notifier.loadMedia(append: true); - }); - } - if (index < medias.length) { - if (medias[index].type != AppMediaType.image) { - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: context.colorScheme.outline, - ), - ); - } - return ImageItem( - onTap: () { - if (selectedMedia.isNotEmpty) { - notifier.mediaSelection(medias[index]); - } - }, - onLongTap: () { - notifier.mediaSelection(medias[index]); - }, - isSelected: selectedMedia.contains(medias[index]), - imageProvider: FileImage(File(medias[index].path)), - ); - } else { - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: context.colorScheme.primary, - ), - ); - } - }, ), - ), + ], ), ); } + + Widget _titleWidget({required BuildContext context}) => Row( + children: [ + const SizedBox(width: 16), + Image.asset( + Assets.images.appIcon, + width: 28, + ), + const SizedBox(width: 10), + Text(context.l10n.app_name) + ], + ); } 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 ac24dc1..c564e33 100644 --- a/app/lib/ui/flow/home/home_screen_view_model.dart +++ b/app/lib/ui/flow/home/home_screen_view_model.dart @@ -1,8 +1,3 @@ -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:flutter_riverpod/flutter_riverpod.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -10,97 +5,36 @@ part 'home_screen_view_model.freezed.dart'; final homeViewStateNotifier = StateNotifierProvider.autoDispose( - (ref) => HomeViewStateNotifier( - ref.read(localMediaServiceProvider), - ref.read(googleDriveServiceProvider), - ref.read(authServiceProvider), - ), + (ref) => HomeViewStateNotifier(), ); -class HomeViewStateNotifier extends StateNotifier { - final LocalMediaService _localMediaService; - final GoogleDriveService _googleDriveService; - final AuthService _authService; - - bool _loading = false; - - HomeViewStateNotifier(this._localMediaService, this._googleDriveService, this._authService) - : super(const HomeViewState()); - - Future loadMediaCount() async { - try { - final count = await _localMediaService.getMediaCount(); - state = state.copyWith(mediaCount: count); - } catch (error) { - state = state.copyWith(error: error); - } - } - - Future loadMedia({bool append = false}) async { - if (_loading == true) return; - _loading = true; - try { - state = state.copyWith(loading: state.medias.isEmpty); - final medias = await _localMediaService.getMedia( - start: append ? state.medias.length : 0, - end: append - ? state.medias.length + 20 - : state.medias.length < 20 - ? 20 - : state.medias.length, - ); - state = state.copyWith( - medias: [...state.medias, ...medias], - loading: false, - ); - } catch (error) { - state = state.copyWith(error: error, loading: false); - } - _loading = false; - } +enum MediaSource { local, googleDrive } - void mediaSelection(AppMedia media) { - final selectedMedias = state.selectedMedias; - if (selectedMedias.contains(media)) { - state = state.copyWith( - selectedMedias: selectedMedias.toList()..remove(media), - ); - } else { - state = state.copyWith( - selectedMedias: [...selectedMedias, media], - ); - } - } +class HomeViewStateNotifier extends StateNotifier { + HomeViewStateNotifier() : super(const HomeViewState()); - Future uploadMediaOnGoogleDrive() async { - try { - if(_authService.getUser == null){ - await _authService.signInWithGoogle(); - } - state = state.copyWith(uploadingMedias: state.selectedMedias); - final folderId = await _googleDriveService.getBackupFolderId(); - for (final media in state.selectedMedias) { - await _googleDriveService.uploadInGoogleDrive(media: media, folderID: folderId!); - } - state = state.copyWith(uploadingMedias: [], selectedMedias: []); - } catch (error) { - if(error is UserGoogleSignInAccountNotFound){ - await _authService.signInWithGoogle(); - await uploadMediaOnGoogleDrive(); - } - state = state.copyWith(error: error, uploadingMedias: []); - } + Future updateMediaSource( + {required MediaSource source, required bool isChangedByScroll}) async { + state = state.copyWith( + sourcePage: SourcePage( + sourcePage: source, viewChangedByScroll: isChangedByScroll)); } } @freezed class HomeViewState with _$HomeViewState { const factory HomeViewState({ - @Default(false) bool loading, - @Default([]) List uploadingMedias, - @Default([]) List medias, - @Default([]) List selectedMedias, - @Default(0) int mediaCount, - Object? error, + @Default(SourcePage()) SourcePage sourcePage, + @Default(false) bool isLastViewChangedByScroll, }) = _HomeViewState; } + +class SourcePage { + final MediaSource sourcePage; + final bool viewChangedByScroll; + + const SourcePage({ + this.sourcePage = MediaSource.local, + this.viewChangedByScroll = false, + }); +} 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 51c8a02..3076101 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 @@ -16,12 +16,8 @@ final _privateConstructorUsedError = UnsupportedError( /// @nodoc mixin _$HomeViewState { - bool get loading => throw _privateConstructorUsedError; - List get uploadingMedias => throw _privateConstructorUsedError; - List get medias => throw _privateConstructorUsedError; - List get selectedMedias => throw _privateConstructorUsedError; - int get mediaCount => throw _privateConstructorUsedError; - Object? get error => throw _privateConstructorUsedError; + SourcePage get sourcePage => throw _privateConstructorUsedError; + bool get isLastViewChangedByScroll => throw _privateConstructorUsedError; @JsonKey(ignore: true) $HomeViewStateCopyWith get copyWith => @@ -34,13 +30,7 @@ abstract class $HomeViewStateCopyWith<$Res> { HomeViewState value, $Res Function(HomeViewState) then) = _$HomeViewStateCopyWithImpl<$Res, HomeViewState>; @useResult - $Res call( - {bool loading, - List uploadingMedias, - List medias, - List selectedMedias, - int mediaCount, - Object? error}); + $Res call({SourcePage sourcePage, bool isLastViewChangedByScroll}); } /// @nodoc @@ -56,35 +46,18 @@ class _$HomeViewStateCopyWithImpl<$Res, $Val extends HomeViewState> @pragma('vm:prefer-inline') @override $Res call({ - Object? loading = null, - Object? uploadingMedias = null, - Object? medias = null, - Object? selectedMedias = null, - Object? mediaCount = null, - Object? error = freezed, + Object? sourcePage = null, + Object? isLastViewChangedByScroll = null, }) { return _then(_value.copyWith( - loading: null == loading - ? _value.loading - : loading // ignore: cast_nullable_to_non_nullable + sourcePage: null == sourcePage + ? _value.sourcePage + : sourcePage // ignore: cast_nullable_to_non_nullable + as SourcePage, + isLastViewChangedByScroll: null == isLastViewChangedByScroll + ? _value.isLastViewChangedByScroll + : isLastViewChangedByScroll // ignore: cast_nullable_to_non_nullable as bool, - uploadingMedias: null == uploadingMedias - ? _value.uploadingMedias - : uploadingMedias // ignore: cast_nullable_to_non_nullable - as List, - medias: null == medias - ? _value.medias - : medias // ignore: cast_nullable_to_non_nullable - as List, - selectedMedias: null == selectedMedias - ? _value.selectedMedias - : selectedMedias // ignore: cast_nullable_to_non_nullable - as List, - mediaCount: null == mediaCount - ? _value.mediaCount - : mediaCount // ignore: cast_nullable_to_non_nullable - as int, - error: freezed == error ? _value.error : error, ) as $Val); } } @@ -97,13 +70,7 @@ abstract class _$$HomeViewStateImplCopyWith<$Res> __$$HomeViewStateImplCopyWithImpl<$Res>; @override @useResult - $Res call( - {bool loading, - List uploadingMedias, - List medias, - List selectedMedias, - int mediaCount, - Object? error}); + $Res call({SourcePage sourcePage, bool isLastViewChangedByScroll}); } /// @nodoc @@ -117,35 +84,18 @@ class __$$HomeViewStateImplCopyWithImpl<$Res> @pragma('vm:prefer-inline') @override $Res call({ - Object? loading = null, - Object? uploadingMedias = null, - Object? medias = null, - Object? selectedMedias = null, - Object? mediaCount = null, - Object? error = freezed, + Object? sourcePage = null, + Object? isLastViewChangedByScroll = null, }) { return _then(_$HomeViewStateImpl( - loading: null == loading - ? _value.loading - : loading // ignore: cast_nullable_to_non_nullable + sourcePage: null == sourcePage + ? _value.sourcePage + : sourcePage // ignore: cast_nullable_to_non_nullable + as SourcePage, + isLastViewChangedByScroll: null == isLastViewChangedByScroll + ? _value.isLastViewChangedByScroll + : isLastViewChangedByScroll // ignore: cast_nullable_to_non_nullable as bool, - uploadingMedias: null == uploadingMedias - ? _value._uploadingMedias - : uploadingMedias // ignore: cast_nullable_to_non_nullable - as List, - medias: null == medias - ? _value._medias - : medias // ignore: cast_nullable_to_non_nullable - as List, - selectedMedias: null == selectedMedias - ? _value._selectedMedias - : selectedMedias // ignore: cast_nullable_to_non_nullable - as List, - mediaCount: null == mediaCount - ? _value.mediaCount - : mediaCount // ignore: cast_nullable_to_non_nullable - as int, - error: freezed == error ? _value.error : error, )); } } @@ -154,55 +104,19 @@ class __$$HomeViewStateImplCopyWithImpl<$Res> class _$HomeViewStateImpl implements _HomeViewState { const _$HomeViewStateImpl( - {this.loading = false, - final List uploadingMedias = const [], - final List medias = const [], - final List selectedMedias = const [], - this.mediaCount = 0, - this.error}) - : _uploadingMedias = uploadingMedias, - _medias = medias, - _selectedMedias = selectedMedias; + {this.sourcePage = const SourcePage(), + this.isLastViewChangedByScroll = false}); @override @JsonKey() - final bool loading; - final List _uploadingMedias; + final SourcePage sourcePage; @override @JsonKey() - List get uploadingMedias { - if (_uploadingMedias is EqualUnmodifiableListView) return _uploadingMedias; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_uploadingMedias); - } - - final List _medias; - @override - @JsonKey() - List get medias { - if (_medias is EqualUnmodifiableListView) return _medias; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_medias); - } - - final List _selectedMedias; - @override - @JsonKey() - List get selectedMedias { - if (_selectedMedias is EqualUnmodifiableListView) return _selectedMedias; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_selectedMedias); - } - - @override - @JsonKey() - final int mediaCount; - @override - final Object? error; + final bool isLastViewChangedByScroll; @override String toString() { - return 'HomeViewState(loading: $loading, uploadingMedias: $uploadingMedias, medias: $medias, selectedMedias: $selectedMedias, mediaCount: $mediaCount, error: $error)'; + return 'HomeViewState(sourcePage: $sourcePage, isLastViewChangedByScroll: $isLastViewChangedByScroll)'; } @override @@ -210,26 +124,16 @@ class _$HomeViewStateImpl implements _HomeViewState { return identical(this, other) || (other.runtimeType == runtimeType && other is _$HomeViewStateImpl && - (identical(other.loading, loading) || other.loading == loading) && - const DeepCollectionEquality() - .equals(other._uploadingMedias, _uploadingMedias) && - const DeepCollectionEquality().equals(other._medias, _medias) && - const DeepCollectionEquality() - .equals(other._selectedMedias, _selectedMedias) && - (identical(other.mediaCount, mediaCount) || - other.mediaCount == mediaCount) && - const DeepCollectionEquality().equals(other.error, error)); + (identical(other.sourcePage, sourcePage) || + other.sourcePage == sourcePage) && + (identical(other.isLastViewChangedByScroll, + isLastViewChangedByScroll) || + other.isLastViewChangedByScroll == isLastViewChangedByScroll)); } @override - int get hashCode => Object.hash( - runtimeType, - loading, - const DeepCollectionEquality().hash(_uploadingMedias), - const DeepCollectionEquality().hash(_medias), - const DeepCollectionEquality().hash(_selectedMedias), - mediaCount, - const DeepCollectionEquality().hash(error)); + int get hashCode => + Object.hash(runtimeType, sourcePage, isLastViewChangedByScroll); @JsonKey(ignore: true) @override @@ -240,25 +144,13 @@ class _$HomeViewStateImpl implements _HomeViewState { abstract class _HomeViewState implements HomeViewState { const factory _HomeViewState( - {final bool loading, - final List uploadingMedias, - final List medias, - final List selectedMedias, - final int mediaCount, - final Object? error}) = _$HomeViewStateImpl; + {final SourcePage sourcePage, + final bool isLastViewChangedByScroll}) = _$HomeViewStateImpl; @override - bool get loading; - @override - List get uploadingMedias; - @override - List get medias; - @override - List get selectedMedias; - @override - int get mediaCount; + SourcePage get sourcePage; @override - Object? get error; + bool get isLastViewChangedByScroll; @override @JsonKey(ignore: true) _$$HomeViewStateImplCopyWith<_$HomeViewStateImpl> get copyWith => diff --git a/app/lib/ui/flow/home/local/components/multi_selection_done_button.dart b/app/lib/ui/flow/home/local/components/multi_selection_done_button.dart new file mode 100644 index 0000000..a7ad362 --- /dev/null +++ b/app/lib/ui/flow/home/local/components/multi_selection_done_button.dart @@ -0,0 +1,46 @@ +import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; +import 'package:cloud_gallery/ui/flow/home/local/local_media_screen_view_model.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:style/extensions/context_extensions.dart'; +import '../../../../../components/action_sheet.dart'; +import '../../../../../components/app_sheet.dart'; +import '../../../../../domain/assets/assets_paths.dart'; + +class MultiSelectionDoneButton extends ConsumerWidget { + const MultiSelectionDoneButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifier = ref.read(localMediasViewStateNotifier.notifier); + return FloatingActionButton( + elevation: 3, + backgroundColor: context.colorScheme.primary, + onPressed: () { + showAppSheet( + context: context, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppSheetAction( + icon: SvgPicture.asset( + Assets.images.icons.googlePhotos, + height: 24, + width: 24, + ), + title: context.l10n.back_up_on_google_drive_text, + onPressed: notifier.uploadMediaOnGoogleDrive, + ), + ], + ), + ); + }, + child: Icon( + CupertinoIcons.checkmark_alt, + color: context.colorScheme.onPrimary, + ), + ); + } +} diff --git a/app/lib/ui/flow/home/local/components/no_local_medias_access_screen.dart b/app/lib/ui/flow/home/local/components/no_local_medias_access_screen.dart new file mode 100644 index 0000000..42ae9f5 --- /dev/null +++ b/app/lib/ui/flow/home/local/components/no_local_medias_access_screen.dart @@ -0,0 +1,55 @@ +import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; +import 'package:cloud_gallery/ui/flow/home/local/local_media_screen_view_model.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/text/app_text_style.dart'; +import 'package:style/buttons/primary_button.dart'; + +class NoLocalMediasAccessScreen extends ConsumerWidget { + const NoLocalMediasAccessScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifier = ref.read(localMediasViewStateNotifier.notifier); + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(30), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.photo, + color: context.colorScheme.containerHighOnSurface, + size: 100, + ), + const SizedBox(height: 20), + Text(context.l10n.cant_find_media_title, + style: AppTextStyles.subtitle2.copyWith( + color: context.colorScheme.textPrimary, + )), + const SizedBox(height: 20), + Text( + context.l10n.ask_for_media_permission_message, + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + PrimaryButton( + onPressed: () async { + await openAppSettings(); + await notifier.loadMediaCount(); + await notifier.loadMedia(); + }, + text: context.l10n.load_local_media_button_text, + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/ui/flow/home/local/local_media_screen_view_model.dart b/app/lib/ui/flow/home/local/local_media_screen_view_model.dart new file mode 100644 index 0000000..9c1b57b --- /dev/null +++ b/app/lib/ui/flow/home/local/local_media_screen_view_model.dart @@ -0,0 +1,118 @@ +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:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'local_media_screen_view_model.freezed.dart'; + +final localMediasViewStateNotifier = StateNotifierProvider.autoDispose< + LocalMediasViewStateNotifier, LocalMediasViewState>( + (ref) => LocalMediasViewStateNotifier( + ref.read(localMediaServiceProvider), + ref.read(googleDriveServiceProvider), + ref.read(authServiceProvider), + ), +); + +class LocalMediasViewStateNotifier extends StateNotifier { + final LocalMediaService _localMediaService; + final GoogleDriveService _googleDriveService; + final AuthService _authService; + + bool _loading = false; + + LocalMediasViewStateNotifier( + this._localMediaService, this._googleDriveService, this._authService) + : super(const LocalMediasViewState()); + + + Future loadMediaCount() async { + try { + final hasAccess = await _localMediaService.requestPermission(); + if (hasAccess) { + final count = await _localMediaService.getMediaCount(); + state = state.copyWith( + mediaCount: count, + hasLocalMediaAccess: hasAccess, + ); + } else { + state = state.copyWith(hasLocalMediaAccess: hasAccess); + } + } catch (error) { + state = state.copyWith(error: error); + } + } + + Future loadMedia({bool append = false}) async { + if (_loading == true) return; + _loading = true; + try { + state = state.copyWith(loading: state.medias.isEmpty); + final medias = await _localMediaService.getMedia( + start: append ? state.medias.length : 0, + end: append + ? state.medias.length + 20 + : state.medias.length < 20 + ? 20 + : state.medias.length, + ); + state = state.copyWith( + medias: [...state.medias, ...medias], + loading: false, + ); + } catch (error) { + state = state.copyWith(error: error, loading: false); + } + _loading = false; + } + + void mediaSelection(AppMedia media) { + final selectedMedias = state.selectedMedias; + if (selectedMedias.contains(media)) { + state = state.copyWith( + selectedMedias: selectedMedias.toList()..remove(media), + ); + } else { + state = state.copyWith( + selectedMedias: [...selectedMedias, media], + ); + } + } + + Future uploadMediaOnGoogleDrive() async { + try { + if (_authService.getUser == null) { + await _authService.signInWithGoogle(); + } + state = state.copyWith(uploadingMedias: state.selectedMedias); + final folderId = await _googleDriveService.getBackupFolderId(); + for (final media in state.selectedMedias) { + await _googleDriveService.uploadInGoogleDrive( + media: media, folderID: folderId!); + } + state = state.copyWith(uploadingMedias: [], selectedMedias: []); + } catch (error) { + if (error is UserGoogleSignInAccountNotFound) { + await _authService.signInWithGoogle(); + await uploadMediaOnGoogleDrive(); + } + state = state.copyWith(error: error, uploadingMedias: []); + } + } +} + +@freezed +class LocalMediasViewState with _$LocalMediasViewState { + const factory LocalMediasViewState({ + @Default(false) bool loading, + @Default([]) List uploadingMedias, + @Default([]) List medias, + @Default([]) List selectedMedias, + @Default(0) int mediaCount, + @Default(false) hasLocalMediaAccess, + Object? error, + }) = _LocalMediasViewState; +} diff --git a/app/lib/ui/flow/home/local/local_media_screen_view_model.freezed.dart b/app/lib/ui/flow/home/local/local_media_screen_view_model.freezed.dart new file mode 100644 index 0000000..e139c63 --- /dev/null +++ b/app/lib/ui/flow/home/local/local_media_screen_view_model.freezed.dart @@ -0,0 +1,291 @@ +// 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 'local_media_screen_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#custom-getters-and-methods'); + +/// @nodoc +mixin _$LocalMediasViewState { + bool get loading => throw _privateConstructorUsedError; + List get uploadingMedias => throw _privateConstructorUsedError; + List get medias => throw _privateConstructorUsedError; + List get selectedMedias => throw _privateConstructorUsedError; + int get mediaCount => throw _privateConstructorUsedError; + dynamic get hasLocalMediaAccess => throw _privateConstructorUsedError; + Object? get error => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $LocalMediasViewStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $LocalMediasViewStateCopyWith<$Res> { + factory $LocalMediasViewStateCopyWith(LocalMediasViewState value, + $Res Function(LocalMediasViewState) then) = + _$LocalMediasViewStateCopyWithImpl<$Res, LocalMediasViewState>; + @useResult + $Res call( + {bool loading, + List uploadingMedias, + List medias, + List selectedMedias, + int mediaCount, + dynamic hasLocalMediaAccess, + Object? error}); +} + +/// @nodoc +class _$LocalMediasViewStateCopyWithImpl<$Res, + $Val extends LocalMediasViewState> + implements $LocalMediasViewStateCopyWith<$Res> { + _$LocalMediasViewStateCopyWithImpl(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? uploadingMedias = null, + Object? medias = null, + Object? selectedMedias = null, + Object? mediaCount = null, + Object? hasLocalMediaAccess = freezed, + Object? error = freezed, + }) { + return _then(_value.copyWith( + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + uploadingMedias: null == uploadingMedias + ? _value.uploadingMedias + : uploadingMedias // ignore: cast_nullable_to_non_nullable + as List, + medias: null == medias + ? _value.medias + : medias // ignore: cast_nullable_to_non_nullable + as List, + selectedMedias: null == selectedMedias + ? _value.selectedMedias + : selectedMedias // ignore: cast_nullable_to_non_nullable + as List, + mediaCount: null == mediaCount + ? _value.mediaCount + : mediaCount // ignore: cast_nullable_to_non_nullable + as int, + hasLocalMediaAccess: freezed == hasLocalMediaAccess + ? _value.hasLocalMediaAccess + : hasLocalMediaAccess // ignore: cast_nullable_to_non_nullable + as dynamic, + error: freezed == error ? _value.error : error, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$LocalMediasViewStateImplCopyWith<$Res> + implements $LocalMediasViewStateCopyWith<$Res> { + factory _$$LocalMediasViewStateImplCopyWith(_$LocalMediasViewStateImpl value, + $Res Function(_$LocalMediasViewStateImpl) then) = + __$$LocalMediasViewStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {bool loading, + List uploadingMedias, + List medias, + List selectedMedias, + int mediaCount, + dynamic hasLocalMediaAccess, + Object? error}); +} + +/// @nodoc +class __$$LocalMediasViewStateImplCopyWithImpl<$Res> + extends _$LocalMediasViewStateCopyWithImpl<$Res, _$LocalMediasViewStateImpl> + implements _$$LocalMediasViewStateImplCopyWith<$Res> { + __$$LocalMediasViewStateImplCopyWithImpl(_$LocalMediasViewStateImpl _value, + $Res Function(_$LocalMediasViewStateImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? loading = null, + Object? uploadingMedias = null, + Object? medias = null, + Object? selectedMedias = null, + Object? mediaCount = null, + Object? hasLocalMediaAccess = freezed, + Object? error = freezed, + }) { + return _then(_$LocalMediasViewStateImpl( + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + uploadingMedias: null == uploadingMedias + ? _value._uploadingMedias + : uploadingMedias // ignore: cast_nullable_to_non_nullable + as List, + medias: null == medias + ? _value._medias + : medias // ignore: cast_nullable_to_non_nullable + as List, + selectedMedias: null == selectedMedias + ? _value._selectedMedias + : selectedMedias // ignore: cast_nullable_to_non_nullable + as List, + mediaCount: null == mediaCount + ? _value.mediaCount + : mediaCount // ignore: cast_nullable_to_non_nullable + as int, + hasLocalMediaAccess: freezed == hasLocalMediaAccess + ? _value.hasLocalMediaAccess! + : hasLocalMediaAccess, + error: freezed == error ? _value.error : error, + )); + } +} + +/// @nodoc + +class _$LocalMediasViewStateImpl implements _LocalMediasViewState { + const _$LocalMediasViewStateImpl( + {this.loading = false, + final List uploadingMedias = const [], + final List medias = const [], + final List selectedMedias = const [], + this.mediaCount = 0, + this.hasLocalMediaAccess = false, + this.error}) + : _uploadingMedias = uploadingMedias, + _medias = medias, + _selectedMedias = selectedMedias; + + @override + @JsonKey() + final bool loading; + final List _uploadingMedias; + @override + @JsonKey() + List get uploadingMedias { + if (_uploadingMedias is EqualUnmodifiableListView) return _uploadingMedias; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_uploadingMedias); + } + + final List _medias; + @override + @JsonKey() + List get medias { + if (_medias is EqualUnmodifiableListView) return _medias; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_medias); + } + + final List _selectedMedias; + @override + @JsonKey() + List get selectedMedias { + if (_selectedMedias is EqualUnmodifiableListView) return _selectedMedias; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_selectedMedias); + } + + @override + @JsonKey() + final int mediaCount; + @override + @JsonKey() + final dynamic hasLocalMediaAccess; + @override + final Object? error; + + @override + String toString() { + return 'LocalMediasViewState(loading: $loading, uploadingMedias: $uploadingMedias, medias: $medias, selectedMedias: $selectedMedias, mediaCount: $mediaCount, hasLocalMediaAccess: $hasLocalMediaAccess, error: $error)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$LocalMediasViewStateImpl && + (identical(other.loading, loading) || other.loading == loading) && + const DeepCollectionEquality() + .equals(other._uploadingMedias, _uploadingMedias) && + const DeepCollectionEquality().equals(other._medias, _medias) && + const DeepCollectionEquality() + .equals(other._selectedMedias, _selectedMedias) && + (identical(other.mediaCount, mediaCount) || + other.mediaCount == mediaCount) && + const DeepCollectionEquality() + .equals(other.hasLocalMediaAccess, hasLocalMediaAccess) && + const DeepCollectionEquality().equals(other.error, error)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + loading, + const DeepCollectionEquality().hash(_uploadingMedias), + const DeepCollectionEquality().hash(_medias), + const DeepCollectionEquality().hash(_selectedMedias), + mediaCount, + const DeepCollectionEquality().hash(hasLocalMediaAccess), + const DeepCollectionEquality().hash(error)); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$LocalMediasViewStateImplCopyWith<_$LocalMediasViewStateImpl> + get copyWith => + __$$LocalMediasViewStateImplCopyWithImpl<_$LocalMediasViewStateImpl>( + this, _$identity); +} + +abstract class _LocalMediasViewState implements LocalMediasViewState { + const factory _LocalMediasViewState( + {final bool loading, + final List uploadingMedias, + final List medias, + final List selectedMedias, + final int mediaCount, + final dynamic hasLocalMediaAccess, + final Object? error}) = _$LocalMediasViewStateImpl; + + @override + bool get loading; + @override + List get uploadingMedias; + @override + List get medias; + @override + List get selectedMedias; + @override + int get mediaCount; + @override + dynamic get hasLocalMediaAccess; + @override + Object? get error; + @override + @JsonKey(ignore: true) + _$$LocalMediasViewStateImplCopyWith<_$LocalMediasViewStateImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/app/lib/ui/flow/home/local/local_medias_screen.dart b/app/lib/ui/flow/home/local/local_medias_screen.dart new file mode 100644 index 0000000..5c3d18d --- /dev/null +++ b/app/lib/ui/flow/home/local/local_medias_screen.dart @@ -0,0 +1,127 @@ +import 'dart:io'; +import 'package:cloud_gallery/ui/flow/home/local/components/no_local_medias_access_screen.dart'; +import 'package:cloud_gallery/ui/flow/home/local/local_media_screen_view_model.dart'; +import 'package:data/models/media/media.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:style/extensions/context_extensions.dart'; +import '../../../../components/snack_bar.dart'; +import '../../../../domain/extensions/widget_extensions.dart'; +import '../components/image_item.dart'; + +class LocalMediasScreen extends ConsumerStatefulWidget { + const LocalMediasScreen({super.key}); + + @override + ConsumerState createState() => _LocalSourceViewState(); +} + +class _LocalSourceViewState extends ConsumerState { + late LocalMediasViewStateNotifier notifier; + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + notifier = ref.read(localMediasViewStateNotifier.notifier); + runPostFrame(() async { + await notifier.loadMediaCount(); + await notifier.loadMedia(); + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _observeError() { + ref.listen(localMediasViewStateNotifier.select((value) => value.error), + (previous, next) { + if (next != null) { + showErrorSnackBar(context: context, error: next); + } + }); + } + + @override + Widget build(BuildContext context) { + //Listeners + _observeError(); + + //States + final medias = + ref.watch(localMediasViewStateNotifier.select((state) => state.medias)); + + final selectedMedia = ref.watch( + localMediasViewStateNotifier.select((state) => state.selectedMedias)); + + final isLoading = ref + .watch(localMediasViewStateNotifier.select((state) => state.loading)); + + final mediaCounts = ref.watch( + localMediasViewStateNotifier.select((state) => state.mediaCount)); + + final hasAccess = ref.watch(localMediasViewStateNotifier + .select((state) => state.hasLocalMediaAccess)); + + //View + if(!hasAccess){ + return const NoLocalMediasAccessScreen(); + } + return Visibility( + visible: !isLoading, + replacement: const Center(child: CircularProgressIndicator.adaptive()), + child: Scrollbar( + controller: _scrollController, + child: GridView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: mediaCounts, + itemBuilder: (context, index) { + if (index > medias.length - 6) { + runPostFrame(() { + notifier.loadMedia(append: true); + }); + } + if (index < medias.length) { + if (medias[index].type != AppMediaType.image) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: context.colorScheme.outline, + ), + ); + } + return ImageItem( + onTap: () { + if (selectedMedia.isNotEmpty) { + notifier.mediaSelection(medias[index]); + } + }, + onLongTap: () { + notifier.mediaSelection(medias[index]); + }, + isSelected: selectedMedia.contains(medias[index]), + imageProvider: FileImage(File(medias[index].path)), + ); + } else { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: context.colorScheme.primary, + ), + ); + } + }, + ), + ), + ); + } +} diff --git a/app/lib/ui/flow/onboard/onboard_screen.dart b/app/lib/ui/flow/onboard/onboard_screen.dart index 3bdbb93..2fdebd4 100644 --- a/app/lib/ui/flow/onboard/onboard_screen.dart +++ b/app/lib/ui/flow/onboard/onboard_screen.dart @@ -5,6 +5,7 @@ import 'package:data/storage/app_preferences.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:style/extensions/context_extensions.dart'; import 'package:style/text/app_text_style.dart'; import 'package:style/animations/on_tap_scale.dart'; import '../../../domain/assets/assets_paths.dart'; diff --git a/app/lib/ui/navigation/app_route.dart b/app/lib/ui/navigation/app_route.dart index 8fec94a..3ce2c63 100644 --- a/app/lib/ui/navigation/app_route.dart +++ b/app/lib/ui/navigation/app_route.dart @@ -58,11 +58,11 @@ class AppRoute { GoRoute get goRoute => GoRoute( path: path, - name: path, + name: name, builder: (context, state) => builder(context), ); } extension GoRouterStateExtensions on GoRouterState { Widget widget(BuildContext context) => (extra as WidgetBuilder)(context); -} \ No newline at end of file +} diff --git a/app/pubspec.lock b/app/pubspec.lock index fdb27fe..626afe4 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -348,6 +348,14 @@ packages: description: flutter source: sdk version: "0.0.0" + fluttertoast: + dependency: "direct main" + description: + name: fluttertoast + sha256: dfdde255317af381bfc1c486ed968d5a43a2ded9c931e87cbecd88767d6a71c1 + url: "https://pub.dev" + source: hosted + version: "8.2.4" freezed: dependency: "direct main" description: @@ -652,6 +660,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "74e962b7fad7ff75959161bb2c0ad8fe7f2568ee82621c9c2660b751146bfe44" + url: "https://pub.dev" + source: hosted + version: "11.3.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1acac6bae58144b442f11e66621c062aead9c99841093c38f5bcdcc24c1c3474" + url: "https://pub.dev" + source: hosted + version: "12.0.5" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: bdafc6db74253abb63907f4e357302e6bb786ab41465e8635f362ee71fd8707b + url: "https://pub.dev" + source: hosted + version: "9.4.0" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: "23dfba8447c076ab5be3dee9ceb66aad345c4a648f0cac292c77b1eb0e800b78" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index c68407a..6c630b4 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -26,10 +26,14 @@ dependencies: # UI cupertino_icons: ^1.0.2 flutter_svg: ^2.0.9 + fluttertoast: ^8.2.4 # state management flutter_riverpod: ^2.4.9 + # permission + permission_handler: ^11.3.0 + # navigation go_router: ^13.0.1 diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc index 1a82e7d..5893c2f 100644 --- a/app/windows/flutter/generated_plugin_registrant.cc +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,11 @@ #include "generated_plugin_registrant.h" #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); } diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake index fa8a39b..873f19d 100644 --- a/app/windows/flutter/generated_plugins.cmake +++ b/app/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST firebase_core + permission_handler_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/data/lib/extensions/sucesser_function.dart b/data/lib/extensions/sucesser_function.dart deleted file mode 100644 index 8b13789..0000000 --- a/data/lib/extensions/sucesser_function.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/data/lib/services/local_media_service.dart b/data/lib/services/local_media_service.dart index fb80e30..2ae278c 100644 --- a/data/lib/services/local_media_service.dart +++ b/data/lib/services/local_media_service.dart @@ -10,14 +10,18 @@ final localMediaServiceProvider = Provider( class LocalMediaService { const LocalMediaService(); + Future requestPermission() async { + final state = await PhotoManager.requestPermissionExtend(); + return state.hasAccess; + } + Future getMediaCount() async { - await PhotoManager.requestPermissionExtend(); return await PhotoManager.getAssetCount(); } - Future> getMedia({required int start, required int end}) async { - final assets = - await PhotoManager.getAssetListRange(start: start, end: end); + Future> getMedia( + {required int start, required int end}) async { + final assets = await PhotoManager.getAssetListRange(start: start, end: end); final files = await Future.wait( assets.map( (asset) async { diff --git a/style/lib/buttons/primary_button.dart b/style/lib/buttons/primary_button.dart new file mode 100644 index 0000000..8888f89 --- /dev/null +++ b/style/lib/buttons/primary_button.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/text/app_text_style.dart'; + +class PrimaryButton extends StatelessWidget { + final String text; + final Widget? child; + final VoidCallback onPressed; + + const PrimaryButton( + {super.key, this.text = '', this.child, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return FilledButton( + onPressed: onPressed, + style: FilledButton.styleFrom( + backgroundColor: context.colorScheme.primary, + foregroundColor: context.colorScheme.onPrimary, + ), + child: child ?? + Text( + text, + style: AppTextStyles.button + .copyWith(color: context.colorScheme.onPrimary), + ), + ); + } +} diff --git a/style/lib/extensions/context_extensions.dart b/style/lib/extensions/context_extensions.dart new file mode 100644 index 0000000..1e0bd18 --- /dev/null +++ b/style/lib/extensions/context_extensions.dart @@ -0,0 +1,18 @@ +import 'package:flutter/cupertino.dart'; +import 'package:style/theme/theme.dart'; + +extension BuildContextExtensions on BuildContext { + EdgeInsets get systemPadding => MediaQuery.of(this).padding; + + Size get mediaQuerySize => MediaQuery.of(this).size; + + AppColorScheme get colorScheme => appColorSchemeOf(this); + + Brightness get brightness => MediaQuery.of(this).platformBrightness; + + bool get isDarkMode => brightness == Brightness.dark; + + FocusScopeNode get focusScope => FocusScope.of(this); + + bool get use24Hour => MediaQuery.of(this).alwaysUse24HourFormat; +}