diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml index 18c0c921..6d04606f 100644 --- a/.idea/libraries/Dart_Packages.xml +++ b/.idea/libraries/Dart_Packages.xml @@ -97,6 +97,7 @@ @@ -104,6 +105,7 @@ @@ -111,6 +113,7 @@ @@ -118,6 +121,7 @@ @@ -125,6 +129,7 @@ @@ -132,6 +137,7 @@ @@ -146,6 +152,7 @@ @@ -560,7 +567,6 @@ @@ -575,6 +581,7 @@ @@ -652,6 +659,7 @@ @@ -659,6 +667,7 @@ @@ -680,6 +689,7 @@ @@ -701,7 +711,6 @@ @@ -731,6 +740,7 @@ @@ -794,6 +804,7 @@ @@ -801,6 +812,7 @@ @@ -808,6 +820,7 @@ @@ -836,7 +849,7 @@ @@ -844,7 +857,6 @@ @@ -922,7 +934,6 @@ @@ -951,6 +962,7 @@ @@ -958,6 +970,7 @@ @@ -972,7 +985,6 @@ @@ -980,7 +992,6 @@ @@ -1009,6 +1020,7 @@ @@ -1016,7 +1028,7 @@ @@ -1024,6 +1036,7 @@ @@ -1059,6 +1072,7 @@ @@ -1066,7 +1080,6 @@ @@ -1088,6 +1101,7 @@ @@ -1123,7 +1137,6 @@ @@ -1131,6 +1144,7 @@ @@ -1166,6 +1180,7 @@ @@ -1215,6 +1230,7 @@ @@ -1369,6 +1385,7 @@ @@ -1425,7 +1442,7 @@ @@ -1447,6 +1464,7 @@ @@ -1466,13 +1484,20 @@ + + + + + + + @@ -1528,10 +1553,10 @@ - + @@ -1543,19 +1568,22 @@ + + + - + @@ -1565,14 +1593,16 @@ + + + - - + @@ -1584,46 +1614,49 @@ - + + - - - + + + - + + - + + @@ -1631,6 +1664,7 @@ + @@ -1653,6 +1687,7 @@ + @@ -1660,11 +1695,12 @@ - + + diff --git a/.idea/libraries/Flutter_Plugins.xml b/.idea/libraries/Flutter_Plugins.xml index 393d7ece..5d64713e 100644 --- a/.idea/libraries/Flutter_Plugins.xml +++ b/.idea/libraries/Flutter_Plugins.xml @@ -2,7 +2,6 @@ - @@ -13,20 +12,19 @@ - + - - + - + @@ -39,22 +37,27 @@ + - + - + + - - - - + + + + + + + diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index f10bf9e5..b44fd206 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -51,6 +51,10 @@ + + diff --git a/app/assets/locales/app_en.arb b/app/assets/locales/app_en.arb index d45bd015..88902bb0 100644 --- a/app/assets/locales/app_en.arb +++ b/app/assets/locales/app_en.arb @@ -15,10 +15,12 @@ "common_info":"Info", "common_upload": "Upload", "common_download": "Download", + "common_edit": "Edit", "common_delete": "Delete", "common_share": "Share", "common_cancel": "Cancel", "common_retry": "Retry", + "common_remove": "Remove", "common_done": "Done", "common_not_available": "N/A", "common_open_settings": "Open Settings", @@ -116,6 +118,32 @@ "empty_media_title": "Oh Snap! No Media Found!", "empty_media_message": "Looks like your gallery is taking a little break.", + "@_ALBUM":{}, + "album_screen_title": "Albums", + "empty_album_title": "Oops! No Albums Here!", + "empty_album_message": "It seems like there are no albums to show right now. You can create a new one. We've got you covered!", + + "@_ADD_ALBUM":{}, + "add_album_screen_title": "Album", + "album_name_field_title": "Album Name", + "store_in_title": "Store In", + "store_in_device_title": "Device", + + "@_ALBUM_MEDIA_LIST":{}, + "add_items_action_title": "Add Items", + "edit_album_action_title": "Edit Album", + "delete_album_action_title": "Delete Album", + "remove_item_action_title": "Remove Items", + "empty_album_media_list_title": "A Quiet Album", + "empty_album_media_list_message": "It looks like you don't have any media in this album just yet. Go ahead, add some photos or videos, and let's make this place sparkle!", + + "@_MEDIA_SELECTION":{}, + "select_from_device_title": "Select from Device", + "select_from_google_drive_title": "Select from Google Drive", + "select_from_dropbox_title": "Select from Dropbox", + "no_media_access_title": "No cloud media access", + "no_cloud_media_access_message": "You don't have access to view media files, check you sign in with the cloud!", + "@_MEDIA_INFO":{}, "name_text": "Name", diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist index 52b1c20e..e2c5f2c6 100644 --- a/app/ios/Runner/Info.plist +++ b/app/ios/Runner/Info.plist @@ -65,5 +65,7 @@ ITSAppUsesNonExemptEncryption + FlutterDeepLinkingEnabled + diff --git a/app/lib/components/app_media_thumbnail.dart b/app/lib/components/app_media_thumbnail.dart new file mode 100644 index 00000000..67659c8b --- /dev/null +++ b/app/lib/components/app_media_thumbnail.dart @@ -0,0 +1,116 @@ +import 'package:data/models/media/media.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/text/app_text_style.dart'; +import '../domain/formatter/duration_formatter.dart'; +import 'thumbnail_builder.dart'; + +class AppMediaThumbnail extends StatelessWidget { + final AppMedia media; + final String heroTag; + final void Function()? onTap; + final void Function()? onLongTap; + final bool selected; + + const AppMediaThumbnail({ + super.key, + required this.media, + required this.heroTag, + this.onTap, + this.onLongTap, + this.selected = false, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) => GestureDetector( + onTap: onTap, + onLongPress: onLongTap, + child: Stack( + alignment: Alignment.bottomLeft, + children: [ + AnimatedOpacity( + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 100), + opacity: selected ? 0.6 : 1, + child: AppMediaImage( + radius: selected ? 4 : 0, + size: constraints.biggest, + media: media, + heroTag: heroTag, + ), + ), + if (media.type.isVideo) _videoDuration(context), + if (selected) + Align( + alignment: Alignment.topLeft, + child: Container( + margin: const EdgeInsets.all(4), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.colorScheme.primary, + ), + child: const Icon( + CupertinoIcons.checkmark_alt, + color: Colors.white, + size: 14, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _videoDuration(BuildContext context) => Align( + alignment: Alignment.topCenter, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + stops: [0.0, 0.9], + begin: Alignment.topRight, + end: Alignment.bottomRight, + colors: [ + Colors.black.withValues(alpha: 0.4), + Colors.transparent, + ], + ), + ), + padding: const EdgeInsets.all(4).copyWith(bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + (media.videoDuration ?? Duration.zero).format, + style: AppTextStyles.caption.copyWith( + color: Colors.white, + fontSize: 12, + shadows: [ + Shadow( + color: Colors.grey.shade400, + blurRadius: 5, + ), + ], + ), + ), + const SizedBox(width: 2), + Icon( + CupertinoIcons.play_fill, + color: Colors.white, + size: 14, + shadows: [ + Shadow( + color: Colors.grey.shade400, + blurRadius: 5, + ), + ], + ), + ], + ), + ), + ); +} diff --git a/app/lib/components/app_page.dart b/app/lib/components/app_page.dart index 6b3985e1..aa2704dd 100644 --- a/app/lib/components/app_page.dart +++ b/app/lib/components/app_page.dart @@ -12,7 +12,7 @@ class AppPage extends StatelessWidget { final Widget? body; final Widget Function(BuildContext context)? bodyBuilder; final bool automaticallyImplyLeading; - final bool? resizeToAvoidBottomInset; + final bool resizeToAvoidBottomInset; final Color? backgroundColor; final Color? barBackgroundColor; @@ -24,7 +24,7 @@ class AppPage extends StatelessWidget { this.leading, this.body, this.floatingActionButton, - this.resizeToAvoidBottomInset, + this.resizeToAvoidBottomInset = true, this.bodyBuilder, this.automaticallyImplyLeading = true, this.barBackgroundColor, @@ -50,6 +50,7 @@ class AppPage extends StatelessWidget { leading: leading, middle: titleWidget ?? _title(context), border: null, + enableBackgroundFilterBlur: false, trailing: actions == null ? null : actions!.length == 1 @@ -63,7 +64,7 @@ class AppPage extends StatelessWidget { ? MaterialLocalizations.of(context).backButtonTooltip : null, ), - resizeToAvoidBottomInset: resizeToAvoidBottomInset ?? true, + resizeToAvoidBottomInset: resizeToAvoidBottomInset, backgroundColor: backgroundColor, child: Stack( alignment: Alignment.bottomRight, @@ -89,7 +90,10 @@ class AppPage extends StatelessWidget { leading == null ? null : AppBar( + centerTitle: true, backgroundColor: barBackgroundColor, + scrolledUnderElevation: 0.5, + shadowColor: context.colorScheme.textDisabled, title: titleWidget ?? _title(context), actions: [...?actions, const SizedBox(width: 16)], leading: leading, @@ -150,7 +154,8 @@ class AdaptiveAppBar extends StatelessWidget { : Column( children: [ AppBar( - backgroundColor: context.colorScheme.barColor, + centerTitle: true, + backgroundColor: context.colorScheme.surface, leading: leading, actions: actions, automaticallyImplyLeading: automaticallyImplyLeading, diff --git a/app/lib/components/app_sheet.dart b/app/lib/components/app_sheet.dart index 6d56534a..7a69587f 100644 --- a/app/lib/components/app_sheet.dart +++ b/app/lib/components/app_sheet.dart @@ -6,6 +6,7 @@ Future showAppSheet({ required Widget child, }) { return showModalBottomSheet( + useRootNavigator: true, backgroundColor: context.colorScheme.containerNormalOnSurface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(22), diff --git a/app/lib/components/selection_menu.dart b/app/lib/components/selection_menu.dart new file mode 100644 index 00000000..308e4840 --- /dev/null +++ b/app/lib/components/selection_menu.dart @@ -0,0 +1,108 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:style/animations/cross_fade_animation.dart'; +import 'package:style/animations/on_tap_scale.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/text/app_text_style.dart'; + +class SelectionMenuAction extends StatelessWidget { + final String title; + final Widget icon; + final void Function() onTap; + + const SelectionMenuAction({ + super.key, + required this.title, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return OnTapScale( + onTap: onTap, + child: SizedBox( + width: 92, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + alignment: Alignment.center, + height: 60, + width: 60, + decoration: BoxDecoration( + color: context.colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: context.colorScheme.outline), + ), + child: icon, + ), + const SizedBox(height: 8), + Text( + title, + style: AppTextStyles.body.copyWith( + color: context.colorScheme.textPrimary, + ), + textAlign: TextAlign.center, + overflow: TextOverflow.visible, + ), + ], + ), + ), + ); + } +} + +class SelectionMenu extends StatelessWidget { + final List items; + final bool useSystemPadding; + final bool show; + + const SelectionMenu({ + super.key, + required this.items, + this.useSystemPadding = true, + required this.show, + }); + + @override + Widget build(BuildContext context) { + return CrossFadeAnimation( + showChild: show, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + padding: const EdgeInsets.only(bottom: 16, top: 24), + width: double.infinity, + decoration: BoxDecoration( + color: context.colorScheme.containerLowOnSurface, + border: Border( + top: BorderSide( + width: 1, + color: context.colorScheme.outline, + ), + ), + ), + child: SafeArea( + top: false, + bottom: useSystemPadding, + left: false, + right: false, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: items, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/components/thumbnail_builder.dart b/app/lib/components/thumbnail_builder.dart index e8227556..ae1db0f4 100644 --- a/app/lib/components/thumbnail_builder.dart +++ b/app/lib/components/thumbnail_builder.dart @@ -8,7 +8,7 @@ import 'package:style/indicators/circular_progress_indicator.dart'; import '../domain/image_providers/app_media_image_provider.dart'; class AppMediaImage extends ConsumerWidget { - final Object? heroTag; + final String heroTag; final AppMedia media; final Size size; final double radius; @@ -16,7 +16,7 @@ class AppMediaImage extends ConsumerWidget { const AppMediaImage({ super.key, required this.size, - this.heroTag, + required this.heroTag, this.radius = 4, required this.media, }); @@ -28,7 +28,7 @@ class AppMediaImage extends ConsumerWidget { child: Container( color: context.colorScheme.containerNormalOnSurface, child: Hero( - tag: heroTag ?? '', + tag: heroTag, child: Image( gaplessPlayback: true, image: AppMediaImageProvider( diff --git a/app/lib/ui/app.dart b/app/lib/ui/app.dart index 70a04f57..b60a23bf 100644 --- a/app/lib/ui/app.dart +++ b/app/lib/ui/app.dart @@ -39,11 +39,12 @@ class _CloudGalleryAppState extends ConsumerState { _handleNotification(); _router = GoRouter( + navigatorKey: rootNavigatorKey, initialLocation: _configureInitialRoute(), routes: $appRoutes, redirect: (context, state) { if (state.uri.path.contains('/auth')) { - return '/'; + return AppRoutePath.accounts; } return null; }, diff --git a/app/lib/ui/flow/albums/add/add_album_screen.dart b/app/lib/ui/flow/albums/add/add_album_screen.dart new file mode 100644 index 00000000..baddb67d --- /dev/null +++ b/app/lib/ui/flow/albums/add/add_album_screen.dart @@ -0,0 +1,144 @@ +import 'package:data/models/album/album.dart'; +import 'package:data/models/media/media.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:style/buttons/action_button.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/indicators/circular_progress_indicator.dart'; +import 'package:style/text/app_text_field.dart'; +import 'package:style/text/app_text_style.dart'; +import '../../../../components/app_page.dart'; +import '../../../../components/snack_bar.dart'; +import '../../../../domain/extensions/context_extensions.dart'; +import 'add_album_state_notifier.dart'; +import 'package:style/buttons/radio_selection_button.dart'; + +class AddAlbumScreen extends ConsumerStatefulWidget { + final Album? editAlbum; + + const AddAlbumScreen({super.key, this.editAlbum}); + + @override + ConsumerState createState() => _AddAlbumScreenState(); +} + +class _AddAlbumScreenState extends ConsumerState { + late AutoDisposeStateNotifierProvider + _provider; + late AddAlbumStateNotifier _notifier; + + @override + void initState() { + _provider = addAlbumStateNotifierProvider(widget.editAlbum); + _notifier = ref.read(_provider.notifier); + super.initState(); + } + + void _observeError(BuildContext context) { + ref.listen( + _provider.select( + (value) => value.error, + ), (previous, error) { + if (error != null) { + showErrorSnackBar(context: context, error: error); + } + }); + } + + void _observeSucceed(BuildContext context) { + ref.listen( + _provider.select( + (value) => value.succeed, + ), (previous, success) { + if (success) { + context.pop(true); + } + }); + } + + @override + Widget build(BuildContext context) { + _observeError(context); + _observeSucceed(context); + final state = ref.watch(_provider); + return AppPage( + resizeToAvoidBottomInset: false, + title: context.l10n.add_album_screen_title, + body: _body(context: context, state: state), + actions: [ + state.loading + ? const SizedBox( + height: 24, + width: 24, + child: AppCircularProgressIndicator(), + ) + : ActionButton( + onPressed: state.allowSave ? _notifier.createAlbum : null, + icon: Icon( + Icons.check, + size: 24, + color: state.allowSave + ? context.colorScheme.textPrimary + : context.colorScheme.textDisabled, + ), + ), + ], + ); + } + + Widget _body({required BuildContext context, required AddAlbumsState state}) { + return ListView( + children: [ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: AppTextField( + controller: state.albumNameController, + onChanged: _notifier.validateAlbumName, + label: context.l10n.album_name_field_title, + ), + ), + if ((state.googleAccount != null || state.dropboxAccount != null) && + widget.editAlbum == null) ...[ + const SizedBox(height: 40), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + context.l10n.store_in_title, + style: AppTextStyles.subtitle1.copyWith( + color: context.colorScheme.textPrimary, + ), + ), + ), + const SizedBox(height: 8), + Column( + children: [ + RadioSelectionButton( + value: AppMediaSource.local, + groupValue: state.mediaSource, + onTab: () => _notifier.onSourceChange(AppMediaSource.local), + label: context.l10n.store_in_device_title, + ), + if (state.googleAccount != null) + RadioSelectionButton( + value: AppMediaSource.googleDrive, + groupValue: state.mediaSource, + onTab: () => + _notifier.onSourceChange(AppMediaSource.googleDrive), + label: context.l10n.common_google_drive, + ), + if (state.dropboxAccount != null) + RadioSelectionButton( + value: AppMediaSource.dropbox, + groupValue: state.mediaSource, + onTab: () => _notifier.onSourceChange(AppMediaSource.dropbox), + label: context.l10n.common_dropbox, + ), + ], + ), + ], + ], + ); + } +} diff --git a/app/lib/ui/flow/albums/add/add_album_state_notifier.dart b/app/lib/ui/flow/albums/add/add_album_state_notifier.dart new file mode 100644 index 00000000..edce0eef --- /dev/null +++ b/app/lib/ui/flow/albums/add/add_album_state_notifier.dart @@ -0,0 +1,188 @@ +import 'package:data/errors/app_error.dart'; +import 'package:data/log/logger.dart'; +import 'package:data/models/album/album.dart'; +import 'package:data/models/dropbox/account/dropbox_account.dart'; +import 'package:data/models/media/media.dart'; +import 'package:data/services/auth_service.dart'; +import 'package:data/services/dropbox_services.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/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:logger/logger.dart'; +import 'package:data/handlers/unique_id_generator.dart'; + +part 'add_album_state_notifier.freezed.dart'; + +final addAlbumStateNotifierProvider = StateNotifierProvider.autoDispose + .family((ref, state) { + final notifier = AddAlbumStateNotifier( + ref.read(googleUserAccountProvider), + ref.read(AppPreferences.dropboxCurrentUserAccount), + ref.read(localMediaServiceProvider), + ref.read(googleDriveServiceProvider), + ref.read(dropboxServiceProvider), + ref.read(uniqueIdGeneratorProvider), + ref.read(loggerProvider), + state, + ); + final googleDriveAccountSubscription = ref.listen( + googleUserAccountProvider, + (_, googleAccount) => notifier.onGoogleDriveAccountChange(googleAccount), + ); + final dropboxAccountSubscription = ref.listen( + AppPreferences.dropboxCurrentUserAccount, + (_, dropboxAccount) => notifier.onDropboxAccountChange(dropboxAccount), + ); + + ref.onDispose(() { + googleDriveAccountSubscription.close(); + dropboxAccountSubscription.close(); + }); + + return notifier; +}); + +class AddAlbumStateNotifier extends StateNotifier { + final GoogleSignInAccount? googleAccount; + final DropboxAccount? dropboxAccount; + final LocalMediaService _localMediaService; + final GoogleDriveService _googleDriveService; + final DropboxService _dropboxService; + final UniqueIdGenerator _uniqueIdGenerator; + final Logger _logger; + final Album? editAlbum; + + AddAlbumStateNotifier( + this.googleAccount, + this.dropboxAccount, + this._localMediaService, + this._googleDriveService, + this._dropboxService, + this._uniqueIdGenerator, + this._logger, + this.editAlbum, + ) : super( + AddAlbumsState( + albumNameController: TextEditingController(text: editAlbum?.name), + mediaSource: editAlbum?.source ?? AppMediaSource.local, + googleAccount: googleAccount, + dropboxAccount: dropboxAccount, + ), + ); + + void onGoogleDriveAccountChange(GoogleSignInAccount? googleAccount) { + state = state.copyWith(googleAccount: googleAccount); + } + + void onDropboxAccountChange(DropboxAccount? dropboxAccount) { + state = state.copyWith(dropboxAccount: dropboxAccount); + } + + void onSourceChange(AppMediaSource source) { + state = state.copyWith(mediaSource: source); + } + + Future createAlbum() async { + try { + state = state.copyWith(loading: true); + + if (state.mediaSource == AppMediaSource.local) { + if (editAlbum != null) { + await _localMediaService.updateAlbum( + editAlbum!.copyWith( + name: state.albumNameController.text.trim(), + ), + ); + } else { + await _localMediaService.createAlbum( + id: _uniqueIdGenerator.v4(), + name: state.albumNameController.text.trim(), + ); + } + } else if (state.mediaSource == AppMediaSource.googleDrive && + googleAccount != null) { + final backupFolderId = await _googleDriveService.getBackUpFolderId(); + + if (backupFolderId == null) { + throw BackUpFolderNotFound(); + } + if (editAlbum != null) { + final album = editAlbum!.copyWith( + name: state.albumNameController.text.trim(), + ); + await _googleDriveService.updateAlbum( + folderId: backupFolderId, + album: album, + ); + } else { + final album = Album( + id: _uniqueIdGenerator.v4(), + name: state.albumNameController.text.trim(), + source: AppMediaSource.googleDrive, + created_at: DateTime.now(), + medias: [], + ); + await _googleDriveService.createAlbum( + folderId: backupFolderId, + newAlbum: album, + ); + } + } else if (state.mediaSource == AppMediaSource.dropbox) { + if (editAlbum != null) { + await _dropboxService.updateAlbum( + editAlbum!.copyWith( + name: state.albumNameController.text.trim(), + ), + ); + } else { + final album = Album( + id: _uniqueIdGenerator.v4(), + name: state.albumNameController.text.trim(), + source: AppMediaSource.dropbox, + created_at: DateTime.now(), + medias: [], + ); + await _dropboxService.createAlbum(album); + } + } + state = state.copyWith(loading: false, succeed: true); + } catch (e, s) { + state = state.copyWith(loading: false, error: e); + _logger.e( + 'AddAlbumStateNotifier: Error creating album', + error: e, + stackTrace: s, + ); + } + } + + void validateAlbumName(String _) { + state = state.copyWith( + allowSave: state.albumNameController.text.trim().isNotEmpty, + ); + } + + @override + void dispose() { + state.albumNameController.dispose(); + super.dispose(); + } +} + +@freezed +class AddAlbumsState with _$AddAlbumsState { + const factory AddAlbumsState({ + @Default(false) bool loading, + @Default(false) bool succeed, + @Default(false) bool allowSave, + @Default(AppMediaSource.local) AppMediaSource mediaSource, + required TextEditingController albumNameController, + GoogleSignInAccount? googleAccount, + DropboxAccount? dropboxAccount, + Object? error, + }) = _AddAlbumsState; +} diff --git a/app/lib/ui/flow/albums/add/add_album_state_notifier.freezed.dart b/app/lib/ui/flow/albums/add/add_album_state_notifier.freezed.dart new file mode 100644 index 00000000..a7633dd6 --- /dev/null +++ b/app/lib/ui/flow/albums/add/add_album_state_notifier.freezed.dart @@ -0,0 +1,320 @@ +// 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 'add_album_state_notifier.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 _$AddAlbumsState { + bool get loading => throw _privateConstructorUsedError; + bool get succeed => throw _privateConstructorUsedError; + bool get allowSave => throw _privateConstructorUsedError; + AppMediaSource get mediaSource => throw _privateConstructorUsedError; + TextEditingController get albumNameController => + throw _privateConstructorUsedError; + GoogleSignInAccount? get googleAccount => throw _privateConstructorUsedError; + DropboxAccount? get dropboxAccount => throw _privateConstructorUsedError; + Object? get error => throw _privateConstructorUsedError; + + /// Create a copy of AddAlbumsState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AddAlbumsStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AddAlbumsStateCopyWith<$Res> { + factory $AddAlbumsStateCopyWith( + AddAlbumsState value, $Res Function(AddAlbumsState) then) = + _$AddAlbumsStateCopyWithImpl<$Res, AddAlbumsState>; + @useResult + $Res call( + {bool loading, + bool succeed, + bool allowSave, + AppMediaSource mediaSource, + TextEditingController albumNameController, + GoogleSignInAccount? googleAccount, + DropboxAccount? dropboxAccount, + Object? error}); + + $DropboxAccountCopyWith<$Res>? get dropboxAccount; +} + +/// @nodoc +class _$AddAlbumsStateCopyWithImpl<$Res, $Val extends AddAlbumsState> + implements $AddAlbumsStateCopyWith<$Res> { + _$AddAlbumsStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AddAlbumsState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? loading = null, + Object? succeed = null, + Object? allowSave = null, + Object? mediaSource = null, + Object? albumNameController = null, + Object? googleAccount = freezed, + Object? dropboxAccount = freezed, + Object? error = freezed, + }) { + return _then(_value.copyWith( + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + succeed: null == succeed + ? _value.succeed + : succeed // ignore: cast_nullable_to_non_nullable + as bool, + allowSave: null == allowSave + ? _value.allowSave + : allowSave // ignore: cast_nullable_to_non_nullable + as bool, + mediaSource: null == mediaSource + ? _value.mediaSource + : mediaSource // ignore: cast_nullable_to_non_nullable + as AppMediaSource, + albumNameController: null == albumNameController + ? _value.albumNameController + : albumNameController // ignore: cast_nullable_to_non_nullable + as TextEditingController, + googleAccount: freezed == googleAccount + ? _value.googleAccount + : googleAccount // ignore: cast_nullable_to_non_nullable + as GoogleSignInAccount?, + dropboxAccount: freezed == dropboxAccount + ? _value.dropboxAccount + : dropboxAccount // ignore: cast_nullable_to_non_nullable + as DropboxAccount?, + error: freezed == error ? _value.error : error, + ) as $Val); + } + + /// Create a copy of AddAlbumsState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $DropboxAccountCopyWith<$Res>? get dropboxAccount { + if (_value.dropboxAccount == null) { + return null; + } + + return $DropboxAccountCopyWith<$Res>(_value.dropboxAccount!, (value) { + return _then(_value.copyWith(dropboxAccount: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$AddAlbumsStateImplCopyWith<$Res> + implements $AddAlbumsStateCopyWith<$Res> { + factory _$$AddAlbumsStateImplCopyWith(_$AddAlbumsStateImpl value, + $Res Function(_$AddAlbumsStateImpl) then) = + __$$AddAlbumsStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {bool loading, + bool succeed, + bool allowSave, + AppMediaSource mediaSource, + TextEditingController albumNameController, + GoogleSignInAccount? googleAccount, + DropboxAccount? dropboxAccount, + Object? error}); + + @override + $DropboxAccountCopyWith<$Res>? get dropboxAccount; +} + +/// @nodoc +class __$$AddAlbumsStateImplCopyWithImpl<$Res> + extends _$AddAlbumsStateCopyWithImpl<$Res, _$AddAlbumsStateImpl> + implements _$$AddAlbumsStateImplCopyWith<$Res> { + __$$AddAlbumsStateImplCopyWithImpl( + _$AddAlbumsStateImpl _value, $Res Function(_$AddAlbumsStateImpl) _then) + : super(_value, _then); + + /// Create a copy of AddAlbumsState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? loading = null, + Object? succeed = null, + Object? allowSave = null, + Object? mediaSource = null, + Object? albumNameController = null, + Object? googleAccount = freezed, + Object? dropboxAccount = freezed, + Object? error = freezed, + }) { + return _then(_$AddAlbumsStateImpl( + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + succeed: null == succeed + ? _value.succeed + : succeed // ignore: cast_nullable_to_non_nullable + as bool, + allowSave: null == allowSave + ? _value.allowSave + : allowSave // ignore: cast_nullable_to_non_nullable + as bool, + mediaSource: null == mediaSource + ? _value.mediaSource + : mediaSource // ignore: cast_nullable_to_non_nullable + as AppMediaSource, + albumNameController: null == albumNameController + ? _value.albumNameController + : albumNameController // ignore: cast_nullable_to_non_nullable + as TextEditingController, + googleAccount: freezed == googleAccount + ? _value.googleAccount + : googleAccount // ignore: cast_nullable_to_non_nullable + as GoogleSignInAccount?, + dropboxAccount: freezed == dropboxAccount + ? _value.dropboxAccount + : dropboxAccount // ignore: cast_nullable_to_non_nullable + as DropboxAccount?, + error: freezed == error ? _value.error : error, + )); + } +} + +/// @nodoc + +class _$AddAlbumsStateImpl implements _AddAlbumsState { + const _$AddAlbumsStateImpl( + {this.loading = false, + this.succeed = false, + this.allowSave = false, + this.mediaSource = AppMediaSource.local, + required this.albumNameController, + this.googleAccount, + this.dropboxAccount, + this.error}); + + @override + @JsonKey() + final bool loading; + @override + @JsonKey() + final bool succeed; + @override + @JsonKey() + final bool allowSave; + @override + @JsonKey() + final AppMediaSource mediaSource; + @override + final TextEditingController albumNameController; + @override + final GoogleSignInAccount? googleAccount; + @override + final DropboxAccount? dropboxAccount; + @override + final Object? error; + + @override + String toString() { + return 'AddAlbumsState(loading: $loading, succeed: $succeed, allowSave: $allowSave, mediaSource: $mediaSource, albumNameController: $albumNameController, googleAccount: $googleAccount, dropboxAccount: $dropboxAccount, error: $error)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AddAlbumsStateImpl && + (identical(other.loading, loading) || other.loading == loading) && + (identical(other.succeed, succeed) || other.succeed == succeed) && + (identical(other.allowSave, allowSave) || + other.allowSave == allowSave) && + (identical(other.mediaSource, mediaSource) || + other.mediaSource == mediaSource) && + (identical(other.albumNameController, albumNameController) || + other.albumNameController == albumNameController) && + (identical(other.googleAccount, googleAccount) || + other.googleAccount == googleAccount) && + (identical(other.dropboxAccount, dropboxAccount) || + other.dropboxAccount == dropboxAccount) && + const DeepCollectionEquality().equals(other.error, error)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + loading, + succeed, + allowSave, + mediaSource, + albumNameController, + googleAccount, + dropboxAccount, + const DeepCollectionEquality().hash(error)); + + /// Create a copy of AddAlbumsState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AddAlbumsStateImplCopyWith<_$AddAlbumsStateImpl> get copyWith => + __$$AddAlbumsStateImplCopyWithImpl<_$AddAlbumsStateImpl>( + this, _$identity); +} + +abstract class _AddAlbumsState implements AddAlbumsState { + const factory _AddAlbumsState( + {final bool loading, + final bool succeed, + final bool allowSave, + final AppMediaSource mediaSource, + required final TextEditingController albumNameController, + final GoogleSignInAccount? googleAccount, + final DropboxAccount? dropboxAccount, + final Object? error}) = _$AddAlbumsStateImpl; + + @override + bool get loading; + @override + bool get succeed; + @override + bool get allowSave; + @override + AppMediaSource get mediaSource; + @override + TextEditingController get albumNameController; + @override + GoogleSignInAccount? get googleAccount; + @override + DropboxAccount? get dropboxAccount; + @override + Object? get error; + + /// Create a copy of AddAlbumsState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AddAlbumsStateImplCopyWith<_$AddAlbumsStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/app/lib/ui/flow/albums/albums_screen.dart b/app/lib/ui/flow/albums/albums_screen.dart new file mode 100644 index 00000000..7493de07 --- /dev/null +++ b/app/lib/ui/flow/albums/albums_screen.dart @@ -0,0 +1,156 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:style/animations/fade_in_switcher.dart'; +import 'package:style/buttons/action_button.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/indicators/circular_progress_indicator.dart'; +import '../../../components/action_sheet.dart'; +import '../../../components/app_page.dart'; +import '../../../components/app_sheet.dart'; +import '../../../components/error_screen.dart'; +import '../../../components/place_holder_screen.dart'; +import '../../../components/snack_bar.dart'; +import '../../../domain/extensions/context_extensions.dart'; +import '../../navigation/app_route.dart'; +import 'albums_view_notifier.dart'; +import 'component/album_item.dart'; + +class AlbumsScreen extends ConsumerStatefulWidget { + const AlbumsScreen({super.key}); + + @override + ConsumerState createState() => _AlbumsScreenState(); +} + +class _AlbumsScreenState extends ConsumerState { + late AlbumStateNotifier _notifier; + + @override + void initState() { + super.initState(); + _notifier = ref.read(albumStateNotifierProvider.notifier); + } + + void _observeError(BuildContext context) { + ref.listen( + albumStateNotifierProvider.select( + (value) => value.actionError, + ), (previous, error) { + if (error != null) { + showErrorSnackBar(context: context, error: error); + } + }); + } + + @override + Widget build(BuildContext context) { + _observeError(context); + return AppPage( + title: context.l10n.album_screen_title, + actions: [ + ActionButton( + onPressed: () async { + final res = await AddAlbumRoute().push(context); + if (res == true) { + _notifier.loadAlbums(); + } + }, + icon: Icon( + Icons.add, + color: context.colorScheme.textPrimary, + ), + ), + ], + body: FadeInSwitcher(child: _body(context: context)), + ); + } + + Widget _body({required BuildContext context}) { + final state = ref.watch(albumStateNotifierProvider); + + if (state.loading && state.albums.isEmpty) { + return const Center(child: AppCircularProgressIndicator()); + } else if (state.error != null) { + return ErrorScreen( + error: state.error!, + onRetryTap: _notifier.loadAlbums, + ); + } else if (state.albums.isEmpty) { + return PlaceHolderScreen( + icon: Icon( + CupertinoIcons.folder, + size: 100, + color: context.colorScheme.containerNormalOnSurface, + ), + title: context.l10n.empty_album_title, + message: context.l10n.empty_album_message, + ); + } + + return GridView( + padding: EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.9, + crossAxisSpacing: 8, + mainAxisSpacing: 16, + ), + children: state.albums + .map( + (album) => AlbumItem( + album: album, + media: state.medias[album.id], + onTap: () async { + await AlbumMediaListRoute( + albumId: album.id, + $extra: album, + ).push(context); + _notifier.loadAlbums(); + }, + onLongTap: () { + showAppSheet( + context: context, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppSheetAction( + title: context.l10n.common_edit, + onPressed: () async { + context.pop(); + final res = await AddAlbumRoute( + $extra: album, + ).push(context); + if (res == true) { + _notifier.loadAlbums(); + } + }, + icon: Icon( + Icons.edit_outlined, + size: 24, + color: context.colorScheme.textPrimary, + ), + ), + AppSheetAction( + title: context.l10n.common_delete, + onPressed: () { + context.pop(); + _notifier.deleteAlbum(album); + }, + icon: Icon( + CupertinoIcons.delete, + size: 24, + color: context.colorScheme.textPrimary, + ), + ), + ], + ), + ); + }, + ), + ) + .toList(), + ); + } +} diff --git a/app/lib/ui/flow/albums/albums_view_notifier.dart b/app/lib/ui/flow/albums/albums_view_notifier.dart new file mode 100644 index 00000000..8d1235f8 --- /dev/null +++ b/app/lib/ui/flow/albums/albums_view_notifier.dart @@ -0,0 +1,213 @@ +import 'package:data/errors/app_error.dart'; +import 'package:data/log/logger.dart'; +import 'package:data/models/album/album.dart'; +import 'package:data/models/dropbox/account/dropbox_account.dart'; +import 'package:data/models/media/media.dart'; +import 'package:data/services/auth_service.dart'; +import 'package:data/services/dropbox_services.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'; +import 'package:logger/logger.dart'; + +part 'albums_view_notifier.freezed.dart'; + +final albumStateNotifierProvider = + StateNotifierProvider.autoDispose((ref) { + final notifier = AlbumStateNotifier( + ref.read(localMediaServiceProvider), + ref.read(googleDriveServiceProvider), + ref.read(dropboxServiceProvider), + ref.read(loggerProvider), + ref.read(googleUserAccountProvider), + ref.read(AppPreferences.dropboxCurrentUserAccount), + ); + final googleDriveAccountSubscription = + ref.listen(googleUserAccountProvider, (p, c) { + notifier.onGoogleDriveAccountChange(c); + }); + final dropboxAccountSubscription = + ref.listen(AppPreferences.dropboxCurrentUserAccount, (p, c) { + notifier.onDropboxAccountChange(c); + }); + + ref.onDispose(() { + googleDriveAccountSubscription.close(); + dropboxAccountSubscription.close(); + }); + return notifier; +}); + +class AlbumStateNotifier extends StateNotifier { + final LocalMediaService _localMediaService; + final GoogleDriveService _googleDriveService; + final DropboxService _dropboxService; + final Logger _logger; + String? _backupFolderId; + + AlbumStateNotifier( + this._localMediaService, + this._googleDriveService, + this._dropboxService, + this._logger, + GoogleSignInAccount? googleAccount, + DropboxAccount? dropboxAccount, + ) : super( + AlbumsState( + googleAccount: googleAccount, + dropboxAccount: dropboxAccount, + ), + ) { + loadAlbums(); + } + + Future onGoogleDriveAccountChange( + GoogleSignInAccount? googleAccount, + ) async { + state = state.copyWith(googleAccount: googleAccount); + if (googleAccount != null) { + _backupFolderId = await _googleDriveService.getBackUpFolderId(); + loadAlbums(); + } else { + _backupFolderId = null; + state = state.copyWith( + albums: state.albums + .where((element) => element.source != AppMediaSource.googleDrive) + .toList(), + ); + } + } + + void onDropboxAccountChange(DropboxAccount? dropboxAccount) { + state = state.copyWith(dropboxAccount: dropboxAccount); + if (dropboxAccount != null) { + loadAlbums(); + } else { + state = state.copyWith( + albums: state.albums + .where((element) => element.source != AppMediaSource.dropbox) + .toList(), + ); + } + } + + /// Lookups for the first media in the album that is available + Future<({String id, AppMedia media})?> _getThumbnailMedia({ + required Album album, + required Future Function(String id) fetchMedia, + }) async { + if (album.medias.isEmpty) return null; + + for (final id in album.medias) { + final media = await fetchMedia.call(id); + if (media != null) { + return (id: album.id, media: media); + } + } + return null; + } + + Future loadAlbums() async { + if (state.loading) return; + + state = state.copyWith(loading: true, error: null); + try { + if (state.googleAccount != null) { + _backupFolderId ??= await _googleDriveService.getBackUpFolderId(); + } + final res = await Future.wait([ + _localMediaService.getAlbums(), + (state.googleAccount != null && _backupFolderId != null) + ? _googleDriveService.getAlbums(folderId: _backupFolderId!) + : Future.value([]), + (state.dropboxAccount != null) + ? _dropboxService.getAlbums() + : Future.value([]), + ]); + + state = state.copyWith( + albums: [...res[0], ...res[1], ...res[2]], + loading: false, + ); + + final medias = await Future.wait([ + for (Album album in res[0]) + _getThumbnailMedia( + album: album, + fetchMedia: (id) => _localMediaService.getMedia(id: id), + ), + for (final album in res[1]) + _getThumbnailMedia( + album: album, + fetchMedia: (id) => _googleDriveService.getMedia(id: id), + ), + for (final album in res[2]) + _getThumbnailMedia( + album: album, + fetchMedia: (id) => _dropboxService.getMedia(id: id), + ), + ]).then( + (value) => { + for (final item in value) + if (item != null) item.id: item.media, + }, + ); + + state = state.copyWith(medias: medias); + } catch (e, s) { + state = state.copyWith(loading: false, error: e); + _logger.e( + "AlbumStateNotifier: Error loading albums", + error: e, + stackTrace: s, + ); + } + } + + Future deleteAlbum(Album album) async { + try { + state = state.copyWith(actionError: null); + if (album.source == AppMediaSource.local) { + await _localMediaService.deleteAlbum(album.id); + } else if (album.source == AppMediaSource.googleDrive) { + _backupFolderId ??= await _googleDriveService.getBackUpFolderId(); + if (_backupFolderId == null) { + throw BackUpFolderNotFound(); + } + await _googleDriveService.removeAlbum( + folderId: _backupFolderId!, + id: album.id, + ); + } else if (album.source == AppMediaSource.dropbox) { + await _dropboxService.deleteAlbum(album.id); + } + state = state.copyWith( + albums: + state.albums.where((element) => element.id != album.id).toList(), + ); + } catch (e, s) { + state = state.copyWith(actionError: e); + _logger.e( + "AlbumStateNotifier: Error deleting album", + error: e, + stackTrace: s, + ); + } + } +} + +@freezed +class AlbumsState with _$AlbumsState { + const factory AlbumsState({ + @Default(false) bool loading, + @Default([]) List albums, + @Default({}) Map medias, + GoogleSignInAccount? googleAccount, + DropboxAccount? dropboxAccount, + Object? error, + Object? actionError, + }) = _AlbumsState; +} diff --git a/app/lib/ui/flow/albums/albums_view_notifier.freezed.dart b/app/lib/ui/flow/albums/albums_view_notifier.freezed.dart new file mode 100644 index 00000000..9fa76e8b --- /dev/null +++ b/app/lib/ui/flow/albums/albums_view_notifier.freezed.dart @@ -0,0 +1,302 @@ +// 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 'albums_view_notifier.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 _$AlbumsState { + bool get loading => throw _privateConstructorUsedError; + List get albums => throw _privateConstructorUsedError; + Map get medias => throw _privateConstructorUsedError; + GoogleSignInAccount? get googleAccount => throw _privateConstructorUsedError; + DropboxAccount? get dropboxAccount => throw _privateConstructorUsedError; + Object? get error => throw _privateConstructorUsedError; + Object? get actionError => throw _privateConstructorUsedError; + + /// Create a copy of AlbumsState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AlbumsStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AlbumsStateCopyWith<$Res> { + factory $AlbumsStateCopyWith( + AlbumsState value, $Res Function(AlbumsState) then) = + _$AlbumsStateCopyWithImpl<$Res, AlbumsState>; + @useResult + $Res call( + {bool loading, + List albums, + Map medias, + GoogleSignInAccount? googleAccount, + DropboxAccount? dropboxAccount, + Object? error, + Object? actionError}); + + $DropboxAccountCopyWith<$Res>? get dropboxAccount; +} + +/// @nodoc +class _$AlbumsStateCopyWithImpl<$Res, $Val extends AlbumsState> + implements $AlbumsStateCopyWith<$Res> { + _$AlbumsStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AlbumsState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? loading = null, + Object? albums = null, + Object? medias = null, + Object? googleAccount = freezed, + Object? dropboxAccount = freezed, + Object? error = freezed, + Object? actionError = freezed, + }) { + return _then(_value.copyWith( + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + albums: null == albums + ? _value.albums + : albums // ignore: cast_nullable_to_non_nullable + as List, + medias: null == medias + ? _value.medias + : medias // ignore: cast_nullable_to_non_nullable + as Map, + googleAccount: freezed == googleAccount + ? _value.googleAccount + : googleAccount // ignore: cast_nullable_to_non_nullable + as GoogleSignInAccount?, + dropboxAccount: freezed == dropboxAccount + ? _value.dropboxAccount + : dropboxAccount // ignore: cast_nullable_to_non_nullable + as DropboxAccount?, + error: freezed == error ? _value.error : error, + actionError: freezed == actionError ? _value.actionError : actionError, + ) as $Val); + } + + /// Create a copy of AlbumsState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $DropboxAccountCopyWith<$Res>? get dropboxAccount { + if (_value.dropboxAccount == null) { + return null; + } + + return $DropboxAccountCopyWith<$Res>(_value.dropboxAccount!, (value) { + return _then(_value.copyWith(dropboxAccount: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$AlbumsStateImplCopyWith<$Res> + implements $AlbumsStateCopyWith<$Res> { + factory _$$AlbumsStateImplCopyWith( + _$AlbumsStateImpl value, $Res Function(_$AlbumsStateImpl) then) = + __$$AlbumsStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {bool loading, + List albums, + Map medias, + GoogleSignInAccount? googleAccount, + DropboxAccount? dropboxAccount, + Object? error, + Object? actionError}); + + @override + $DropboxAccountCopyWith<$Res>? get dropboxAccount; +} + +/// @nodoc +class __$$AlbumsStateImplCopyWithImpl<$Res> + extends _$AlbumsStateCopyWithImpl<$Res, _$AlbumsStateImpl> + implements _$$AlbumsStateImplCopyWith<$Res> { + __$$AlbumsStateImplCopyWithImpl( + _$AlbumsStateImpl _value, $Res Function(_$AlbumsStateImpl) _then) + : super(_value, _then); + + /// Create a copy of AlbumsState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? loading = null, + Object? albums = null, + Object? medias = null, + Object? googleAccount = freezed, + Object? dropboxAccount = freezed, + Object? error = freezed, + Object? actionError = freezed, + }) { + return _then(_$AlbumsStateImpl( + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + albums: null == albums + ? _value._albums + : albums // ignore: cast_nullable_to_non_nullable + as List, + medias: null == medias + ? _value._medias + : medias // ignore: cast_nullable_to_non_nullable + as Map, + googleAccount: freezed == googleAccount + ? _value.googleAccount + : googleAccount // ignore: cast_nullable_to_non_nullable + as GoogleSignInAccount?, + dropboxAccount: freezed == dropboxAccount + ? _value.dropboxAccount + : dropboxAccount // ignore: cast_nullable_to_non_nullable + as DropboxAccount?, + error: freezed == error ? _value.error : error, + actionError: freezed == actionError ? _value.actionError : actionError, + )); + } +} + +/// @nodoc + +class _$AlbumsStateImpl implements _AlbumsState { + const _$AlbumsStateImpl( + {this.loading = false, + final List albums = const [], + final Map medias = const {}, + this.googleAccount, + this.dropboxAccount, + this.error, + this.actionError}) + : _albums = albums, + _medias = medias; + + @override + @JsonKey() + final bool loading; + final List _albums; + @override + @JsonKey() + List get albums { + if (_albums is EqualUnmodifiableListView) return _albums; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_albums); + } + + final Map _medias; + @override + @JsonKey() + Map get medias { + if (_medias is EqualUnmodifiableMapView) return _medias; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_medias); + } + + @override + final GoogleSignInAccount? googleAccount; + @override + final DropboxAccount? dropboxAccount; + @override + final Object? error; + @override + final Object? actionError; + + @override + String toString() { + return 'AlbumsState(loading: $loading, albums: $albums, medias: $medias, googleAccount: $googleAccount, dropboxAccount: $dropboxAccount, error: $error, actionError: $actionError)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AlbumsStateImpl && + (identical(other.loading, loading) || other.loading == loading) && + const DeepCollectionEquality().equals(other._albums, _albums) && + const DeepCollectionEquality().equals(other._medias, _medias) && + (identical(other.googleAccount, googleAccount) || + other.googleAccount == googleAccount) && + (identical(other.dropboxAccount, dropboxAccount) || + other.dropboxAccount == dropboxAccount) && + const DeepCollectionEquality().equals(other.error, error) && + const DeepCollectionEquality() + .equals(other.actionError, actionError)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + loading, + const DeepCollectionEquality().hash(_albums), + const DeepCollectionEquality().hash(_medias), + googleAccount, + dropboxAccount, + const DeepCollectionEquality().hash(error), + const DeepCollectionEquality().hash(actionError)); + + /// Create a copy of AlbumsState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AlbumsStateImplCopyWith<_$AlbumsStateImpl> get copyWith => + __$$AlbumsStateImplCopyWithImpl<_$AlbumsStateImpl>(this, _$identity); +} + +abstract class _AlbumsState implements AlbumsState { + const factory _AlbumsState( + {final bool loading, + final List albums, + final Map medias, + final GoogleSignInAccount? googleAccount, + final DropboxAccount? dropboxAccount, + final Object? error, + final Object? actionError}) = _$AlbumsStateImpl; + + @override + bool get loading; + @override + List get albums; + @override + Map get medias; + @override + GoogleSignInAccount? get googleAccount; + @override + DropboxAccount? get dropboxAccount; + @override + Object? get error; + @override + Object? get actionError; + + /// Create a copy of AlbumsState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AlbumsStateImplCopyWith<_$AlbumsStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/app/lib/ui/flow/albums/component/album_item.dart b/app/lib/ui/flow/albums/component/album_item.dart new file mode 100644 index 00000000..78cca3fc --- /dev/null +++ b/app/lib/ui/flow/albums/component/album_item.dart @@ -0,0 +1,98 @@ +import 'package:data/models/album/album.dart'; +import 'package:data/models/media/media.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:style/animations/fade_in_switcher.dart'; +import 'package:style/animations/on_tap_scale.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/text/app_text_style.dart'; +import '../../../../components/thumbnail_builder.dart'; +import '../../../../gen/assets.gen.dart'; + +class AlbumItem extends StatelessWidget { + final Album album; + final AppMedia? media; + final void Function() onTap; + final void Function() onLongTap; + + const AlbumItem({ + super.key, + required this.album, + required this.media, + required this.onTap, + required this.onLongTap, + }); + + @override + Widget build(BuildContext context) { + return OnTapScale( + onTap: onTap, + onLongTap: onLongTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: FadeInSwitcher( + child: media == null + ? Container( + height: double.infinity, + width: double.infinity, + decoration: BoxDecoration( + color: context.colorScheme.containerLowOnSurface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: context.colorScheme.outline, + ), + ), + child: Icon( + CupertinoIcons.folder, + size: 80, + color: context.colorScheme.containerHighOnSurface, + ), + ) + : AppMediaImage( + heroTag: "album${media.toString()}", + radius: 8, + media: media!, + size: Size(double.infinity, double.infinity), + ), + ), + ), + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + children: [ + if (album.source == AppMediaSource.dropbox) ...[ + SvgPicture.asset( + Assets.images.icDropbox, + width: 18, + height: 18, + ), + const SizedBox(width: 4), + ], + if (album.source == AppMediaSource.googleDrive) ...[ + SvgPicture.asset( + Assets.images.icGoogleDrive, + width: 18, + height: 18, + ), + const SizedBox(width: 4), + ], + Expanded( + child: Text( + album.name, + style: AppTextStyles.subtitle1.copyWith( + color: context.colorScheme.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/ui/flow/albums/media_list/album_media_list_screen.dart b/app/lib/ui/flow/albums/media_list/album_media_list_screen.dart new file mode 100644 index 00000000..a409b493 --- /dev/null +++ b/app/lib/ui/flow/albums/media_list/album_media_list_screen.dart @@ -0,0 +1,251 @@ +import 'package:data/models/album/album.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:style/animations/fade_in_switcher.dart'; +import 'package:style/buttons/action_button.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/indicators/circular_progress_indicator.dart'; +import '../../../../components/action_sheet.dart'; +import '../../../../components/app_media_thumbnail.dart'; +import '../../../../components/app_page.dart'; +import '../../../../components/app_sheet.dart'; +import '../../../../components/error_screen.dart'; +import '../../../../components/place_holder_screen.dart'; +import '../../../../components/selection_menu.dart'; +import '../../../../domain/extensions/context_extensions.dart'; +import '../../../../domain/extensions/widget_extensions.dart'; +import '../../../../gen/assets.gen.dart'; +import '../../../navigation/app_route.dart'; +import 'album_media_list_state_notifier.dart'; + +class AlbumMediaListScreen extends ConsumerStatefulWidget { + final Album album; + + const AlbumMediaListScreen({super.key, required this.album}); + + @override + ConsumerState createState() => + _AlbumMediaListScreenState(); +} + +class _AlbumMediaListScreenState extends ConsumerState { + late AutoDisposeStateNotifierProvider _provider; + late AlbumMediaListStateNotifier _notifier; + + @override + void initState() { + _provider = albumMediaListStateNotifierProvider(widget.album); + _notifier = ref.read(_provider.notifier); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(_provider); + return AppPage( + title: state.album.name, + actions: [ + ActionButton( + onPressed: () async { + showAppSheet( + context: context, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppSheetAction( + title: context.l10n.add_items_action_title, + onPressed: () async { + context.pop(); + final res = + await MediaSelectionRoute($extra: widget.album.source) + .push(context); + if (res != null && res is List) { + await _notifier.addMediaInAlbum(medias: res); + } + }, + icon: Icon( + Icons.add, + size: 24, + color: context.colorScheme.textPrimary, + ), + ), + AppSheetAction( + title: context.l10n.edit_album_action_title, + onPressed: () async { + context.pop(); + final res = await AddAlbumRoute($extra: state.album) + .push(context); + if (res == true) { + _notifier.loadAlbum(); + } + }, + icon: Icon( + Icons.edit_outlined, + size: 24, + color: context.colorScheme.textPrimary, + ), + ), + AppSheetAction( + title: context.l10n.delete_album_action_title, + onPressed: () async { + context.pop(); + _notifier.deleteAlbum(); + }, + icon: Icon( + CupertinoIcons.delete, + size: 24, + color: context.colorScheme.textPrimary, + ), + ), + ], + ), + ); + }, + icon: Icon( + Icons.more_vert_rounded, + color: context.colorScheme.textPrimary, + size: 24, + ), + ), + ], + body: FadeInSwitcher(child: _body(context: context, state: state)), + ); + } + + Widget _body({ + required BuildContext context, + required AlbumMediaListState state, + }) { + if (state.loading) { + return const Center(child: AppCircularProgressIndicator()); + } else if (state.error != null) { + return ErrorScreen( + error: state.error!, + onRetryTap: () => _notifier.loadMedia(reload: true), + ); + } else if (state.medias.isEmpty && state.addingMedia.isEmpty) { + return PlaceHolderScreen( + icon: SvgPicture.asset( + Assets.images.ilNoMediaFound, + width: 150, + ), + title: context.l10n.empty_album_media_list_title, + message: context.l10n.empty_album_media_list_message, + ); + } + return Column( + children: [ + Expanded( + child: CustomScrollView( + slivers: [ + SliverGrid.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: context.mediaQuerySize.width > 600 + ? context.mediaQuerySize.width ~/ 180 + : context.mediaQuerySize.width ~/ 100, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + ), + itemCount: (state.medias.length + state.addingMedia.length), + itemBuilder: (context, index) { + if (index >= + (state.medias.length + state.addingMedia.length) - 1) { + runPostFrame(() { + _notifier.loadMedia(); + }); + } + if (index < state.medias.length) { + return Opacity( + opacity: state.removingMedia.contains( + state.medias.keys.elementAt(index), + ) + ? 0.7 + : 1, + child: AppMediaThumbnail( + selected: state.selectedMedias + .contains(state.medias.keys.elementAt(index)), + onTap: () async { + if (state.selectedMedias.isNotEmpty) { + _notifier.toggleMediaSelection( + state.medias.keys.elementAt(index), + ); + return; + } + await MediaPreviewRoute( + $extra: MediaPreviewRouteData( + onLoadMore: _notifier.loadMedia, + heroTag: "album_media_list", + medias: state.medias.values.toList(), + startFrom: + state.medias.values.elementAt(index).id, + ), + ).push(context); + _notifier.loadMedia(reload: true); + }, + onLongTap: () { + _notifier.toggleMediaSelection( + state.medias.keys.elementAt(index), + ); + }, + heroTag: + "album_media_list${state.medias.values.elementAt(index).toString()}", + media: state.medias.values.elementAt(index), + ), + ); + } + + return Container( + width: double.infinity, + height: double.infinity, + color: context.colorScheme.containerNormalOnSurface, + child: const Center( + child: AppCircularProgressIndicator( + size: 22, + ), + ), + ); + }, + ), + if (state.loadingMore) + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(16), + child: AppCircularProgressIndicator( + size: 22, + ), + ), + ), + ], + ), + ), + SelectionMenu( + items: [ + SelectionMenuAction( + title: context.l10n.common_cancel, + icon: Icon( + Icons.close, + color: context.colorScheme.textPrimary, + size: 24, + ), + onTap: _notifier.clearSelection, + ), + SelectionMenuAction( + title: context.l10n.remove_item_action_title, + icon: Icon( + CupertinoIcons.delete, + color: context.colorScheme.textPrimary, + size: 24, + ), + onTap: _notifier.removeMediaFromAlbum, + ), + ], + show: state.selectedMedias.isNotEmpty, + ), + ], + ); + } +} diff --git a/app/lib/ui/flow/albums/media_list/album_media_list_state_notifier.dart b/app/lib/ui/flow/albums/media_list/album_media_list_state_notifier.dart new file mode 100644 index 00000000..e0e77e8e --- /dev/null +++ b/app/lib/ui/flow/albums/media_list/album_media_list_state_notifier.dart @@ -0,0 +1,353 @@ +import 'package:collection/collection.dart'; +import 'package:data/errors/app_error.dart'; +import 'package:data/log/logger.dart'; +import 'package:data/models/album/album.dart'; +import 'package:data/models/media/media.dart'; +import 'package:data/services/dropbox_services.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'; +import 'package:logger/logger.dart'; + +part 'album_media_list_state_notifier.freezed.dart'; + +final albumMediaListStateNotifierProvider = StateNotifierProvider.autoDispose + .family( + (ref, state) => AlbumMediaListStateNotifier( + state, + ref.read(localMediaServiceProvider), + ref.read(googleDriveServiceProvider), + ref.read(dropboxServiceProvider), + ref.read(loggerProvider), + ), +); + +class AlbumMediaListStateNotifier extends StateNotifier { + final LocalMediaService _localMediaService; + final GoogleDriveService _googleDriveService; + final DropboxService _dropboxService; + final Logger _logger; + + int _loadedMediaCount = 0; + String? _backupFolderId; + + AlbumMediaListStateNotifier( + Album album, + this._localMediaService, + this._googleDriveService, + this._dropboxService, + this._logger, + ) : super( + AlbumMediaListState(album: album), + ) { + loadMedia(); + } + + Future> loadMedia({bool reload = false}) async { + try { + if (state.loading || + state.loadingMore || + (!reload && state.album.medias.length <= _loadedMediaCount)) { + return state.medias.values.toList(); + } + + state = state.copyWith( + loading: state.medias.isEmpty, + loadingMore: state.medias.isNotEmpty && !reload, + error: null, + actionError: null, + ); + + final moreMediaIds = state.album.medias + .sublist( + (reload ? 0 : _loadedMediaCount), + ) + .take(_loadedMediaCount + (reload ? _loadedMediaCount : 30)) + .toList(); + + Map medias = {}; + + if (state.album.source == AppMediaSource.local) { + final res = await Future.wait( + moreMediaIds.map((id) => _localMediaService.getMedia(id: id)), + ).then((value) => value.nonNulls.toList()); + medias = {for (final item in res) item.id: item}; + } else if (state.album.source == AppMediaSource.googleDrive) { + final res = await Future.wait( + moreMediaIds.map((id) => _googleDriveService.getMedia(id: id)), + ).then((value) => value.nonNulls.toList()); + medias = {for (final item in res) item.driveMediaRefId!: item}; + } else if (state.album.source == AppMediaSource.dropbox) { + final res = await Future.wait( + moreMediaIds.map((id) => _dropboxService.getMedia(id: id)), + ).then((value) => value.nonNulls.toList()); + medias = {for (final item in res) item.dropboxMediaRefId!: item}; + } + + state = state.copyWith( + medias: reload ? medias : {...state.medias, ...medias}, + loading: false, + loadingMore: false, + ); + + _loadedMediaCount = reload + ? moreMediaIds.length + : _loadedMediaCount + moreMediaIds.length; + + // remove media-ids from album which is deleted from source + final manuallyRemovedMedia = moreMediaIds + .where( + (element) => !medias.keys.contains(element), + ) + .toList(); + + if (manuallyRemovedMedia.isNotEmpty) { + removeMediaFromAlbum(removeMediaList: manuallyRemovedMedia); + } + } catch (e, s) { + state = state.copyWith( + loading: false, + loadingMore: false, + error: state.medias.isEmpty ? e : null, + actionError: state.medias.isNotEmpty ? e : null, + ); + _logger.e( + "AlbumMediaListStateNotifier: Error loading medias", + error: e, + stackTrace: s, + ); + } + return state.medias.values.toList(); + } + + Future loadAlbum() async { + state = state.copyWith(actionError: null); + List albums = []; + try { + if (state.album.source == AppMediaSource.googleDrive) { + _backupFolderId ??= await _googleDriveService.getBackUpFolderId(); + if (_backupFolderId == null) { + throw BackUpFolderNotFound(); + } + albums = + await _googleDriveService.getAlbums(folderId: _backupFolderId!); + } else if (state.album.source == AppMediaSource.dropbox) { + albums = await _dropboxService.getAlbums(); + } else { + albums = await _localMediaService.getAlbums(); + } + + state = state.copyWith( + album: albums + .firstWhereOrNull((element) => element.id == state.album.id) ?? + state.album, + ); + } catch (e, s) { + state = state.copyWith(actionError: e); + _logger.e( + "AlbumMediaListStateNotifier: Error loading album", + error: e, + stackTrace: s, + ); + } + } + + Future deleteAlbum() async { + try { + state = state.copyWith(actionError: null); + if (state.album.source == AppMediaSource.local) { + await _localMediaService.deleteAlbum(state.album.id); + } else if (state.album.source == AppMediaSource.googleDrive) { + _backupFolderId ??= await _googleDriveService.getBackUpFolderId(); + if (_backupFolderId == null) { + throw BackUpFolderNotFound(); + } + await _googleDriveService.removeAlbum( + folderId: _backupFolderId!, + id: state.album.id, + ); + } else if (state.album.source == AppMediaSource.dropbox) { + await _dropboxService.deleteAlbum(state.album.id); + } + state = state.copyWith( + deleteAlbumSuccess: true, + ); + } catch (e, s) { + state = state.copyWith(actionError: e); + _logger.e( + "AlbumMediaListStateNotifier: Error deleting album", + error: e, + stackTrace: s, + ); + } + } + + Future addMediaInAlbum({ + required List medias, + }) async { + state = state.copyWith( + actionError: null, + addingMedia: [...state.addingMedia, ...medias], + ); + try { + //Remove duplicate media ids + final updatedMedias = {...state.album.medias, ...medias}.toList(); + + Map moreMedia = {}; + + if (state.album.source == AppMediaSource.local) { + await _localMediaService.updateAlbum( + state.album.copyWith(medias: updatedMedias), + ); + + final res = await Future.wait( + medias.map((id) => _localMediaService.getMedia(id: id)), + ).then((value) => value.nonNulls.toList()); + + moreMedia = {for (final item in res) item.id: item}; + } else if (state.album.source == AppMediaSource.googleDrive) { + _backupFolderId ??= await _googleDriveService.getBackUpFolderId(); + if (_backupFolderId == null) { + throw BackUpFolderNotFound(); + } + await _googleDriveService.updateAlbum( + folderId: _backupFolderId!, + album: state.album.copyWith(medias: updatedMedias), + ); + final res = await Future.wait( + medias.map((id) => _googleDriveService.getMedia(id: id)), + ).then((value) => value.nonNulls.toList()); + + moreMedia = {for (final item in res) item.driveMediaRefId!: item}; + } else if (state.album.source == AppMediaSource.dropbox) { + await _dropboxService.updateAlbum( + state.album.copyWith(medias: updatedMedias), + ); + final res = await Future.wait( + medias.map((id) => _dropboxService.getMedia(id: id)), + ).then((value) => value.nonNulls.toList()); + moreMedia = {for (final item in res) item.dropboxMediaRefId!: item}; + } + + state = state.copyWith( + addingMedia: state.addingMedia + .where( + (element) => !medias.contains(element), + ) + .toList(), + medias: {...state.medias, ...moreMedia}, + ); + } catch (e, s) { + state = state.copyWith( + actionError: e, + addingMedia: state.addingMedia + .where( + (element) => !medias.contains(element), + ) + .toList(), + ); + _logger.e( + "AlbumMediaListStateNotifier: Error while adding media", + error: e, + stackTrace: s, + ); + } + } + + Future removeMediaFromAlbum({List? removeMediaList}) async { + final medias = removeMediaList ?? state.selectedMedias.toList(); + try { + state = state.copyWith( + actionError: null, + selectedMedias: + removeMediaList == null ? [] : state.selectedMedias.toList(), + removingMedia: [...state.removingMedia, ...medias], + ); + + final updatedMedias = state.album.medias + .toSet() + .where( + (element) => !medias.contains(element), + ) + .toList(); + if (state.album.source == AppMediaSource.local) { + await _localMediaService.updateAlbum( + state.album.copyWith(medias: updatedMedias), + ); + } else if (state.album.source == AppMediaSource.googleDrive) { + _backupFolderId ??= await _googleDriveService.getBackUpFolderId(); + if (_backupFolderId == null) { + throw BackUpFolderNotFound(); + } + await _googleDriveService.updateAlbum( + folderId: _backupFolderId!, + album: state.album.copyWith(medias: updatedMedias), + ); + } else if (state.album.source == AppMediaSource.dropbox) { + await _dropboxService.updateAlbum( + state.album.copyWith(medias: updatedMedias), + ); + } + + state = state.copyWith( + removingMedia: state.removingMedia + .where( + (element) => !medias.contains(element), + ) + .toList(), + medias: Map.fromEntries( + state.medias.entries.where( + (element) => !medias.contains(element.key), + ), + ), + album: state.album.copyWith(medias: updatedMedias), + ); + } catch (e, s) { + state = state.copyWith( + actionError: e, + removingMedia: state.removingMedia + .where( + (element) => !medias.contains(element), + ) + .toList(), + ); + _logger.e( + "AlbumMediaListStateNotifier: Error while removing media", + error: e, + stackTrace: s, + ); + } + } + + void toggleMediaSelection(String id) { + if (state.selectedMedias.contains(id)) { + state = state.copyWith( + selectedMedias: + state.selectedMedias.where((element) => element != id).toList(), + ); + } else { + state = state.copyWith(selectedMedias: [...state.selectedMedias, id]); + } + } + + void clearSelection() { + state = state.copyWith(selectedMedias: []); + } +} + +@freezed +class AlbumMediaListState with _$AlbumMediaListState { + const factory AlbumMediaListState({ + @Default({}) Map medias, + @Default([]) List selectedMedias, + required Album album, + @Default(false) bool loading, + @Default(false) bool loadingMore, + @Default([]) List addingMedia, + @Default([]) List removingMedia, + @Default(false) bool deleteAlbumSuccess, + Object? error, + Object? actionError, + }) = _AlbumMediaListState; +} diff --git a/app/lib/ui/flow/albums/media_list/album_media_list_state_notifier.freezed.dart b/app/lib/ui/flow/albums/media_list/album_media_list_state_notifier.freezed.dart new file mode 100644 index 00000000..18132153 --- /dev/null +++ b/app/lib/ui/flow/albums/media_list/album_media_list_state_notifier.freezed.dart @@ -0,0 +1,383 @@ +// 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 'album_media_list_state_notifier.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 _$AlbumMediaListState { + Map get medias => throw _privateConstructorUsedError; + List get selectedMedias => throw _privateConstructorUsedError; + Album get album => throw _privateConstructorUsedError; + bool get loading => throw _privateConstructorUsedError; + bool get loadingMore => throw _privateConstructorUsedError; + List get addingMedia => throw _privateConstructorUsedError; + List get removingMedia => throw _privateConstructorUsedError; + bool get deleteAlbumSuccess => throw _privateConstructorUsedError; + Object? get error => throw _privateConstructorUsedError; + Object? get actionError => throw _privateConstructorUsedError; + + /// Create a copy of AlbumMediaListState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AlbumMediaListStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AlbumMediaListStateCopyWith<$Res> { + factory $AlbumMediaListStateCopyWith( + AlbumMediaListState value, $Res Function(AlbumMediaListState) then) = + _$AlbumMediaListStateCopyWithImpl<$Res, AlbumMediaListState>; + @useResult + $Res call( + {Map medias, + List selectedMedias, + Album album, + bool loading, + bool loadingMore, + List addingMedia, + List removingMedia, + bool deleteAlbumSuccess, + Object? error, + Object? actionError}); + + $AlbumCopyWith<$Res> get album; +} + +/// @nodoc +class _$AlbumMediaListStateCopyWithImpl<$Res, $Val extends AlbumMediaListState> + implements $AlbumMediaListStateCopyWith<$Res> { + _$AlbumMediaListStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AlbumMediaListState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? medias = null, + Object? selectedMedias = null, + Object? album = null, + Object? loading = null, + Object? loadingMore = null, + Object? addingMedia = null, + Object? removingMedia = null, + Object? deleteAlbumSuccess = null, + Object? error = freezed, + Object? actionError = freezed, + }) { + return _then(_value.copyWith( + medias: null == medias + ? _value.medias + : medias // ignore: cast_nullable_to_non_nullable + as Map, + selectedMedias: null == selectedMedias + ? _value.selectedMedias + : selectedMedias // ignore: cast_nullable_to_non_nullable + as List, + album: null == album + ? _value.album + : album // ignore: cast_nullable_to_non_nullable + as Album, + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + loadingMore: null == loadingMore + ? _value.loadingMore + : loadingMore // ignore: cast_nullable_to_non_nullable + as bool, + addingMedia: null == addingMedia + ? _value.addingMedia + : addingMedia // ignore: cast_nullable_to_non_nullable + as List, + removingMedia: null == removingMedia + ? _value.removingMedia + : removingMedia // ignore: cast_nullable_to_non_nullable + as List, + deleteAlbumSuccess: null == deleteAlbumSuccess + ? _value.deleteAlbumSuccess + : deleteAlbumSuccess // ignore: cast_nullable_to_non_nullable + as bool, + error: freezed == error ? _value.error : error, + actionError: freezed == actionError ? _value.actionError : actionError, + ) as $Val); + } + + /// Create a copy of AlbumMediaListState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AlbumCopyWith<$Res> get album { + return $AlbumCopyWith<$Res>(_value.album, (value) { + return _then(_value.copyWith(album: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$AlbumMediaListStateImplCopyWith<$Res> + implements $AlbumMediaListStateCopyWith<$Res> { + factory _$$AlbumMediaListStateImplCopyWith(_$AlbumMediaListStateImpl value, + $Res Function(_$AlbumMediaListStateImpl) then) = + __$$AlbumMediaListStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {Map medias, + List selectedMedias, + Album album, + bool loading, + bool loadingMore, + List addingMedia, + List removingMedia, + bool deleteAlbumSuccess, + Object? error, + Object? actionError}); + + @override + $AlbumCopyWith<$Res> get album; +} + +/// @nodoc +class __$$AlbumMediaListStateImplCopyWithImpl<$Res> + extends _$AlbumMediaListStateCopyWithImpl<$Res, _$AlbumMediaListStateImpl> + implements _$$AlbumMediaListStateImplCopyWith<$Res> { + __$$AlbumMediaListStateImplCopyWithImpl(_$AlbumMediaListStateImpl _value, + $Res Function(_$AlbumMediaListStateImpl) _then) + : super(_value, _then); + + /// Create a copy of AlbumMediaListState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? medias = null, + Object? selectedMedias = null, + Object? album = null, + Object? loading = null, + Object? loadingMore = null, + Object? addingMedia = null, + Object? removingMedia = null, + Object? deleteAlbumSuccess = null, + Object? error = freezed, + Object? actionError = freezed, + }) { + return _then(_$AlbumMediaListStateImpl( + medias: null == medias + ? _value._medias + : medias // ignore: cast_nullable_to_non_nullable + as Map, + selectedMedias: null == selectedMedias + ? _value._selectedMedias + : selectedMedias // ignore: cast_nullable_to_non_nullable + as List, + album: null == album + ? _value.album + : album // ignore: cast_nullable_to_non_nullable + as Album, + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + loadingMore: null == loadingMore + ? _value.loadingMore + : loadingMore // ignore: cast_nullable_to_non_nullable + as bool, + addingMedia: null == addingMedia + ? _value._addingMedia + : addingMedia // ignore: cast_nullable_to_non_nullable + as List, + removingMedia: null == removingMedia + ? _value._removingMedia + : removingMedia // ignore: cast_nullable_to_non_nullable + as List, + deleteAlbumSuccess: null == deleteAlbumSuccess + ? _value.deleteAlbumSuccess + : deleteAlbumSuccess // ignore: cast_nullable_to_non_nullable + as bool, + error: freezed == error ? _value.error : error, + actionError: freezed == actionError ? _value.actionError : actionError, + )); + } +} + +/// @nodoc + +class _$AlbumMediaListStateImpl implements _AlbumMediaListState { + const _$AlbumMediaListStateImpl( + {final Map medias = const {}, + final List selectedMedias = const [], + required this.album, + this.loading = false, + this.loadingMore = false, + final List addingMedia = const [], + final List removingMedia = const [], + this.deleteAlbumSuccess = false, + this.error, + this.actionError}) + : _medias = medias, + _selectedMedias = selectedMedias, + _addingMedia = addingMedia, + _removingMedia = removingMedia; + + final Map _medias; + @override + @JsonKey() + Map get medias { + if (_medias is EqualUnmodifiableMapView) return _medias; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_medias); + } + + final List _selectedMedias; + @override + @JsonKey() + List get selectedMedias { + if (_selectedMedias is EqualUnmodifiableListView) return _selectedMedias; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_selectedMedias); + } + + @override + final Album album; + @override + @JsonKey() + final bool loading; + @override + @JsonKey() + final bool loadingMore; + final List _addingMedia; + @override + @JsonKey() + List get addingMedia { + if (_addingMedia is EqualUnmodifiableListView) return _addingMedia; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_addingMedia); + } + + final List _removingMedia; + @override + @JsonKey() + List get removingMedia { + if (_removingMedia is EqualUnmodifiableListView) return _removingMedia; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_removingMedia); + } + + @override + @JsonKey() + final bool deleteAlbumSuccess; + @override + final Object? error; + @override + final Object? actionError; + + @override + String toString() { + return 'AlbumMediaListState(medias: $medias, selectedMedias: $selectedMedias, album: $album, loading: $loading, loadingMore: $loadingMore, addingMedia: $addingMedia, removingMedia: $removingMedia, deleteAlbumSuccess: $deleteAlbumSuccess, error: $error, actionError: $actionError)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AlbumMediaListStateImpl && + const DeepCollectionEquality().equals(other._medias, _medias) && + const DeepCollectionEquality() + .equals(other._selectedMedias, _selectedMedias) && + (identical(other.album, album) || other.album == album) && + (identical(other.loading, loading) || other.loading == loading) && + (identical(other.loadingMore, loadingMore) || + other.loadingMore == loadingMore) && + const DeepCollectionEquality() + .equals(other._addingMedia, _addingMedia) && + const DeepCollectionEquality() + .equals(other._removingMedia, _removingMedia) && + (identical(other.deleteAlbumSuccess, deleteAlbumSuccess) || + other.deleteAlbumSuccess == deleteAlbumSuccess) && + const DeepCollectionEquality().equals(other.error, error) && + const DeepCollectionEquality() + .equals(other.actionError, actionError)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(_medias), + const DeepCollectionEquality().hash(_selectedMedias), + album, + loading, + loadingMore, + const DeepCollectionEquality().hash(_addingMedia), + const DeepCollectionEquality().hash(_removingMedia), + deleteAlbumSuccess, + const DeepCollectionEquality().hash(error), + const DeepCollectionEquality().hash(actionError)); + + /// Create a copy of AlbumMediaListState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AlbumMediaListStateImplCopyWith<_$AlbumMediaListStateImpl> get copyWith => + __$$AlbumMediaListStateImplCopyWithImpl<_$AlbumMediaListStateImpl>( + this, _$identity); +} + +abstract class _AlbumMediaListState implements AlbumMediaListState { + const factory _AlbumMediaListState( + {final Map medias, + final List selectedMedias, + required final Album album, + final bool loading, + final bool loadingMore, + final List addingMedia, + final List removingMedia, + final bool deleteAlbumSuccess, + final Object? error, + final Object? actionError}) = _$AlbumMediaListStateImpl; + + @override + Map get medias; + @override + List get selectedMedias; + @override + Album get album; + @override + bool get loading; + @override + bool get loadingMore; + @override + List get addingMedia; + @override + List get removingMedia; + @override + bool get deleteAlbumSuccess; + @override + Object? get error; + @override + Object? get actionError; + + /// Create a copy of AlbumMediaListState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AlbumMediaListStateImplCopyWith<_$AlbumMediaListStateImpl> get copyWith => + throw _privateConstructorUsedError; +} 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 c47c341f..0c0efab2 100644 --- a/app/lib/ui/flow/home/components/app_media_item.dart +++ b/app/lib/ui/flow/home/components/app_media_item.dart @@ -13,6 +13,7 @@ import '../../../../gen/assets.gen.dart'; class AppMediaItem extends StatelessWidget { final AppMedia media; + final String heroTag; final void Function()? onTap; final void Function()? onLongTap; final bool isSelected; @@ -22,6 +23,7 @@ class AppMediaItem extends StatelessWidget { const AppMediaItem({ super.key, required this.media, + required this.heroTag, this.onTap, this.onLongTap, this.isSelected = false, @@ -46,7 +48,7 @@ class AppMediaItem extends StatelessWidget { radius: isSelected ? 4 : 0, size: constraints.biggest, media: media, - heroTag: media, + heroTag: heroTag, ), ), if (media.type.isVideo) _videoDuration(context), diff --git a/app/lib/ui/flow/home/components/multi_selection_done_button.dart b/app/lib/ui/flow/home/components/multi_selection_done_button.dart index da582abb..6123a4c8 100644 --- a/app/lib/ui/flow/home/components/multi_selection_done_button.dart +++ b/app/lib/ui/flow/home/components/multi_selection_done_button.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:data/models/media/media_extension.dart'; import '../../../../components/app_dialog.dart'; +import '../../../../components/selection_menu.dart'; import '../../../../domain/extensions/context_extensions.dart'; import '../../../../gen/assets.gen.dart'; import '../home_screen_view_model.dart'; @@ -12,11 +13,9 @@ import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; import 'package:style/extensions/context_extensions.dart'; -import '../../../../components/action_sheet.dart'; -import '../../../../components/app_sheet.dart'; -class MultiSelectionDoneButton extends ConsumerWidget { - const MultiSelectionDoneButton({super.key}); +class HomeSelectionMenu extends ConsumerWidget { + const HomeSelectionMenu({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -30,69 +29,67 @@ class MultiSelectionDoneButton extends ConsumerWidget { ), ); - return FloatingActionButton( - elevation: 3, - backgroundColor: context.colorScheme.primary, - onPressed: () { - showAppSheet( - context: context, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (state.selectedMedias.values.any( - (element) => - !element.sources.contains(AppMediaSource.googleDrive) && - element.sources.contains(AppMediaSource.local) && - state.googleAccount != null, - )) - _uploadToGoogleDriveAction(context, ref), - if (state.selectedMedias.values - .any((element) => element.isGoogleDriveStored) && - state.googleAccount != null) - _downloadFromGoogleDriveAction(context, ref), - if (state.selectedMedias.values.any( - (element) => - element.sources.contains(AppMediaSource.googleDrive), - ) && - state.googleAccount != null) - _deleteMediaFromGoogleDriveAction(context, ref), - if (state.selectedMedias.values.any( - (element) => - !element.sources.contains(AppMediaSource.dropbox) && - element.sources.contains(AppMediaSource.local) && - state.dropboxAccount != null, - )) - _uploadToDropboxAction(context, ref), - if (state.selectedMedias.values - .any((element) => element.isDropboxStored) && - state.dropboxAccount != null) - _downloadFromDropboxAction(context, ref), - if (state.selectedMedias.values.any( - (element) => - element.sources.contains(AppMediaSource.dropbox), - ) && - state.dropboxAccount != null) - _deleteMediaFromDropboxAction(context, ref), - if (state.selectedMedias.values.any( - (element) => element.sources.contains(AppMediaSource.local), - )) - _deleteFromDevice(context, ref), - if (state.selectedMedias.values - .any((element) => element.isLocalStored)) - _shareAction(context, state.selectedMedias), - ], - ), - ); - }, - child: Icon( - CupertinoIcons.checkmark_alt, - color: context.colorScheme.onPrimary, + return SelectionMenu( + useSystemPadding: false, + items: [ + _clearSelectionAction(context, ref), + if (state.selectedMedias.values.any( + (element) => + !element.sources.contains(AppMediaSource.googleDrive) && + element.sources.contains(AppMediaSource.local) && + state.googleAccount != null, + )) + _uploadToGoogleDriveAction(context, ref), + if (state.selectedMedias.values + .any((element) => element.isGoogleDriveStored) && + state.googleAccount != null) + _downloadFromGoogleDriveAction(context, ref), + if (state.selectedMedias.values.any( + (element) => element.sources.contains(AppMediaSource.googleDrive), + ) && + state.googleAccount != null) + _deleteMediaFromGoogleDriveAction(context, ref), + if (state.selectedMedias.values.any( + (element) => + !element.sources.contains(AppMediaSource.dropbox) && + element.sources.contains(AppMediaSource.local) && + state.dropboxAccount != null, + )) + _uploadToDropboxAction(context, ref), + if (state.selectedMedias.values + .any((element) => element.isDropboxStored) && + state.dropboxAccount != null) + _downloadFromDropboxAction(context, ref), + if (state.selectedMedias.values.any( + (element) => element.sources.contains(AppMediaSource.dropbox), + ) && + state.dropboxAccount != null) + _deleteMediaFromDropboxAction(context, ref), + if (state.selectedMedias.values.any( + (element) => element.sources.contains(AppMediaSource.local), + )) + _deleteFromDevice(context, ref), + if (state.selectedMedias.values.any((element) => element.isLocalStored)) + _shareAction(context, state.selectedMedias, ref), + ], + show: state.selectedMedias.isNotEmpty, + ); + } + + Widget _clearSelectionAction(BuildContext context, WidgetRef ref) { + return SelectionMenuAction( + title: context.l10n.common_cancel, + icon: Icon( + Icons.close, + color: context.colorScheme.textPrimary, + size: 22, ), + onTap: ref.read(homeViewStateNotifier.notifier).clearSelection, ); } Widget _uploadToGoogleDriveAction(BuildContext context, WidgetRef ref) { - return AppSheetAction( + return SelectionMenuAction( icon: Stack( alignment: Alignment.bottomRight, children: [ @@ -112,8 +109,7 @@ class MultiSelectionDoneButton extends ConsumerWidget { ], ), title: context.l10n.upload_to_google_drive_title, - onPressed: () { - context.pop(); + onTap: () { showAppAlertDialog( context: context, title: context.l10n.upload_to_google_drive_title, @@ -128,8 +124,8 @@ class MultiSelectionDoneButton extends ConsumerWidget { AppAlertAction( title: context.l10n.common_upload, onPressed: () { - ref.read(homeViewStateNotifier.notifier).uploadToGoogleDrive(); context.pop(); + ref.read(homeViewStateNotifier.notifier).uploadToGoogleDrive(); }, ), ], @@ -139,7 +135,7 @@ class MultiSelectionDoneButton extends ConsumerWidget { } Widget _downloadFromGoogleDriveAction(BuildContext context, WidgetRef ref) { - return AppSheetAction( + return SelectionMenuAction( icon: Stack( alignment: Alignment.bottomRight, children: [ @@ -159,8 +155,7 @@ class MultiSelectionDoneButton extends ConsumerWidget { ], ), title: context.l10n.download_from_google_drive_title, - onPressed: () async { - context.pop(); + onTap: () async { showAppAlertDialog( context: context, title: context.l10n.download_from_google_drive_title, @@ -175,10 +170,10 @@ class MultiSelectionDoneButton extends ConsumerWidget { AppAlertAction( title: context.l10n.common_download, onPressed: () { + context.pop(); ref .read(homeViewStateNotifier.notifier) .downloadFromGoogleDrive(); - context.pop(); }, ), ], @@ -191,7 +186,7 @@ class MultiSelectionDoneButton extends ConsumerWidget { BuildContext context, WidgetRef ref, ) { - return AppSheetAction( + return SelectionMenuAction( icon: Stack( alignment: Alignment.bottomRight, children: [ @@ -211,8 +206,7 @@ class MultiSelectionDoneButton extends ConsumerWidget { ], ), title: context.l10n.delete_from_google_drive_title, - onPressed: () { - context.pop(); + onTap: () { showAppAlertDialog( context: context, title: context.l10n.delete_from_google_drive_title, @@ -228,10 +222,10 @@ class MultiSelectionDoneButton extends ConsumerWidget { isDestructiveAction: true, title: context.l10n.common_delete, onPressed: () { + context.pop(); ref .read(homeViewStateNotifier.notifier) .deleteGoogleDriveMedias(); - context.pop(); }, ), ], @@ -241,7 +235,7 @@ class MultiSelectionDoneButton extends ConsumerWidget { } Widget _uploadToDropboxAction(BuildContext context, WidgetRef ref) { - return AppSheetAction( + return SelectionMenuAction( icon: Stack( alignment: Alignment.bottomRight, children: [ @@ -261,8 +255,7 @@ class MultiSelectionDoneButton extends ConsumerWidget { ], ), title: context.l10n.upload_to_dropbox_title, - onPressed: () { - context.pop(); + onTap: () { showAppAlertDialog( context: context, title: context.l10n.upload_to_dropbox_title, @@ -277,8 +270,8 @@ class MultiSelectionDoneButton extends ConsumerWidget { AppAlertAction( title: context.l10n.common_upload, onPressed: () { - ref.read(homeViewStateNotifier.notifier).uploadToDropbox(); context.pop(); + ref.read(homeViewStateNotifier.notifier).uploadToDropbox(); }, ), ], @@ -288,7 +281,7 @@ class MultiSelectionDoneButton extends ConsumerWidget { } Widget _downloadFromDropboxAction(BuildContext context, WidgetRef ref) { - return AppSheetAction( + return SelectionMenuAction( icon: Stack( alignment: Alignment.bottomRight, children: [ @@ -308,8 +301,7 @@ class MultiSelectionDoneButton extends ConsumerWidget { ], ), title: context.l10n.download_from_dropbox_title, - onPressed: () async { - context.pop(); + onTap: () async { showAppAlertDialog( context: context, title: context.l10n.download_from_dropbox_title, @@ -324,8 +316,8 @@ class MultiSelectionDoneButton extends ConsumerWidget { AppAlertAction( title: context.l10n.common_download, onPressed: () { - ref.read(homeViewStateNotifier.notifier).downloadFromDropbox(); context.pop(); + ref.read(homeViewStateNotifier.notifier).downloadFromDropbox(); }, ), ], @@ -335,7 +327,7 @@ class MultiSelectionDoneButton extends ConsumerWidget { } Widget _deleteMediaFromDropboxAction(BuildContext context, WidgetRef ref) { - return AppSheetAction( + return SelectionMenuAction( icon: Stack( alignment: Alignment.bottomRight, children: [ @@ -355,8 +347,7 @@ class MultiSelectionDoneButton extends ConsumerWidget { ], ), title: context.l10n.delete_from_dropbox_title, - onPressed: () { - context.pop(); + onTap: () { showAppAlertDialog( context: context, title: context.l10n.delete_from_dropbox_title, @@ -372,8 +363,8 @@ class MultiSelectionDoneButton extends ConsumerWidget { isDestructiveAction: true, title: context.l10n.common_delete, onPressed: () { - ref.read(homeViewStateNotifier.notifier).deleteDropboxMedias(); context.pop(); + ref.read(homeViewStateNotifier.notifier).deleteDropboxMedias(); }, ), ], @@ -383,14 +374,13 @@ class MultiSelectionDoneButton extends ConsumerWidget { } Widget _deleteFromDevice(BuildContext context, WidgetRef ref) { - return AppSheetAction( + return SelectionMenuAction( icon: const Icon( CupertinoIcons.delete, size: 24, ), title: context.l10n.delete_from_device_title, - onPressed: () { - context.pop(); + onTap: () { showAppAlertDialog( context: context, title: context.l10n.delete_from_device_title, @@ -406,8 +396,8 @@ class MultiSelectionDoneButton extends ConsumerWidget { isDestructiveAction: true, title: context.l10n.common_delete, onPressed: () { - ref.read(homeViewStateNotifier.notifier).deleteLocalMedias(); context.pop(); + ref.read(homeViewStateNotifier.notifier).deleteLocalMedias(); }, ), ], @@ -419,22 +409,23 @@ class MultiSelectionDoneButton extends ConsumerWidget { Widget _shareAction( BuildContext context, Map selectedMedias, + WidgetRef ref, ) { - return AppSheetAction( + return SelectionMenuAction( icon: Icon( Platform.isIOS ? CupertinoIcons.share : Icons.share_rounded, color: context.colorScheme.textSecondary, size: 24, ), title: context.l10n.common_share, - onPressed: () { + onTap: () { Share.shareXFiles( selectedMedias.values .where((element) => element.isLocalStored) .map((e) => XFile(e.path)) .toList(), ); - context.pop(); + ref.read(homeViewStateNotifier.notifier).clearSelection(); }, ); } diff --git a/app/lib/ui/flow/home/home_screen.dart b/app/lib/ui/flow/home/home_screen.dart index e2f2d41b..217d7bac 100644 --- a/app/lib/ui/flow/home/home_screen.dart +++ b/app/lib/ui/flow/home/home_screen.dart @@ -64,11 +64,6 @@ class _HomeScreenState extends ConsumerState { _errorObserver(); return AppPage( titleWidget: const HomeAppTitle(), - actions: const [ - HomeTransferButton(), - SizedBox(width: 8), - HomeAccountButton(), - ], body: FadeInSwitcher(child: _body(context: context)), ); } @@ -78,7 +73,6 @@ class _HomeScreenState extends ConsumerState { homeViewStateNotifier.select( (value) => ( hasMedia: value.medias.isNotEmpty, - hasSelectedMedia: value.selectedMedias.isNotEmpty, isLoading: value.loading, hasLocalMediaAccess: value.hasLocalMediaAccess, error: value.error, @@ -97,15 +91,10 @@ class _HomeScreenState extends ConsumerState { ); } - return Stack( - alignment: Alignment.bottomRight, + return Column( children: [ - _buildMediaList(context: context), - if (state.hasSelectedMedia) - Padding( - padding: context.systemPadding + const EdgeInsets.all(16), - child: const MultiSelectionDoneButton(), - ), + Expanded(child: _buildMediaList(context: context)), + const HomeSelectionMenu(), ], ); } @@ -184,9 +173,10 @@ class _HomeScreenState extends ConsumerState { physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: context.mediaQuerySize.width > 600 - ? context.mediaQuerySize.width ~/ 180 - : context.mediaQuerySize.width ~/ 100, + crossAxisCount: (context.mediaQuerySize.width > 600 + ? context.mediaQuerySize.width ~/ 180 + : context.mediaQuerySize.width ~/ 100) + .clamp(1, 6), crossAxisSpacing: 4, mainAxisSpacing: 4, ), @@ -199,9 +189,9 @@ class _HomeScreenState extends ConsumerState { _notifier.loadMedias(); }); } - return AppMediaItem( - key: ValueKey(media.id), + media: media, + heroTag: "home${media.toString()}", onTap: () async { if (state.selectedMedias.isNotEmpty) { _notifier.toggleMediaSelection(media); @@ -209,6 +199,8 @@ class _HomeScreenState extends ConsumerState { } else { await MediaPreviewRoute( $extra: MediaPreviewRouteData( + onLoadMore: _notifier.loadMedias, + heroTag: "home", medias: state.medias.values .expand((element) => element.values) .toList(), @@ -225,7 +217,6 @@ class _HomeScreenState extends ConsumerState { uploadMediaProcess: state.uploadMediaProcesses[media.id], downloadMediaProcess: state.downloadMediaProcesses[media.id], - media: media, ); }, ), diff --git a/app/lib/ui/flow/home/home_screen_view_model.dart b/app/lib/ui/flow/home/home_screen_view_model.dart index 3ed21abb..2635de5a 100644 --- a/app/lib/ui/flow/home/home_screen_view_model.dart +++ b/app/lib/ui/flow/home/home_screen_view_model.dart @@ -269,8 +269,13 @@ class HomeViewStateNotifier extends StateNotifier /// Loads medias from local, google drive and dropbox. /// it append the medias to the existing medias if reload is false. /// force will load media event its already loading - Future loadMedias({bool reload = false, bool force = false}) async { - if (state.cloudLoading && !force) return; + Future> loadMedias({ + bool reload = false, + bool force = false, + }) async { + if (state.cloudLoading && !force) { + return state.medias.values.expand((element) => element.values).toList(); + } state = state.copyWith(loading: true, cloudLoading: true, error: null); try { // Reset all the variables if reload is true @@ -449,6 +454,7 @@ class HomeViewStateNotifier extends StateNotifier stackTrace: s, ); } + return state.medias.values.expand((element) => element.values).toList(); } Future<({List onlyCloudBasedMedias, List localRefMedias})> @@ -481,6 +487,10 @@ class HomeViewStateNotifier extends StateNotifier state = state.copyWith(selectedMedias: selectedMedias); } + void clearSelection() { + state = state.copyWith(selectedMedias: {}); + } + Future uploadToGoogleDrive() async { try { if (state.googleAccount == null) return; diff --git a/app/lib/ui/flow/main/main_screen.dart b/app/lib/ui/flow/main/main_screen.dart new file mode 100644 index 00000000..a1375dc3 --- /dev/null +++ b/app/lib/ui/flow/main/main_screen.dart @@ -0,0 +1,151 @@ +import 'dart:io'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:style/extensions/context_extensions.dart'; +import '../../navigation/app_route.dart'; + +class MainScreen extends StatefulWidget { + final StatefulNavigationShell navigationShell; + + const MainScreen({ + super.key, + required this.navigationShell, + }); + + @override + State createState() => _MainScreenState(); +} + +class _MainScreenState extends State { + @override + Widget build(BuildContext context) { + final tabs = [ + ( + icon: CupertinoIcons.house_fill, + label: "Home", + activeIcon: CupertinoIcons.house_fill, + ), + ( + icon: CupertinoIcons.folder, + label: "Albums", + activeIcon: CupertinoIcons.folder_fill + ), + ( + icon: CupertinoIcons.arrow_up_arrow_down, + label: "Transfer", + activeIcon: CupertinoIcons.arrow_up_arrow_down + ), + ( + icon: CupertinoIcons.person, + label: "Account", + activeIcon: CupertinoIcons.person_fill + ), + ]; + + return Material( + color: context.colorScheme.surface, + child: Column( + children: [ + Expanded(child: widget.navigationShell), + (!kIsWeb && Platform.isIOS) + ? CupertinoTabBar( + currentIndex: widget.navigationShell.currentIndex, + activeColor: context.colorScheme.primary, + inactiveColor: context.colorScheme.textDisabled, + onTap: (index) => _goBranch( + index: index, + context: context, + ), + backgroundColor: context.colorScheme.surface, + border: Border( + top: BorderSide( + color: context.colorScheme.outline, + width: 1, + ), + ), + items: tabs + .map( + (e) => BottomNavigationBarItem( + icon: Icon( + e.icon, + color: context.colorScheme.textDisabled, + size: 22, + ), + label: e.label, + activeIcon: Icon( + e.activeIcon, + color: context.colorScheme.primary, + size: 24, + ), + ), + ) + .toList(), + ) + : Container( + decoration: BoxDecoration( + color: context.colorScheme.surface, + border: Border( + top: BorderSide( + color: context.colorScheme.outline, + ), + ), + ), + child: BottomNavigationBar( + items: tabs + .map( + (e) => BottomNavigationBarItem( + icon: Icon( + e.icon, + color: context.colorScheme.textDisabled, + size: 24, + ), + label: e.label, + activeIcon: Icon( + e.activeIcon, + color: context.colorScheme.primary, + size: 24, + ), + ), + ) + .toList(), + currentIndex: widget.navigationShell.currentIndex, + selectedItemColor: context.colorScheme.primary, + unselectedItemColor: context.colorScheme.textDisabled, + backgroundColor: context.colorScheme.surface, + type: BottomNavigationBarType.fixed, + selectedFontSize: 12, + unselectedFontSize: 12, + elevation: 0, + onTap: (index) => _goBranch( + index: index, + context: context, + ), + ), + ), + ], + ), + ); + } + + void _goBranch({ + required int index, + required BuildContext context, + }) { + switch (index) { + case 0: + HomeRoute().go(context); + break; + case 1: + AlbumsRoute().go(context); + break; + case 2: + TransferRoute().go(context); + break; + case 3: + AccountRoute().go(context); + break; + } + } +} diff --git a/app/lib/ui/flow/media_metadata_details/media_metadata_details.dart b/app/lib/ui/flow/media_metadata_details/media_metadata_details.dart index 1fbc04fb..4df28946 100644 --- a/app/lib/ui/flow/media_metadata_details/media_metadata_details.dart +++ b/app/lib/ui/flow/media_metadata_details/media_metadata_details.dart @@ -31,6 +31,7 @@ class MediaMetadataDetailsScreen extends StatelessWidget { alignment: Alignment.center, children: [ AppMediaImage( + heroTag: "media_metadata_details${media.toString()}", size: Size(context.mediaQuerySize.width, 200), media: media, radius: 0, diff --git a/app/lib/ui/flow/media_preview/components/download_require_view.dart b/app/lib/ui/flow/media_preview/components/download_require_view.dart index e4c8b8d5..f7fe4013 100644 --- a/app/lib/ui/flow/media_preview/components/download_require_view.dart +++ b/app/lib/ui/flow/media_preview/components/download_require_view.dart @@ -11,6 +11,7 @@ import '../../../../domain/image_providers/app_media_image_provider.dart'; class DownloadRequireView extends StatelessWidget { final AppMedia media; + final String heroTag; final String? dropboxAccessToken; final DownloadMediaProcess? downloadProcess; final void Function() onDownload; @@ -18,6 +19,7 @@ class DownloadRequireView extends StatelessWidget { const DownloadRequireView({ super.key, required this.media, + required this.heroTag, this.downloadProcess, required this.onDownload, this.dropboxAccessToken, @@ -30,7 +32,7 @@ class DownloadRequireView extends StatelessWidget { alignment: Alignment.center, children: [ Hero( - tag: media, + tag: "$heroTag${media.toString()}", child: Image( gaplessPlayback: true, image: AppMediaImageProvider( diff --git a/app/lib/ui/flow/media_preview/components/local_media_image_preview.dart b/app/lib/ui/flow/media_preview/components/local_media_image_preview.dart index 001f544f..8b70627c 100644 --- a/app/lib/ui/flow/media_preview/components/local_media_image_preview.dart +++ b/app/lib/ui/flow/media_preview/components/local_media_image_preview.dart @@ -9,10 +9,12 @@ import '../../../../domain/image_providers/app_media_image_provider.dart'; class LocalMediaImagePreview extends StatelessWidget { final AppMedia media; + final String heroTag; const LocalMediaImagePreview({ super.key, required this.media, + required this.heroTag, }); @override @@ -27,7 +29,7 @@ class LocalMediaImagePreview extends StatelessWidget { : width; return Center( child: Hero( - tag: media, + tag: "$heroTag${media.toString()}", child: Image.file( width: width, height: height, diff --git a/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview.dart b/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview.dart index 07ec49a2..4849c8d0 100644 --- a/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview.dart +++ b/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'package:data/models/media/media_extension.dart'; import 'package:style/extensions/context_extensions.dart'; import '../../../../../components/place_holder_screen.dart'; import '../../../../../domain/extensions/context_extensions.dart'; @@ -7,14 +6,18 @@ import 'package:data/models/media/media.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../../components/app_page.dart'; -import '../../../../../domain/extensions/widget_extensions.dart'; import '../../../../../domain/image_providers/app_media_image_provider.dart'; import 'network_image_preview_view_model.dart'; class NetworkImagePreview extends ConsumerStatefulWidget { final AppMedia media; + final String heroTag; - const NetworkImagePreview({super.key, required this.media}); + const NetworkImagePreview({ + super.key, + required this.media, + required this.heroTag, + }); @override ConsumerState createState() => @@ -22,32 +25,20 @@ class NetworkImagePreview extends ConsumerStatefulWidget { } class _NetworkImagePreviewState extends ConsumerState { - late NetworkImagePreviewStateNotifier notifier; + late AutoDisposeStateNotifierProvider _provider; @override void initState() { if (!widget.media.sources.contains(AppMediaSource.local)) { - notifier = ref.read(networkImagePreviewStateNotifierProvider.notifier); - runPostFrame(() async { - if (widget.media.driveMediaRefId != null) { - await notifier.loadImageFromGoogleDrive( - id: widget.media.driveMediaRefId!, - extension: widget.media.extension, - ); - } else if (widget.media.dropboxMediaRefId != null) { - await notifier.loadImageFromDropbox( - id: widget.media.dropboxMediaRefId!, - extension: widget.media.extension, - ); - } - }); + _provider = networkImagePreviewStateNotifierProvider(widget.media); } super.initState(); } @override Widget build(BuildContext context) { - final state = ref.watch(networkImagePreviewStateNotifierProvider); + final state = ref.watch(_provider); final width = context.mediaQuerySize.width; double multiplier = 1; if (widget.media.displayWidth != null && widget.media.displayWidth! > 0) { @@ -60,7 +51,7 @@ class _NetworkImagePreviewState extends ConsumerState { return Center( child: Hero( - tag: widget.media, + tag: "${widget.heroTag}${widget.media.toString()}", child: Image( image: state.filePath != null ? FileImage(File(state.filePath!)) as ImageProvider diff --git a/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview_view_model.dart b/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview_view_model.dart index cff93f0a..c43bbfd6 100644 --- a/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview_view_model.dart +++ b/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview_view_model.dart @@ -1,5 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:data/models/media/media.dart'; +import 'package:data/models/media/media_extension.dart'; import 'package:data/services/dropbox_services.dart'; import 'package:data/services/google_drive_service.dart'; import 'package:dio/dio.dart' show CancelToken; @@ -9,12 +11,13 @@ import 'package:path_provider/path_provider.dart'; part 'network_image_preview_view_model.freezed.dart'; -final networkImagePreviewStateNotifierProvider = - StateNotifierProvider.autoDispose((ref) { +final networkImagePreviewStateNotifierProvider = StateNotifierProvider.family + .autoDispose((ref, media) { return NetworkImagePreviewStateNotifier( ref.read(googleDriveServiceProvider), ref.read(dropboxServiceProvider), + media, ); }); @@ -26,7 +29,20 @@ class NetworkImagePreviewStateNotifier NetworkImagePreviewStateNotifier( this._googleDriveServices, this._dropboxService, - ) : super(const NetworkImagePreviewState()); + AppMedia media, + ) : super(NetworkImagePreviewState(media: media)) { + if (media.driveMediaRefId != null) { + loadImageFromGoogleDrive( + id: media.driveMediaRefId!, + extension: media.extension, + ); + } else if (media.dropboxMediaRefId != null) { + loadImageFromDropbox( + id: media.dropboxMediaRefId!, + extension: media.extension, + ); + } + } File? tempFile; CancelToken? cancelToken; @@ -102,6 +118,7 @@ class NetworkImagePreviewStateNotifier @freezed class NetworkImagePreviewState with _$NetworkImagePreviewState { const factory NetworkImagePreviewState({ + required AppMedia media, @Default(false) bool loading, double? progress, String? filePath, diff --git a/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview_view_model.freezed.dart b/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview_view_model.freezed.dart index 1d9b4542..85b7303d 100644 --- a/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview_view_model.freezed.dart +++ b/app/lib/ui/flow/media_preview/components/network_image_preview/network_image_preview_view_model.freezed.dart @@ -16,6 +16,7 @@ final _privateConstructorUsedError = UnsupportedError( /// @nodoc mixin _$NetworkImagePreviewState { + AppMedia get media => throw _privateConstructorUsedError; bool get loading => throw _privateConstructorUsedError; double? get progress => throw _privateConstructorUsedError; String? get filePath => throw _privateConstructorUsedError; @@ -34,7 +35,14 @@ abstract class $NetworkImagePreviewStateCopyWith<$Res> { $Res Function(NetworkImagePreviewState) then) = _$NetworkImagePreviewStateCopyWithImpl<$Res, NetworkImagePreviewState>; @useResult - $Res call({bool loading, double? progress, String? filePath, Object? error}); + $Res call( + {AppMedia media, + bool loading, + double? progress, + String? filePath, + Object? error}); + + $AppMediaCopyWith<$Res> get media; } /// @nodoc @@ -53,12 +61,17 @@ class _$NetworkImagePreviewStateCopyWithImpl<$Res, @pragma('vm:prefer-inline') @override $Res call({ + Object? media = null, Object? loading = null, Object? progress = freezed, Object? filePath = freezed, Object? error = freezed, }) { return _then(_value.copyWith( + media: null == media + ? _value.media + : media // ignore: cast_nullable_to_non_nullable + as AppMedia, loading: null == loading ? _value.loading : loading // ignore: cast_nullable_to_non_nullable @@ -74,6 +87,16 @@ class _$NetworkImagePreviewStateCopyWithImpl<$Res, error: freezed == error ? _value.error : error, ) as $Val); } + + /// Create a copy of NetworkImagePreviewState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AppMediaCopyWith<$Res> get media { + return $AppMediaCopyWith<$Res>(_value.media, (value) { + return _then(_value.copyWith(media: value) as $Val); + }); + } } /// @nodoc @@ -85,7 +108,15 @@ abstract class _$$NetworkImagePreviewStateImplCopyWith<$Res> __$$NetworkImagePreviewStateImplCopyWithImpl<$Res>; @override @useResult - $Res call({bool loading, double? progress, String? filePath, Object? error}); + $Res call( + {AppMedia media, + bool loading, + double? progress, + String? filePath, + Object? error}); + + @override + $AppMediaCopyWith<$Res> get media; } /// @nodoc @@ -103,12 +134,17 @@ class __$$NetworkImagePreviewStateImplCopyWithImpl<$Res> @pragma('vm:prefer-inline') @override $Res call({ + Object? media = null, Object? loading = null, Object? progress = freezed, Object? filePath = freezed, Object? error = freezed, }) { return _then(_$NetworkImagePreviewStateImpl( + media: null == media + ? _value.media + : media // ignore: cast_nullable_to_non_nullable + as AppMedia, loading: null == loading ? _value.loading : loading // ignore: cast_nullable_to_non_nullable @@ -130,8 +166,14 @@ class __$$NetworkImagePreviewStateImplCopyWithImpl<$Res> class _$NetworkImagePreviewStateImpl implements _NetworkImagePreviewState { const _$NetworkImagePreviewStateImpl( - {this.loading = false, this.progress, this.filePath, this.error}); + {required this.media, + this.loading = false, + this.progress, + this.filePath, + this.error}); + @override + final AppMedia media; @override @JsonKey() final bool loading; @@ -144,7 +186,7 @@ class _$NetworkImagePreviewStateImpl implements _NetworkImagePreviewState { @override String toString() { - return 'NetworkImagePreviewState(loading: $loading, progress: $progress, filePath: $filePath, error: $error)'; + return 'NetworkImagePreviewState(media: $media, loading: $loading, progress: $progress, filePath: $filePath, error: $error)'; } @override @@ -152,6 +194,7 @@ class _$NetworkImagePreviewStateImpl implements _NetworkImagePreviewState { return identical(this, other) || (other.runtimeType == runtimeType && other is _$NetworkImagePreviewStateImpl && + (identical(other.media, media) || other.media == media) && (identical(other.loading, loading) || other.loading == loading) && (identical(other.progress, progress) || other.progress == progress) && @@ -161,8 +204,8 @@ class _$NetworkImagePreviewStateImpl implements _NetworkImagePreviewState { } @override - int get hashCode => Object.hash(runtimeType, loading, progress, filePath, - const DeepCollectionEquality().hash(error)); + int get hashCode => Object.hash(runtimeType, media, loading, progress, + filePath, const DeepCollectionEquality().hash(error)); /// Create a copy of NetworkImagePreviewState /// with the given fields replaced by the non-null parameter values. @@ -176,11 +219,14 @@ class _$NetworkImagePreviewStateImpl implements _NetworkImagePreviewState { abstract class _NetworkImagePreviewState implements NetworkImagePreviewState { const factory _NetworkImagePreviewState( - {final bool loading, + {required final AppMedia media, + final bool loading, final double? progress, final String? filePath, final Object? error}) = _$NetworkImagePreviewStateImpl; + @override + AppMedia get media; @override bool get loading; @override diff --git a/app/lib/ui/flow/media_preview/components/video_player_components/video_duration_slider.dart b/app/lib/ui/flow/media_preview/components/video_player_components/video_duration_slider.dart index 54b043f6..bccb9b19 100644 --- a/app/lib/ui/flow/media_preview/components/video_player_components/video_duration_slider.dart +++ b/app/lib/ui/flow/media_preview/components/video_player_components/video_duration_slider.dart @@ -1,4 +1,3 @@ -import 'dart:ui'; import '../../../../../domain/formatter/duration_formatter.dart'; import 'package:flutter/material.dart'; import 'package:style/animations/cross_fade_animation.dart'; @@ -34,52 +33,49 @@ class VideoDurationSlider extends StatelessWidget { left: 16, right: 16, ), - color: context.colorScheme.barColor, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - position.format, - style: AppTextStyles.caption - .copyWith(color: context.colorScheme.textPrimary), - ), - Expanded( - child: SizedBox( - height: 30, - child: Material( - color: Colors.transparent, - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 4, - trackShape: const RoundedRectSliderTrackShape(), - rangeTrackShape: - const RoundedRectRangeSliderTrackShape(), - thumbShape: SliderComponentShape.noThumb, - ), - child: Slider( - value: position.inSeconds.toDouble(), - max: duration.inSeconds.toDouble(), - min: 0, - activeColor: context.colorScheme.primary, - inactiveColor: context.colorScheme.outline, - onChangeEnd: (value) => onChangeEnd - .call(Duration(seconds: value.toInt())), - onChanged: (double value) => - onChanged.call(Duration(seconds: value.toInt())), - ), + color: context.colorScheme.surface, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + position.format, + style: AppTextStyles.caption + .copyWith(color: context.colorScheme.textPrimary), + ), + Expanded( + child: SizedBox( + height: 30, + child: Material( + color: Colors.transparent, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 4, + trackShape: const RoundedRectSliderTrackShape(), + rangeTrackShape: + const RoundedRectRangeSliderTrackShape(), + thumbShape: SliderComponentShape.noThumb, + ), + child: Slider( + value: position.inSeconds.toDouble(), + max: duration.inSeconds.toDouble(), + min: 0, + activeColor: context.colorScheme.primary, + inactiveColor: context.colorScheme.outline, + onChangeEnd: (value) => + onChangeEnd.call(Duration(seconds: value.toInt())), + onChanged: (double value) => + onChanged.call(Duration(seconds: value.toInt())), ), ), ), ), - Text( - duration.format, - style: AppTextStyles.caption - .copyWith(color: context.colorScheme.textPrimary), - ), - ], - ), + ), + Text( + duration.format, + style: AppTextStyles.caption + .copyWith(color: context.colorScheme.textPrimary), + ), + ], ), ), ), diff --git a/app/lib/ui/flow/media_preview/media_preview_screen.dart b/app/lib/ui/flow/media_preview/media_preview_screen.dart index 28b8e620..4ef708ac 100644 --- a/app/lib/ui/flow/media_preview/media_preview_screen.dart +++ b/app/lib/ui/flow/media_preview/media_preview_screen.dart @@ -28,11 +28,15 @@ import 'components/video_player_components/video_duration_slider.dart'; class MediaPreview extends ConsumerStatefulWidget { final List medias; + final String heroTag; + final Future> Function() onLoadMore; final String startFrom; const MediaPreview({ super.key, required this.medias, + required this.heroTag, + required this.onLoadMore, required this.startFrom, }); @@ -190,7 +194,10 @@ class _MediaPreviewState extends ConsumerState { physics: state.isImageZoomed ? const NeverScrollableScrollPhysics() : null, - onPageChanged: _notifier.changeVisibleMediaIndex, + onPageChanged: (value) => _notifier.changeVisibleMediaIndex( + value, + widget.onLoadMore, + ), controller: _pageController, itemCount: state.medias.length, itemBuilder: (context, index) => _preview( @@ -243,7 +250,7 @@ class _MediaPreviewState extends ConsumerState { ); return Hero( - tag: media, + tag: "${widget.heroTag}${media.toString()}", child: Stack( alignment: Alignment.center, children: [ @@ -321,7 +328,7 @@ class _MediaPreviewState extends ConsumerState { }, onDismiss: context.pop, onDragDown: _notifier.updateSwipeDownPercentage, - child: LocalMediaImagePreview(media: media), + child: LocalMediaImagePreview(media: media, heroTag: widget.heroTag), ); } else if (media.type.isImage && (media.isGoogleDriveStored || media.isDropboxStored)) { @@ -332,7 +339,7 @@ class _MediaPreviewState extends ConsumerState { }, onDismiss: context.pop, onDragDown: _notifier.updateSwipeDownPercentage, - child: NetworkImagePreview(media: media), + child: NetworkImagePreview(media: media, heroTag: widget.heroTag), ); } else { return PlaceHolderScreen( @@ -359,6 +366,7 @@ class _MediaPreviewState extends ConsumerState { ), ); return DownloadRequireView( + heroTag: widget.heroTag, dropboxAccessToken: ref.read(AppPreferences.dropboxToken)?.access_token, media: media, diff --git a/app/lib/ui/flow/media_preview/media_preview_view_model.dart b/app/lib/ui/flow/media_preview/media_preview_view_model.dart index 4e10caad..3a2858ba 100644 --- a/app/lib/ui/flow/media_preview/media_preview_view_model.dart +++ b/app/lib/ui/flow/media_preview/media_preview_view_model.dart @@ -16,7 +16,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:google_sign_in/google_sign_in.dart'; import 'package:logger/logger.dart'; -import '../home/home_screen_view_model.dart'; part 'media_preview_view_model.freezed.dart'; @@ -36,15 +35,6 @@ final mediaPreviewStateNotifierProvider = ref.read(authServiceProvider), ref.read(connectivityHandlerProvider), ref.read(loggerProvider), - ref.read(homeViewStateNotifier.notifier), - () { - return ref.read( - homeViewStateNotifier.select( - (value) => - value.medias.values.expand((element) => element.values).toList(), - ), - ); - }, state.medias, state.startIndex, ref.read(AppPreferences.dropboxCurrentUserAccount), @@ -63,8 +53,6 @@ class MediaPreviewStateNotifier extends StateNotifier { final ConnectivityHandler _connectivityHandler; final AuthService _authService; final Logger _logger; - final HomeViewStateNotifier _homeNotifier; - final List Function() _getUpdatedMedias; StreamSubscription? _googleAccountSubscription; String? _backUpFolderId; @@ -77,8 +65,6 @@ class MediaPreviewStateNotifier extends StateNotifier { this._authService, this._connectivityHandler, this._logger, - this._homeNotifier, - this._getUpdatedMedias, List medias, int startIndex, DropboxAccount? dropboxAccount, @@ -396,12 +382,14 @@ class MediaPreviewStateNotifier extends StateNotifier { // Preview Actions ----------------------------------------------------------- - Future changeVisibleMediaIndex(int index) async { + Future changeVisibleMediaIndex( + int index, + Future> Function() loadMoreMedia, + ) async { state = state.copyWith(currentIndex: index); if (index == state.medias.length - 1) { - await _homeNotifier.loadMedias(); - state = state.copyWith(medias: _getUpdatedMedias()); + state = state.copyWith(medias: await loadMoreMedia()); } } diff --git a/app/lib/ui/flow/media_selection/media_selection_screen.dart b/app/lib/ui/flow/media_selection/media_selection_screen.dart new file mode 100644 index 00000000..30ae7dfa --- /dev/null +++ b/app/lib/ui/flow/media_selection/media_selection_screen.dart @@ -0,0 +1,206 @@ +import 'package:data/models/media/media.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:style/animations/fade_in_switcher.dart'; +import 'package:style/buttons/action_button.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/indicators/circular_progress_indicator.dart'; +import 'package:style/text/app_text_style.dart'; +import '../../../components/app_media_thumbnail.dart'; +import '../../../components/app_page.dart'; +import '../../../components/error_screen.dart'; +import '../../../components/place_holder_screen.dart'; +import '../../../components/snack_bar.dart'; +import '../../../domain/extensions/context_extensions.dart'; +import '../../../domain/extensions/widget_extensions.dart'; +import '../../../domain/formatter/date_formatter.dart'; +import '../../../gen/assets.gen.dart'; +import '../home/components/no_local_medias_access_screen.dart'; +import 'media_selection_state_notifier.dart'; +import 'package:style/callback/on_visible_callback.dart'; + +class MediaSelectionScreen extends ConsumerStatefulWidget { + final AppMediaSource source; + + const MediaSelectionScreen({super.key, required this.source}); + + @override + ConsumerState createState() => _MediaSelectionScreenState(); +} + +class _MediaSelectionScreenState extends ConsumerState { + late AutoDisposeStateNotifierProvider _provider; + late MediaSelectionStateNotifier _notifier; + + void _observeError(BuildContext context) { + ref.listen( + _provider.select( + (value) => value.actionError, + ), + (previous, error) { + if (error != null) { + showErrorSnackBar(context: context, error: error); + } + }, + ); + } + + @override + void initState() { + _provider = mediaSelectionStateNotifierProvider(widget.source); + _notifier = ref.read(_provider.notifier); + super.initState(); + } + + @override + Widget build(BuildContext context) { + _observeError(context); + final state = ref.watch(_provider); + return AppPage( + title: widget.source == AppMediaSource.googleDrive + ? context.l10n.select_from_google_drive_title + : widget.source == AppMediaSource.dropbox + ? context.l10n.select_from_dropbox_title + : context.l10n.select_from_device_title, + actions: [ + ActionButton( + onPressed: () { + context.pop(state.selectedMedias); + }, + icon: Icon( + Icons.check, + color: context.colorScheme.textPrimary, + size: 24, + ), + ), + ], + body: Builder( + builder: (context) { + return FadeInSwitcher(child: _body(context: context, state: state)); + }, + ), + ); + } + + Widget _body({ + required BuildContext context, + required MediaSelectionState state, + }) { + if (state.loading && state.medias.isEmpty) { + return const Center(child: AppCircularProgressIndicator()); + } else if (state.error != null) { + return ErrorScreen( + error: state.error!, + onRetryTap: () => _notifier.loadMedias(reload: true), + ); + } else if (state.medias.isEmpty && state.noAccess) { + return widget.source == AppMediaSource.local + ? NoLocalMediasAccessScreen() + : PlaceHolderScreen( + icon: SvgPicture.asset( + Assets.images.ilNoMediaFound, + width: 150, + ), + title: context.l10n.no_media_access_title, + message: context.l10n.no_cloud_media_access_message, + ); + } else if (state.medias.isEmpty && !state.noAccess) { + return PlaceHolderScreen( + icon: SvgPicture.asset( + Assets.images.ilNoMediaFound, + width: 150, + ), + title: context.l10n.empty_media_title, + message: context.l10n.empty_media_message, + ); + } else { + return ListView.builder( + itemCount: state.medias.length + 1, + itemBuilder: (context, index) { + if (index < state.medias.length) { + final gridEntry = state.medias.entries.elementAt(index); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Builder( + builder: (context) { + return Container( + height: 45, + padding: const EdgeInsets.only(left: 16, top: 5), + margin: EdgeInsets.zero, + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + color: context.colorScheme.surface, + ), + child: Text( + gridEntry.key.format(context, DateFormatType.relative), + style: AppTextStyles.subtitle1.copyWith( + color: context.colorScheme.textPrimary, + ), + ), + ); + }, + ), + 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) => AppMediaThumbnail( + heroTag: + "selection${gridEntry.value.elementAt(index).toString()}", + onTap: () { + _notifier.toggleMediaSelection( + gridEntry.value.elementAt(index), + ); + }, + selected: state.selectedMedias.contains( + widget.source == AppMediaSource.googleDrive + ? gridEntry.value.elementAt(index).driveMediaRefId + : widget.source == AppMediaSource.dropbox + ? gridEntry.value + .elementAt(index) + .dropboxMediaRefId + : gridEntry.value.elementAt(index).id, + ), + media: gridEntry.value.elementAt(index), + ), + ), + ], + ); + } else { + return OnVisibleCallback( + onVisible: () { + runPostFrame(() { + _notifier.loadMedias(); + }); + }, + child: FadeInSwitcher( + child: state.loading + ? const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: AppCircularProgressIndicator( + size: 20, + ), + ), + ) + : const SizedBox(), + ), + ); + } + }, + ); + } + } +} diff --git a/app/lib/ui/flow/media_selection/media_selection_state_notifier.dart b/app/lib/ui/flow/media_selection/media_selection_state_notifier.dart new file mode 100644 index 00000000..3caa9dd2 --- /dev/null +++ b/app/lib/ui/flow/media_selection/media_selection_state_notifier.dart @@ -0,0 +1,221 @@ +import 'package:collection/collection.dart'; +import 'package:data/domain/config.dart'; +import 'package:data/errors/app_error.dart'; +import 'package:data/log/logger.dart'; +import 'package:data/models/dropbox/account/dropbox_account.dart'; +import 'package:data/models/media/media.dart'; +import 'package:data/services/auth_service.dart'; +import 'package:data/services/dropbox_services.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'; +import 'package:logger/logger.dart'; + +part 'media_selection_state_notifier.freezed.dart'; + +final mediaSelectionStateNotifierProvider = StateNotifierProvider.autoDispose + .family( + (ref, state) { + return MediaSelectionStateNotifier( + ref.read(localMediaServiceProvider), + ref.read(googleDriveServiceProvider), + ref.read(dropboxServiceProvider), + ref.read(googleUserAccountProvider), + ref.read(AppPreferences.dropboxCurrentUserAccount), + ref.read(loggerProvider), + state, + ); + }, +); + +class MediaSelectionStateNotifier extends StateNotifier { + final GoogleDriveService _googleDriveService; + final DropboxService _dropboxService; + final LocalMediaService _localMediaService; + final GoogleSignInAccount? _googleAccount; + final DropboxAccount? _dropboxAccount; + final Logger _logger; + final AppMediaSource _source; + + MediaSelectionStateNotifier( + this._localMediaService, + this._googleDriveService, + this._dropboxService, + this._googleAccount, + this._dropboxAccount, + this._logger, + this._source, + ) : super(const MediaSelectionState()) { + loadMedias(reload: true); + } + + String? _pageToken; + String? _backupFolderId; + bool _maxLoaded = false; + + Future loadMedias({bool reload = false}) async { + try { + if (state.loading || _maxLoaded) return; + + if (reload) { + _pageToken = null; + _maxLoaded = false; + } + + state = state.copyWith( + loading: true, + error: null, + actionError: null, + noAccess: false, + medias: reload ? {} : state.medias, + ); + + if (_source == AppMediaSource.googleDrive) { + if (_googleAccount == null) { + state = state.copyWith( + loading: false, + noAccess: true, + ); + return; + } + _backupFolderId ??= await _googleDriveService.getBackUpFolderId(); + if (_backupFolderId == null) { + throw BackUpFolderNotFound(); + } + final res = await _googleDriveService.getPaginatedMedias( + folder: _backupFolderId!, + nextPageToken: _pageToken, + ); + + _pageToken = res.nextPageToken; + if (res.nextPageToken == null) { + _maxLoaded = true; + } + final groupedMedias = groupBy( + [...state.medias.values.expand((element) => element), ...res.medias], + (media) => media.createdTime ?? media.modifiedTime ?? DateTime.now(), + ); + + state = state.copyWith( + medias: groupedMedias, + loading: false, + ); + } else if (_source == AppMediaSource.dropbox) { + if (_dropboxAccount == null) { + state = state.copyWith( + loading: false, + noAccess: true, + ); + return; + } + + final res = await _dropboxService.getPaginatedMedias( + nextPageToken: _pageToken, + folder: ProviderConstants.backupFolderPath, + ); + + _pageToken = res.nextPageToken; + if (res.nextPageToken == null) { + _maxLoaded = true; + } + + final groupedMedias = groupBy( + [...state.medias.values.expand((element) => element), ...res.medias], + (media) => media.createdTime ?? media.modifiedTime ?? DateTime.now(), + ); + + state = state.copyWith( + medias: groupedMedias, + loading: false, + ); + } else if (_source == AppMediaSource.local) { + final hasPermission = await _localMediaService.requestPermission(); + + if (!hasPermission) { + state = state.copyWith( + loading: false, + noAccess: true, + ); + return; + } + final mediasLength = + state.medias.values.expand((element) => element).length; + final medias = await _localMediaService.getLocalMedia( + start: mediasLength, + end: mediasLength + 30, + ); + + if (medias.length < 30) { + _maxLoaded = true; + } + + final groupedMedias = groupBy( + [...state.medias.values.expand((element) => element), ...medias], + (media) => media.createdTime ?? media.modifiedTime ?? DateTime.now(), + ); + + state = state.copyWith( + medias: groupedMedias, + loading: false, + ); + } else { + state = state.copyWith( + loading: false, + ); + } + } catch (e, s) { + state = state.copyWith( + loading: false, + error: state.medias.isEmpty ? e : null, + actionError: state.medias.isNotEmpty ? e : null, + ); + _logger.e( + "MediaSelectionStateNotifier: Error loading medias", + error: e, + stackTrace: s, + ); + } + } + + void toggleMediaSelection(AppMedia media) { + String id; + + if (_source == AppMediaSource.googleDrive) { + id = media.driveMediaRefId!; + } else if (_source == AppMediaSource.dropbox) { + id = media.dropboxMediaRefId!; + } else { + id = media.id; + } + + if (state.selectedMedias.contains(id)) { + state = state.copyWith( + selectedMedias: [ + ...state.selectedMedias.where((element) => element != id), + ], + ); + } else { + state = state.copyWith( + selectedMedias: [ + ...state.selectedMedias, + id, + ], + ); + } + } +} + +@freezed +class MediaSelectionState with _$MediaSelectionState { + const factory MediaSelectionState({ + @Default({}) Map> medias, + @Default([]) List selectedMedias, + @Default(false) bool loading, + @Default(false) bool noAccess, + Object? error, + Object? actionError, + }) = _MediaSelectionState; +} diff --git a/app/lib/ui/flow/media_selection/media_selection_state_notifier.freezed.dart b/app/lib/ui/flow/media_selection/media_selection_state_notifier.freezed.dart new file mode 100644 index 00000000..5a9ec107 --- /dev/null +++ b/app/lib/ui/flow/media_selection/media_selection_state_notifier.freezed.dart @@ -0,0 +1,265 @@ +// 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_selection_state_notifier.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 _$MediaSelectionState { + Map> get medias => + throw _privateConstructorUsedError; + List get selectedMedias => throw _privateConstructorUsedError; + bool get loading => throw _privateConstructorUsedError; + bool get noAccess => throw _privateConstructorUsedError; + Object? get error => throw _privateConstructorUsedError; + Object? get actionError => throw _privateConstructorUsedError; + + /// Create a copy of MediaSelectionState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $MediaSelectionStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $MediaSelectionStateCopyWith<$Res> { + factory $MediaSelectionStateCopyWith( + MediaSelectionState value, $Res Function(MediaSelectionState) then) = + _$MediaSelectionStateCopyWithImpl<$Res, MediaSelectionState>; + @useResult + $Res call( + {Map> medias, + List selectedMedias, + bool loading, + bool noAccess, + Object? error, + Object? actionError}); +} + +/// @nodoc +class _$MediaSelectionStateCopyWithImpl<$Res, $Val extends MediaSelectionState> + implements $MediaSelectionStateCopyWith<$Res> { + _$MediaSelectionStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of MediaSelectionState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? medias = null, + Object? selectedMedias = null, + Object? loading = null, + Object? noAccess = null, + Object? error = freezed, + Object? actionError = freezed, + }) { + return _then(_value.copyWith( + medias: null == medias + ? _value.medias + : medias // ignore: cast_nullable_to_non_nullable + as Map>, + selectedMedias: null == selectedMedias + ? _value.selectedMedias + : selectedMedias // ignore: cast_nullable_to_non_nullable + as List, + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + noAccess: null == noAccess + ? _value.noAccess + : noAccess // ignore: cast_nullable_to_non_nullable + as bool, + error: freezed == error ? _value.error : error, + actionError: freezed == actionError ? _value.actionError : actionError, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$MediaSelectionStateImplCopyWith<$Res> + implements $MediaSelectionStateCopyWith<$Res> { + factory _$$MediaSelectionStateImplCopyWith(_$MediaSelectionStateImpl value, + $Res Function(_$MediaSelectionStateImpl) then) = + __$$MediaSelectionStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {Map> medias, + List selectedMedias, + bool loading, + bool noAccess, + Object? error, + Object? actionError}); +} + +/// @nodoc +class __$$MediaSelectionStateImplCopyWithImpl<$Res> + extends _$MediaSelectionStateCopyWithImpl<$Res, _$MediaSelectionStateImpl> + implements _$$MediaSelectionStateImplCopyWith<$Res> { + __$$MediaSelectionStateImplCopyWithImpl(_$MediaSelectionStateImpl _value, + $Res Function(_$MediaSelectionStateImpl) _then) + : super(_value, _then); + + /// Create a copy of MediaSelectionState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? medias = null, + Object? selectedMedias = null, + Object? loading = null, + Object? noAccess = null, + Object? error = freezed, + Object? actionError = freezed, + }) { + return _then(_$MediaSelectionStateImpl( + medias: null == medias + ? _value._medias + : medias // ignore: cast_nullable_to_non_nullable + as Map>, + selectedMedias: null == selectedMedias + ? _value._selectedMedias + : selectedMedias // ignore: cast_nullable_to_non_nullable + as List, + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + noAccess: null == noAccess + ? _value.noAccess + : noAccess // ignore: cast_nullable_to_non_nullable + as bool, + error: freezed == error ? _value.error : error, + actionError: freezed == actionError ? _value.actionError : actionError, + )); + } +} + +/// @nodoc + +class _$MediaSelectionStateImpl implements _MediaSelectionState { + const _$MediaSelectionStateImpl( + {final Map> medias = const {}, + final List selectedMedias = const [], + this.loading = false, + this.noAccess = false, + this.error, + this.actionError}) + : _medias = medias, + _selectedMedias = selectedMedias; + + final Map> _medias; + @override + @JsonKey() + Map> get medias { + if (_medias is EqualUnmodifiableMapView) return _medias; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_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 bool loading; + @override + @JsonKey() + final bool noAccess; + @override + final Object? error; + @override + final Object? actionError; + + @override + String toString() { + return 'MediaSelectionState(medias: $medias, selectedMedias: $selectedMedias, loading: $loading, noAccess: $noAccess, error: $error, actionError: $actionError)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$MediaSelectionStateImpl && + const DeepCollectionEquality().equals(other._medias, _medias) && + const DeepCollectionEquality() + .equals(other._selectedMedias, _selectedMedias) && + (identical(other.loading, loading) || other.loading == loading) && + (identical(other.noAccess, noAccess) || + other.noAccess == noAccess) && + const DeepCollectionEquality().equals(other.error, error) && + const DeepCollectionEquality() + .equals(other.actionError, actionError)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(_medias), + const DeepCollectionEquality().hash(_selectedMedias), + loading, + noAccess, + const DeepCollectionEquality().hash(error), + const DeepCollectionEquality().hash(actionError)); + + /// Create a copy of MediaSelectionState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$MediaSelectionStateImplCopyWith<_$MediaSelectionStateImpl> get copyWith => + __$$MediaSelectionStateImplCopyWithImpl<_$MediaSelectionStateImpl>( + this, _$identity); +} + +abstract class _MediaSelectionState implements MediaSelectionState { + const factory _MediaSelectionState( + {final Map> medias, + final List selectedMedias, + final bool loading, + final bool noAccess, + final Object? error, + final Object? actionError}) = _$MediaSelectionStateImpl; + + @override + Map> get medias; + @override + List get selectedMedias; + @override + bool get loading; + @override + bool get noAccess; + @override + Object? get error; + @override + Object? get actionError; + + /// Create a copy of MediaSelectionState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$MediaSelectionStateImplCopyWith<_$MediaSelectionStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/app/lib/ui/navigation/app_route.dart b/app/lib/ui/navigation/app_route.dart index 0457f691..3ea1bf7a 100644 --- a/app/lib/ui/navigation/app_route.dart +++ b/app/lib/ui/navigation/app_route.dart @@ -1,4 +1,10 @@ +import 'package:data/models/album/album.dart'; import '../flow/accounts/accounts_screen.dart'; +import '../flow/albums/add/add_album_screen.dart'; +import '../flow/albums/albums_screen.dart'; +import '../flow/albums/media_list/album_media_list_screen.dart'; +import '../flow/main/main_screen.dart'; +import '../flow/media_selection/media_selection_screen.dart'; import '../flow/media_transfer/media_transfer_screen.dart'; import '../flow/onboard/onboard_screen.dart'; import 'package:data/models/media/media.dart'; @@ -11,15 +17,73 @@ import '../flow/media_preview/media_preview_screen.dart'; part 'app_route.g.dart'; class AppRoutePath { - static const home = '/'; static const onBoard = '/on-board'; + static const home = '/'; + static const albums = '/albums'; + static const addAlbum = '/add-album'; + static const albumMediaList = '/albums/:albumId'; + static const transfer = '/transfer'; static const accounts = '/accounts'; static const preview = '/preview'; - static const transfer = '/transfer'; static const metaDataDetails = '/metadata-details'; + static const mediaSelection = '/select'; } -@TypedGoRoute(path: AppRoutePath.home) +final rootNavigatorKey = GlobalKey(); + +@TypedGoRoute(path: AppRoutePath.onBoard) +class OnBoardRoute extends GoRouteData { + const OnBoardRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => + const OnBoardScreen(); +} + +@TypedStatefulShellRoute( + branches: [ + TypedStatefulShellBranch( + routes: [ + TypedGoRoute(path: AppRoutePath.home), + ], + ), + TypedStatefulShellBranch( + routes: [ + TypedGoRoute(path: AppRoutePath.albums), + ], + ), + TypedStatefulShellBranch( + routes: [ + TypedGoRoute(path: AppRoutePath.transfer), + ], + ), + TypedStatefulShellBranch( + routes: [ + TypedGoRoute(path: AppRoutePath.accounts), + ], + ), + ], +) +class MainShellRoute extends StatefulShellRouteData { + const MainShellRoute(); + + @override + Widget builder( + BuildContext context, + GoRouterState state, + StatefulNavigationShell navigationShell, + ) => + MainScreen(navigationShell: navigationShell); +} + +class HomeShellBranch extends StatefulShellBranchData {} + +class AlbumsShellBranch extends StatefulShellBranchData {} + +class TransferShellBranch extends StatefulShellBranchData {} + +class AccountsShellBranch extends StatefulShellBranchData {} + class HomeRoute extends GoRouteData { const HomeRoute(); @@ -27,25 +91,37 @@ class HomeRoute extends GoRouteData { Widget build(BuildContext context, GoRouterState state) => const HomeScreen(); } -@TypedGoRoute(path: AppRoutePath.onBoard) -class OnBoardRoute extends GoRouteData { - const OnBoardRoute(); +class AlbumsRoute extends GoRouteData { + const AlbumsRoute(); @override Widget build(BuildContext context, GoRouterState state) => - const OnBoardScreen(); + const AlbumsScreen(); } -@TypedGoRoute(path: AppRoutePath.accounts) -class AccountRoute extends GoRouteData { - const AccountRoute(); +@TypedGoRoute(path: AppRoutePath.addAlbum) +class AddAlbumRoute extends GoRouteData { + final Album? $extra; + + const AddAlbumRoute({this.$extra}); @override Widget build(BuildContext context, GoRouterState state) => - const AccountsScreen(); + AddAlbumScreen(editAlbum: $extra); +} + +@TypedGoRoute(path: AppRoutePath.albumMediaList) +class AlbumMediaListRoute extends GoRouteData { + final Album $extra; + final String albumId; + + const AlbumMediaListRoute({required this.$extra, required this.albumId}); + + @override + Widget build(BuildContext context, GoRouterState state) => + AlbumMediaListScreen(album: $extra); } -@TypedGoRoute(path: AppRoutePath.transfer) class TransferRoute extends GoRouteData { const TransferRoute(); @@ -54,11 +130,26 @@ class TransferRoute extends GoRouteData { const MediaTransferScreen(); } +class AccountRoute extends GoRouteData { + const AccountRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => + const AccountsScreen(); +} + class MediaPreviewRouteData { final List medias; + final String heroTag; + final Future> Function() onLoadMore; final String startFrom; - const MediaPreviewRouteData({required this.medias, required this.startFrom}); + const MediaPreviewRouteData({ + required this.medias, + required this.startFrom, + required this.onLoadMore, + required this.heroTag, + }); } @TypedGoRoute(path: AppRoutePath.preview) @@ -72,7 +163,12 @@ class MediaPreviewRoute extends GoRouteData { return CustomTransitionPage( opaque: false, key: state.pageKey, - child: MediaPreview(medias: $extra.medias, startFrom: $extra.startFrom), + child: MediaPreview( + medias: $extra.medias, + startFrom: $extra.startFrom, + onLoadMore: $extra.onLoadMore, + heroTag: $extra.heroTag, + ), transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeTransition(opacity: animation, child: child); }, @@ -90,3 +186,14 @@ class MediaMetadataDetailsRoute extends GoRouteData { Widget build(BuildContext context, GoRouterState state) => MediaMetadataDetailsScreen(media: $extra); } + +@TypedGoRoute(path: AppRoutePath.mediaSelection) +class MediaSelectionRoute extends GoRouteData { + final AppMediaSource $extra; + + const MediaSelectionRoute({required this.$extra}); + + @override + Widget build(BuildContext context, GoRouterState state) => + MediaSelectionScreen(source: $extra); +} diff --git a/app/lib/ui/navigation/app_route.g.dart b/app/lib/ui/navigation/app_route.g.dart index 1ed07e89..6c8ff6ac 100644 --- a/app/lib/ui/navigation/app_route.g.dart +++ b/app/lib/ui/navigation/app_route.g.dart @@ -7,24 +7,25 @@ part of 'app_route.dart'; // ************************************************************************** List get $appRoutes => [ - $homeRoute, $onBoardRoute, - $accountRoute, - $transferRoute, + $mainShellRoute, + $addAlbumRoute, + $albumMediaListRoute, $mediaPreviewRoute, $mediaMetadataDetailsRoute, + $mediaSelectionRoute, ]; -RouteBase get $homeRoute => GoRouteData.$route( - path: '/', - factory: $HomeRouteExtension._fromState, +RouteBase get $onBoardRoute => GoRouteData.$route( + path: '/on-board', + factory: $OnBoardRouteExtension._fromState, ); -extension $HomeRouteExtension on HomeRoute { - static HomeRoute _fromState(GoRouterState state) => const HomeRoute(); +extension $OnBoardRouteExtension on OnBoardRoute { + static OnBoardRoute _fromState(GoRouterState state) => const OnBoardRoute(); String get location => GoRouteData.$location( - '/', + '/on-board', ); void go(BuildContext context) => context.go(location); @@ -37,16 +38,54 @@ extension $HomeRouteExtension on HomeRoute { void replace(BuildContext context) => context.replace(location); } -RouteBase get $onBoardRoute => GoRouteData.$route( - path: '/on-board', - factory: $OnBoardRouteExtension._fromState, +RouteBase get $mainShellRoute => StatefulShellRouteData.$route( + factory: $MainShellRouteExtension._fromState, + branches: [ + StatefulShellBranchData.$branch( + routes: [ + GoRouteData.$route( + path: '/', + factory: $HomeRouteExtension._fromState, + ), + ], + ), + StatefulShellBranchData.$branch( + routes: [ + GoRouteData.$route( + path: '/albums', + factory: $AlbumsRouteExtension._fromState, + ), + ], + ), + StatefulShellBranchData.$branch( + routes: [ + GoRouteData.$route( + path: '/transfer', + factory: $TransferRouteExtension._fromState, + ), + ], + ), + StatefulShellBranchData.$branch( + routes: [ + GoRouteData.$route( + path: '/accounts', + factory: $AccountRouteExtension._fromState, + ), + ], + ), + ], ); -extension $OnBoardRouteExtension on OnBoardRoute { - static OnBoardRoute _fromState(GoRouterState state) => const OnBoardRoute(); +extension $MainShellRouteExtension on MainShellRoute { + static MainShellRoute _fromState(GoRouterState state) => + const MainShellRoute(); +} + +extension $HomeRouteExtension on HomeRoute { + static HomeRoute _fromState(GoRouterState state) => const HomeRoute(); String get location => GoRouteData.$location( - '/on-board', + '/', ); void go(BuildContext context) => context.go(location); @@ -59,16 +98,11 @@ extension $OnBoardRouteExtension on OnBoardRoute { void replace(BuildContext context) => context.replace(location); } -RouteBase get $accountRoute => GoRouteData.$route( - path: '/accounts', - factory: $AccountRouteExtension._fromState, - ); - -extension $AccountRouteExtension on AccountRoute { - static AccountRoute _fromState(GoRouterState state) => const AccountRoute(); +extension $AlbumsRouteExtension on AlbumsRoute { + static AlbumsRoute _fromState(GoRouterState state) => const AlbumsRoute(); String get location => GoRouteData.$location( - '/accounts', + '/albums', ); void go(BuildContext context) => context.go(location); @@ -81,11 +115,6 @@ extension $AccountRouteExtension on AccountRoute { void replace(BuildContext context) => context.replace(location); } -RouteBase get $transferRoute => GoRouteData.$route( - path: '/transfer', - factory: $TransferRouteExtension._fromState, - ); - extension $TransferRouteExtension on TransferRoute { static TransferRoute _fromState(GoRouterState state) => const TransferRoute(); @@ -103,6 +132,77 @@ extension $TransferRouteExtension on TransferRoute { void replace(BuildContext context) => context.replace(location); } +extension $AccountRouteExtension on AccountRoute { + static AccountRoute _fromState(GoRouterState state) => const AccountRoute(); + + String get location => GoRouteData.$location( + '/accounts', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $addAlbumRoute => GoRouteData.$route( + path: '/add-album', + factory: $AddAlbumRouteExtension._fromState, + ); + +extension $AddAlbumRouteExtension on AddAlbumRoute { + static AddAlbumRoute _fromState(GoRouterState state) => AddAlbumRoute( + $extra: state.extra as Album?, + ); + + String get location => GoRouteData.$location( + '/add-album', + ); + + void go(BuildContext context) => context.go(location, extra: $extra); + + Future push(BuildContext context) => + context.push(location, extra: $extra); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: $extra); + + void replace(BuildContext context) => + context.replace(location, extra: $extra); +} + +RouteBase get $albumMediaListRoute => GoRouteData.$route( + path: '/albums/:albumId', + factory: $AlbumMediaListRouteExtension._fromState, + ); + +extension $AlbumMediaListRouteExtension on AlbumMediaListRoute { + static AlbumMediaListRoute _fromState(GoRouterState state) => + AlbumMediaListRoute( + albumId: state.pathParameters['albumId']!, + $extra: state.extra as Album, + ); + + String get location => GoRouteData.$location( + '/albums/${Uri.encodeComponent(albumId)}', + ); + + void go(BuildContext context) => context.go(location, extra: $extra); + + Future push(BuildContext context) => + context.push(location, extra: $extra); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: $extra); + + void replace(BuildContext context) => + context.replace(location, extra: $extra); +} + RouteBase get $mediaPreviewRoute => GoRouteData.$route( path: '/preview', factory: $MediaPreviewRouteExtension._fromState, @@ -155,3 +255,30 @@ extension $MediaMetadataDetailsRouteExtension on MediaMetadataDetailsRoute { void replace(BuildContext context) => context.replace(location, extra: $extra); } + +RouteBase get $mediaSelectionRoute => GoRouteData.$route( + path: '/select', + factory: $MediaSelectionRouteExtension._fromState, + ); + +extension $MediaSelectionRouteExtension on MediaSelectionRoute { + static MediaSelectionRoute _fromState(GoRouterState state) => + MediaSelectionRoute( + $extra: state.extra as AppMediaSource, + ); + + String get location => GoRouteData.$location( + '/select', + ); + + void go(BuildContext context) => context.go(location, extra: $extra); + + Future push(BuildContext context) => + context.push(location, extra: $extra); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: $extra); + + void replace(BuildContext context) => + context.replace(location, extra: $extra); +} diff --git a/data/.flutter-plugins b/data/.flutter-plugins index 8b0208e4..fde6dc03 100644 --- a/data/.flutter-plugins +++ b/data/.flutter-plugins @@ -2,25 +2,25 @@ flutter_local_notifications=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/flutter_local_notifications-18.0.1/ flutter_local_notifications_linux=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/flutter_local_notifications_linux-5.0.0/ google_sign_in=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in-6.2.2/ -google_sign_in_android=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_android-6.1.33/ +google_sign_in_android=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_android-6.1.34/ google_sign_in_ios=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_ios-5.7.8/ google_sign_in_web=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_web-0.12.4+3/ -package_info_plus=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-8.1.1/ +package_info_plus=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-8.1.2/ path_provider=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider-2.1.5/ -path_provider_android=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_android-2.2.12/ -path_provider_foundation=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.0/ +path_provider_android=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_android-2.2.15/ +path_provider_foundation=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/ path_provider_linux=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/ path_provider_windows=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/ -photo_manager=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/photo_manager-3.6.2/ -shared_preferences=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences-2.3.3/ -shared_preferences_android=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_android-2.3.3/ -shared_preferences_foundation=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.3/ +photo_manager=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/photo_manager-3.6.3/ +shared_preferences=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences-2.3.5/ +shared_preferences_android=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.0/ +shared_preferences_foundation=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/ shared_preferences_linux=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/ shared_preferences_web=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.2/ shared_preferences_windows=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/ sqflite=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/sqflite-2.4.1/ sqflite_android=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/sqflite_android-2.4.0/ -sqflite_darwin=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1/ +sqflite_darwin=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/ url_launcher=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/url_launcher-6.3.1/ url_launcher_android=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/url_launcher_android-6.3.14/ url_launcher_ios=/Users/pratikcanopas/.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ diff --git a/data/.flutter-plugins-dependencies b/data/.flutter-plugins-dependencies index fd2aaa8b..7ce89141 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":"flutter_local_notifications","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/flutter_local_notifications-18.0.1/","native_build":true,"dependencies":[]},{"name":"google_sign_in_ios","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_ios-5.7.8/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-8.1.1/","native_build":true,"dependencies":[]},{"name":"path_provider_foundation","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.0/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"photo_manager","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/photo_manager-3.6.2/","native_build":true,"dependencies":[]},{"name":"shared_preferences_foundation","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.3/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"sqflite_darwin","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"url_launcher_ios","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/","native_build":true,"dependencies":[]}],"android":[{"name":"flutter_local_notifications","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/flutter_local_notifications-18.0.1/","native_build":true,"dependencies":[]},{"name":"google_sign_in_android","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_android-6.1.33/","native_build":true,"dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-8.1.1/","native_build":true,"dependencies":[]},{"name":"path_provider_android","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_android-2.2.12/","native_build":true,"dependencies":[]},{"name":"photo_manager","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/photo_manager-3.6.2/","native_build":true,"dependencies":[]},{"name":"shared_preferences_android","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_android-2.3.3/","native_build":true,"dependencies":[]},{"name":"sqflite_android","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/sqflite_android-2.4.0/","native_build":true,"dependencies":[]},{"name":"url_launcher_android","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/url_launcher_android-6.3.14/","native_build":true,"dependencies":[]}],"macos":[{"name":"flutter_local_notifications","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/flutter_local_notifications-18.0.1/","native_build":true,"dependencies":[]},{"name":"google_sign_in_ios","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_ios-5.7.8/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-8.1.1/","native_build":true,"dependencies":[]},{"name":"path_provider_foundation","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.0/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"photo_manager","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/photo_manager-3.6.2/","native_build":true,"dependencies":[]},{"name":"shared_preferences_foundation","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.3/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"sqflite_darwin","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"url_launcher_macos","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/url_launcher_macos-3.2.2/","native_build":true,"dependencies":[]}],"linux":[{"name":"flutter_local_notifications_linux","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/flutter_local_notifications_linux-5.0.0/","native_build":false,"dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-8.1.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.4.1/","native_build":false,"dependencies":["path_provider_linux"]},{"name":"url_launcher_linux","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.1/","native_build":true,"dependencies":[]}],"windows":[{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-8.1.1/","native_build":false,"dependencies":[]},{"name":"path_provider_windows","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[]},{"name":"shared_preferences_windows","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"]},{"name":"url_launcher_windows","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.3/","native_build":true,"dependencies":[]}],"web":[{"name":"google_sign_in_web","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_web-0.12.4+3/","dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-8.1.1/","dependencies":[]},{"name":"shared_preferences_web","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.2/","dependencies":[]},{"name":"url_launcher_web","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/url_launcher_web-2.3.3/","dependencies":[]}]},"dependencyGraph":[{"name":"flutter_local_notifications","dependencies":["flutter_local_notifications_linux"]},{"name":"flutter_local_notifications_linux","dependencies":[]},{"name":"google_sign_in","dependencies":["google_sign_in_android","google_sign_in_ios","google_sign_in_web"]},{"name":"google_sign_in_android","dependencies":[]},{"name":"google_sign_in_ios","dependencies":[]},{"name":"google_sign_in_web","dependencies":[]},{"name":"package_info_plus","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"photo_manager","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2024-12-24 11:19:49.594419","version":"3.27.1","swift_package_manager_enabled":false} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"flutter_local_notifications","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/flutter_local_notifications-18.0.1/","native_build":true,"dependencies":[]},{"name":"google_sign_in_ios","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_ios-5.7.8/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-8.1.2/","native_build":true,"dependencies":[]},{"name":"path_provider_foundation","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"photo_manager","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/photo_manager-3.6.3/","native_build":true,"dependencies":[]},{"name":"shared_preferences_foundation","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"sqflite_darwin","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"url_launcher_ios","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/","native_build":true,"dependencies":[]}],"android":[{"name":"flutter_local_notifications","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/flutter_local_notifications-18.0.1/","native_build":true,"dependencies":[]},{"name":"google_sign_in_android","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_android-6.1.34/","native_build":true,"dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-8.1.2/","native_build":true,"dependencies":[]},{"name":"path_provider_android","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_android-2.2.15/","native_build":true,"dependencies":[]},{"name":"photo_manager","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/photo_manager-3.6.3/","native_build":true,"dependencies":[]},{"name":"shared_preferences_android","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.0/","native_build":true,"dependencies":[]},{"name":"sqflite_android","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/sqflite_android-2.4.0/","native_build":true,"dependencies":[]},{"name":"url_launcher_android","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/url_launcher_android-6.3.14/","native_build":true,"dependencies":[]}],"macos":[{"name":"flutter_local_notifications","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/flutter_local_notifications-18.0.1/","native_build":true,"dependencies":[]},{"name":"google_sign_in_ios","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_ios-5.7.8/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-8.1.2/","native_build":true,"dependencies":[]},{"name":"path_provider_foundation","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"photo_manager","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/photo_manager-3.6.3/","native_build":true,"dependencies":[]},{"name":"shared_preferences_foundation","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"sqflite_darwin","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"url_launcher_macos","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/url_launcher_macos-3.2.2/","native_build":true,"dependencies":[]}],"linux":[{"name":"flutter_local_notifications_linux","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/flutter_local_notifications_linux-5.0.0/","native_build":false,"dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-8.1.2/","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.4.1/","native_build":false,"dependencies":["path_provider_linux"]},{"name":"url_launcher_linux","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.1/","native_build":true,"dependencies":[]}],"windows":[{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-8.1.2/","native_build":false,"dependencies":[]},{"name":"path_provider_windows","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[]},{"name":"shared_preferences_windows","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"]},{"name":"url_launcher_windows","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.3/","native_build":true,"dependencies":[]}],"web":[{"name":"google_sign_in_web","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/google_sign_in_web-0.12.4+3/","dependencies":[]},{"name":"package_info_plus","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/package_info_plus-8.1.2/","dependencies":[]},{"name":"shared_preferences_web","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.2/","dependencies":[]},{"name":"url_launcher_web","path":"/Users/pratikcanopas/.pub-cache/hosted/pub.dev/url_launcher_web-2.3.3/","dependencies":[]}]},"dependencyGraph":[{"name":"flutter_local_notifications","dependencies":["flutter_local_notifications_linux"]},{"name":"flutter_local_notifications_linux","dependencies":[]},{"name":"google_sign_in","dependencies":["google_sign_in_android","google_sign_in_ios","google_sign_in_web"]},{"name":"google_sign_in_android","dependencies":[]},{"name":"google_sign_in_ios","dependencies":[]},{"name":"google_sign_in_web","dependencies":[]},{"name":"package_info_plus","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"photo_manager","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2025-01-09 09:50:08.848768","version":"3.27.1","swift_package_manager_enabled":false} \ No newline at end of file diff --git a/data/lib/apis/dropbox/dropbox_content_endpoints.dart b/data/lib/apis/dropbox/dropbox_content_endpoints.dart index a35a0b63..80c6be27 100644 --- a/data/lib/apis/dropbox/dropbox_content_endpoints.dart +++ b/data/lib/apis/dropbox/dropbox_content_endpoints.dart @@ -25,7 +25,7 @@ class DropboxCreateFolderEndpoint extends Endpoint { class DropboxListFolderEndpoint extends Endpoint { final bool includeDeleted; - final String appPropertyTemplateId; + final String? appPropertyTemplateId; final bool includeHasExplicitSharedMembers; final int limit; final bool includeMountedFolders; @@ -41,7 +41,7 @@ class DropboxListFolderEndpoint extends Endpoint { this.includeNonDownloadableFiles = false, this.recursive = false, required this.folderPath, - required this.appPropertyTemplateId, + this.appPropertyTemplateId, }); @override @@ -58,10 +58,11 @@ class DropboxListFolderEndpoint extends Endpoint { "include_deleted": includeDeleted, "include_has_explicit_shared_members": includeHasExplicitSharedMembers, "limit": limit, - 'include_property_groups': { - ".tag": "filter_some", - "filter_some": [appPropertyTemplateId], - }, + if (appPropertyTemplateId != null) + 'include_property_groups': { + ".tag": "filter_some", + "filter_some": [appPropertyTemplateId], + }, "include_mounted_folders": includeMountedFolders, "include_non_downloadable_files": includeNonDownloadableFiles, "path": folderPath, @@ -92,7 +93,7 @@ class DropboxListFolderContinueEndpoint extends Endpoint { } class DropboxUploadEndpoint extends Endpoint { - final String appPropertyTemplateId; + final String? appPropertyTemplateId; final String filePath; final String? localRefId; final String mode; @@ -104,7 +105,7 @@ class DropboxUploadEndpoint extends Endpoint { final CancelToken? cancellationToken; const DropboxUploadEndpoint({ - required this.appPropertyTemplateId, + this.appPropertyTemplateId, required this.filePath, this.mode = 'add', this.autoRename = true, @@ -133,17 +134,18 @@ class DropboxUploadEndpoint extends Endpoint { 'autorename': autoRename, 'mute': mute, 'strict_conflict': strictConflict, - 'property_groups': [ - { - "fields": [ - { - "name": ProviderConstants.localRefIdKey, - "value": localRefId ?? '', - }, - ], - "template_id": appPropertyTemplateId, - } - ], + if (appPropertyTemplateId != null && localRefId != null) + 'property_groups': [ + { + "fields": [ + { + "name": ProviderConstants.localRefIdKey, + "value": localRefId ?? '', + }, + ], + "template_id": appPropertyTemplateId, + } + ], }), 'Content-Type': content.contentType, 'Content-Length': content.length, @@ -161,13 +163,13 @@ class DropboxUploadEndpoint extends Endpoint { class DropboxDownloadEndpoint extends DownloadEndpoint { final String filePath; - final String storagePath; + final String? storagePath; final void Function(int chunk, int length)? onProgress; final CancelToken? cancellationToken; const DropboxDownloadEndpoint({ required this.filePath, - required this.storagePath, + this.storagePath, this.cancellationToken, this.onProgress, }); diff --git a/data/lib/apis/google_drive/google_drive_endpoint.dart b/data/lib/apis/google_drive/google_drive_endpoint.dart index 6553bc26..78f819f7 100644 --- a/data/lib/apis/google_drive/google_drive_endpoint.dart +++ b/data/lib/apis/google_drive/google_drive_endpoint.dart @@ -84,12 +84,57 @@ class GoogleDriveUploadEndpoint extends Endpoint { void Function(int p1, int p2)? get onSendProgress => onProgress; } +class GoogleDriveContentUpdateEndpoint extends Endpoint { + final AppMediaContent content; + final String id; + final CancelToken? cancellationToken; + final void Function(int chunk, int length)? onProgress; + + const GoogleDriveContentUpdateEndpoint({ + required this.content, + required this.id, + this.cancellationToken, + this.onProgress, + }); + + @override + String get baseUrl => BaseURL.googleDriveUploadV3; + + @override + CancelToken? get cancelToken => cancellationToken; + + @override + HttpMethod get method => HttpMethod.patch; + + @override + Map get headers => { + 'Content-Type': content.contentType, + 'Content-Length': content.length.toString(), + }; + + @override + Object? get data => content.stream; + + @override + String get path => '/files/$id'; + + @override + Map? get queryParameters => { + 'uploadType': 'media', + 'fields': + 'id, name, description, mimeType, thumbnailLink, webContentLink, createdTime, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, appProperties', + }; + + @override + void Function(int p1, int p2)? get onSendProgress => onProgress; +} + class GoogleDriveDownloadEndpoint extends DownloadEndpoint { final String id; final void Function(int received, int total)? onProgress; - final String saveLocation; + final String? saveLocation; final CancelToken? cancellationToken; @@ -97,7 +142,7 @@ class GoogleDriveDownloadEndpoint extends DownloadEndpoint { required this.id, this.cancellationToken, this.onProgress, - required this.saveLocation, + this.saveLocation, }); @override @@ -171,6 +216,31 @@ class GoogleDriveListEndpoint extends Endpoint { }; } +class GoogleDriveGetEndpoint extends Endpoint { + final String fields; + final String id; + + const GoogleDriveGetEndpoint({ + required this.id, + this.fields = + 'id, name, description, mimeType, thumbnailLink, webContentLink, createdTime, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, appProperties', + }); + + @override + String get baseUrl => BaseURL.googleDriveV3; + + @override + String get path => '/files/$id'; + + @override + HttpMethod get method => HttpMethod.get; + + @override + Map? get queryParameters => { + 'fields': fields, + }; +} + class GoogleDriveUpdateAppPropertiesEndpoint extends Endpoint { final String id; final String localFileId; diff --git a/data/lib/apis/network/client.dart b/data/lib/apis/network/client.dart index 3a282056..ac3b6a78 100644 --- a/data/lib/apis/network/client.dart +++ b/data/lib/apis/network/client.dart @@ -25,9 +25,13 @@ final dropboxAuthenticatedDioProvider = Provider((ref) { rawDio: ref.read(rawDioProvider), dropboxToken: ref.read(AppPreferences.dropboxToken), ); - ref.listen(AppPreferences.dropboxToken, (previous, next) { + final subscription = + ref.listen(AppPreferences.dropboxToken, (previous, next) { dropboxInterceptor.updateToken(next); }); + + ref.onDispose(() => subscription.close()); + return Dio() ..options.connectTimeout = const Duration(seconds: 60) ..options.sendTimeout = const Duration(seconds: 60) diff --git a/data/lib/domain/config.dart b/data/lib/domain/config.dart index 5fc09f7a..6227587f 100644 --- a/data/lib/domain/config.dart +++ b/data/lib/domain/config.dart @@ -1,4 +1,5 @@ class ProviderConstants { + static const String albumFileName = 'Album.json'; static const String backupFolderName = 'Cloud Gallery Backup'; static const String backupFolderPath = '/Cloud Gallery Backup'; static const String localRefIdKey = 'local_ref_id'; @@ -6,6 +7,14 @@ class ProviderConstants { 'Cloud Gallery Local File Information'; } +class LocalDatabaseConstants { + static const String databaseName = 'cloud-gallery.db'; + static const String albumDatabaseName = 'cloud-gallery-album.db'; + static const String uploadQueueTable = 'UploadQueue'; + static const String downloadQueueTable = 'DownloadQueue'; + static const String albumsTable = 'Albums'; +} + class FeatureFlag { static final googleDriveSupport = false; } diff --git a/data/lib/handlers/unique_id_generator.dart b/data/lib/handlers/unique_id_generator.dart new file mode 100644 index 00000000..eb9116bd --- /dev/null +++ b/data/lib/handlers/unique_id_generator.dart @@ -0,0 +1,48 @@ +import 'dart:math' as math; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final uniqueIdGeneratorProvider = Provider((ref) { + return UniqueIdGenerator(); +}); + +class UniqueIdGenerator { + /// Generate a cryptographically secure unique integer ID + /// Response: 15697651741157933000 + int num() { + return int.parse( + List.generate( + 4, + (index) => math.Random.secure().nextInt(1 << 16).toRadixString(16), + ).join(), + radix: 16, + ); + } + + ///Generate Cryptographically secure unique ID in UUIDv4 format + ///https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random) + String v4() { + final random = math.Random.secure(); + // Generate 16 random bytes + final bytes = List.generate(16, (_) => random.nextInt(256)); + + // Set version to 4 (random UUID) + bytes[6] = (bytes[6] & 0x0F) | 0x40; + + // Set variant to 10xx (RFC 4122) + bytes[8] = (bytes[8] & 0x3F) | 0x80; + + // Convert bytes to UUID string + return _bytesToUuidString(bytes); + } + + /// Helper method to convert byte list to UUID string + String _bytesToUuidString(List bytes) { + final buffer = StringBuffer(); + for (int i = 0; i < bytes.length; i++) { + buffer.write(bytes[i].toRadixString(16).padLeft(2, '0')); + if (i == 3 || i == 5 || i == 7 || i == 9) buffer.write('-'); + } + return buffer.toString(); + } +} diff --git a/data/lib/models/album/album.dart b/data/lib/models/album/album.dart new file mode 100644 index 00000000..a570330c --- /dev/null +++ b/data/lib/models/album/album.dart @@ -0,0 +1,22 @@ +// ignore_for_file: non_constant_identifier_names + +import 'package:freezed_annotation/freezed_annotation.dart'; +import '../../domain/json_converters/date_time_json_converter.dart'; +import '../media/media.dart'; + +part 'album.freezed.dart'; + +part 'album.g.dart'; + +@freezed +class Album with _$Album { + const factory Album({ + required String name, + required String id, + required List medias, + required AppMediaSource source, + @DateTimeJsonConverter() required DateTime created_at, + }) = _Album; + + factory Album.fromJson(Map json) => _$AlbumFromJson(json); +} diff --git a/data/lib/models/album/album.freezed.dart b/data/lib/models/album/album.freezed.dart new file mode 100644 index 00000000..61d00ebb --- /dev/null +++ b/data/lib/models/album/album.freezed.dart @@ -0,0 +1,257 @@ +// 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 'album.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'); + +Album _$AlbumFromJson(Map json) { + return _Album.fromJson(json); +} + +/// @nodoc +mixin _$Album { + String get name => throw _privateConstructorUsedError; + String get id => throw _privateConstructorUsedError; + List get medias => throw _privateConstructorUsedError; + AppMediaSource get source => throw _privateConstructorUsedError; + @DateTimeJsonConverter() + DateTime get created_at => throw _privateConstructorUsedError; + + /// Serializes this Album to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of Album + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AlbumCopyWith get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AlbumCopyWith<$Res> { + factory $AlbumCopyWith(Album value, $Res Function(Album) then) = + _$AlbumCopyWithImpl<$Res, Album>; + @useResult + $Res call( + {String name, + String id, + List medias, + AppMediaSource source, + @DateTimeJsonConverter() DateTime created_at}); +} + +/// @nodoc +class _$AlbumCopyWithImpl<$Res, $Val extends Album> + implements $AlbumCopyWith<$Res> { + _$AlbumCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of Album + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? id = null, + Object? medias = null, + Object? source = null, + Object? created_at = null, + }) { + return _then(_value.copyWith( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + medias: null == medias + ? _value.medias + : medias // ignore: cast_nullable_to_non_nullable + as List, + source: null == source + ? _value.source + : source // ignore: cast_nullable_to_non_nullable + as AppMediaSource, + created_at: null == created_at + ? _value.created_at + : created_at // ignore: cast_nullable_to_non_nullable + as DateTime, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$AlbumImplCopyWith<$Res> implements $AlbumCopyWith<$Res> { + factory _$$AlbumImplCopyWith( + _$AlbumImpl value, $Res Function(_$AlbumImpl) then) = + __$$AlbumImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String name, + String id, + List medias, + AppMediaSource source, + @DateTimeJsonConverter() DateTime created_at}); +} + +/// @nodoc +class __$$AlbumImplCopyWithImpl<$Res> + extends _$AlbumCopyWithImpl<$Res, _$AlbumImpl> + implements _$$AlbumImplCopyWith<$Res> { + __$$AlbumImplCopyWithImpl( + _$AlbumImpl _value, $Res Function(_$AlbumImpl) _then) + : super(_value, _then); + + /// Create a copy of Album + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? id = null, + Object? medias = null, + Object? source = null, + Object? created_at = null, + }) { + return _then(_$AlbumImpl( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + medias: null == medias + ? _value._medias + : medias // ignore: cast_nullable_to_non_nullable + as List, + source: null == source + ? _value.source + : source // ignore: cast_nullable_to_non_nullable + as AppMediaSource, + created_at: null == created_at + ? _value.created_at + : created_at // ignore: cast_nullable_to_non_nullable + as DateTime, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$AlbumImpl implements _Album { + const _$AlbumImpl( + {required this.name, + required this.id, + required final List medias, + required this.source, + @DateTimeJsonConverter() required this.created_at}) + : _medias = medias; + + factory _$AlbumImpl.fromJson(Map json) => + _$$AlbumImplFromJson(json); + + @override + final String name; + @override + final String id; + final List _medias; + @override + List get medias { + if (_medias is EqualUnmodifiableListView) return _medias; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_medias); + } + + @override + final AppMediaSource source; + @override + @DateTimeJsonConverter() + final DateTime created_at; + + @override + String toString() { + return 'Album(name: $name, id: $id, medias: $medias, source: $source, created_at: $created_at)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AlbumImpl && + (identical(other.name, name) || other.name == name) && + (identical(other.id, id) || other.id == id) && + const DeepCollectionEquality().equals(other._medias, _medias) && + (identical(other.source, source) || other.source == source) && + (identical(other.created_at, created_at) || + other.created_at == created_at)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, name, id, + const DeepCollectionEquality().hash(_medias), source, created_at); + + /// Create a copy of Album + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AlbumImplCopyWith<_$AlbumImpl> get copyWith => + __$$AlbumImplCopyWithImpl<_$AlbumImpl>(this, _$identity); + + @override + Map toJson() { + return _$$AlbumImplToJson( + this, + ); + } +} + +abstract class _Album implements Album { + const factory _Album( + {required final String name, + required final String id, + required final List medias, + required final AppMediaSource source, + @DateTimeJsonConverter() required final DateTime created_at}) = + _$AlbumImpl; + + factory _Album.fromJson(Map json) = _$AlbumImpl.fromJson; + + @override + String get name; + @override + String get id; + @override + List get medias; + @override + AppMediaSource get source; + @override + @DateTimeJsonConverter() + DateTime get created_at; + + /// Create a copy of Album + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AlbumImplCopyWith<_$AlbumImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/data/lib/models/album/album.g.dart b/data/lib/models/album/album.g.dart new file mode 100644 index 00000000..770b8710 --- /dev/null +++ b/data/lib/models/album/album.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'album.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$AlbumImpl _$$AlbumImplFromJson(Map json) => _$AlbumImpl( + name: json['name'] as String, + id: json['id'] as String, + medias: + (json['medias'] as List).map((e) => e as String).toList(), + source: $enumDecode(_$AppMediaSourceEnumMap, json['source']), + created_at: + const DateTimeJsonConverter().fromJson(json['created_at'] as String), + ); + +Map _$$AlbumImplToJson(_$AlbumImpl instance) => + { + 'name': instance.name, + 'id': instance.id, + 'medias': instance.medias, + 'source': _$AppMediaSourceEnumMap[instance.source]!, + 'created_at': const DateTimeJsonConverter().toJson(instance.created_at), + }; + +const _$AppMediaSourceEnumMap = { + AppMediaSource.local: 'local', + AppMediaSource.googleDrive: 'google_drive', + AppMediaSource.dropbox: 'dropbox', +}; diff --git a/data/lib/models/media/media_extension.dart b/data/lib/models/media/media_extension.dart index 38a755e8..29e19d02 100644 --- a/data/lib/models/media/media_extension.dart +++ b/data/lib/models/media/media_extension.dart @@ -47,7 +47,9 @@ extension AppMediaExtension on AppMedia { name: name ?? media.name, thumbnailLink: media.thumbnailLink, driveMediaRefId: media.driveMediaRefId, - sources: sources.toList()..add(AppMediaSource.googleDrive), + sources: sources.toList() + ..add(AppMediaSource.googleDrive) + ..toSet().toList(), ); } @@ -55,7 +57,7 @@ extension AppMediaExtension on AppMedia { return copyWith( thumbnailLink: null, driveMediaRefId: null, - sources: sources.toList()..remove(AppMediaSource.googleDrive), + sources: sources.toSet().toList()..remove(AppMediaSource.googleDrive), ); } @@ -73,21 +75,23 @@ extension AppMediaExtension on AppMedia { createdTime: createdTime ?? media.createdTime, name: name ?? media.name, dropboxMediaRefId: media.dropboxMediaRefId, - sources: sources.toList()..add(AppMediaSource.dropbox), + sources: sources.toList() + ..add(AppMediaSource.dropbox) + ..toSet().toList(), ); } AppMedia removeDropboxRef() { return copyWith( dropboxMediaRefId: null, - sources: sources.toList()..remove(AppMediaSource.dropbox), + sources: sources.toSet().toList()..remove(AppMediaSource.dropbox), ); } AppMedia removeLocalRef() { return copyWith( id: driveMediaRefId ?? dropboxMediaRefId ?? '', - sources: sources.toList()..remove(AppMediaSource.local), + sources: sources.toSet().toList()..remove(AppMediaSource.local), ); } diff --git a/data/lib/repositories/media_process_repository.dart b/data/lib/repositories/media_process_repository.dart index feebae90..cb63163b 100644 --- a/data/lib/repositories/media_process_repository.dart +++ b/data/lib/repositories/media_process_repository.dart @@ -28,23 +28,20 @@ final mediaProcessRepoProvider = Provider((ref) { ref.read(notificationHandlerProvider), ref.read(AppPreferences.notifications), ); - ref.onDispose(repo.dispose); - ref.listen( + final subscription = ref.listen( AppPreferences.notifications, (previous, next) { repo.updateShowNotification(next); }, ); + ref.onDispose(() { + subscription.close(); + repo.dispose(); + }); return repo; }); -class LocalDatabaseConstants { - static const String databaseName = 'cloud-gallery.db'; - static const String uploadQueueTable = 'UploadQueue'; - static const String downloadQueueTable = 'DownloadQueue'; -} - class ProcessNotificationConstants { static const String uploadProcessGroupIdentifier = 'cloud_gallery_upload_process'; diff --git a/data/lib/services/dropbox_services.dart b/data/lib/services/dropbox_services.dart index b2f454f6..21ac4f8c 100644 --- a/data/lib/services/dropbox_services.dart +++ b/data/lib/services/dropbox_services.dart @@ -1,9 +1,11 @@ +import 'dart:convert'; import 'dart:io'; import 'package:collection/collection.dart'; import '../apis/dropbox/dropbox_content_endpoints.dart'; import '../apis/network/client.dart'; import '../domain/config.dart'; import '../errors/app_error.dart'; +import '../models/album/album.dart'; import '../models/dropbox/account/dropbox_account.dart'; import '../models/media/media.dart'; import '../models/media_content/media_content.dart'; @@ -47,6 +49,8 @@ class DropboxService extends CloudProviderService { } } + //MEDIA ---------------------------------------------------------------------- + Future setFileIdAppPropertyTemplate() async { // Get all the app property templates final res = await _dropboxAuthenticatedDio @@ -127,7 +131,11 @@ class DropboxService extends CloudProviderService { nextPageToken = response.data['cursor']; medias.addAll( (response.data['entries'] as List) - .where((element) => element['.tag'] == 'file') + .where( + (element) => + element['.tag'] == 'file' && + element['name'] != 'Albums.json', + ) .map((e) => AppMedia.fromDropboxJson(json: e)) .toList(), ); @@ -173,7 +181,8 @@ class DropboxService extends CloudProviderService { ); if (response.statusCode == 200) { final files = (response.data['entries'] as List).where( - (element) => element['.tag'] == 'file', + (element) => + element['.tag'] == 'file' && element['name'] != 'Albums.json', ); final metadataResponses = await Future.wait( @@ -221,6 +230,30 @@ class DropboxService extends CloudProviderService { } } + Future getMedia({ + required String id, + }) async { + try { + final res = await _dropboxAuthenticatedDio.req( + DropboxGetFileMetadata(id: id), + ); + + if (res.statusCode == 200) { + return AppMedia.fromDropboxJson(json: res.data, metadataJson: res.data); + } + throw SomethingWentWrongError( + statusCode: res.statusCode, + message: res.statusMessage ?? '', + ); + } catch (e) { + if (e is DioException && + (e.response?.statusCode == 409 || e.response?.statusCode == 404)) { + return null; + } + rethrow; + } + } + @override Future createFolder(String folderName) async { final response = await _dropboxAuthenticatedDio.req( @@ -357,4 +390,120 @@ class DropboxService extends CloudProviderService { message: res.statusMessage, ); } + + // ALBUM --------------------------------------------------------------------- + + Future> getAlbums() async { + try { + final res = await _dropboxAuthenticatedDio.req( + DropboxDownloadEndpoint( + filePath: "/${ProviderConstants.backupFolderName}/Albums.json", + ), + ); + if (res.statusCode != 200 || res.data is! ResponseBody) { + throw SomethingWentWrongError( + statusCode: res.statusCode, + message: res.statusMessage, + ); + } + final List bytes = []; + await for (final chunk in (res.data as ResponseBody).stream) { + bytes.addAll(chunk); + } + final json = jsonDecode(utf8.decode(bytes)); + return json is! List + ? [] + : json.map((e) => Album.fromJson(e)).toList(); + } catch (e) { + if (e is DioException && e.response?.statusCode == 409) { + return []; + } + rethrow; + } + } + + Future createAlbum(Album album) async { + final albums = await getAlbums(); + albums.add(album); + albums.sort((a, b) => b.created_at.compareTo(a.created_at)); + + final res = await _dropboxAuthenticatedDio.req( + DropboxUploadEndpoint( + mode: 'overwrite', + autoRename: false, + content: AppMediaContent( + stream: Stream.value(utf8.encode(jsonEncode(albums))), + length: utf8.encode(jsonEncode(albums)).length, + contentType: 'application/octet-stream', + ), + filePath: "/${ProviderConstants.backupFolderName}/Albums.json", + ), + ); + + if (res.statusCode == 200) return; + + throw SomethingWentWrongError( + statusCode: res.statusCode, + message: res.statusMessage, + ); + } + + Future deleteAlbum(String id) async { + final albums = await getAlbums(); + albums.removeWhere((a) => a.id == id); + + final res = await _dropboxAuthenticatedDio.req( + DropboxUploadEndpoint( + mode: 'overwrite', + autoRename: false, + content: AppMediaContent( + stream: Stream.value(utf8.encode(jsonEncode(albums))), + length: utf8.encode(jsonEncode(albums)).length, + contentType: 'application/octet-stream', + ), + filePath: "/${ProviderConstants.backupFolderName}/Albums.json", + ), + ); + + if (res.statusCode == 200) return; + + throw SomethingWentWrongError( + statusCode: res.statusCode, + message: res.statusMessage, + ); + } + + Future updateAlbum(Album album) async { + final albums = await getAlbums(); + final index = albums.indexWhere((a) => a.id == album.id); + if (index == -1) { + throw SomethingWentWrongError( + statusCode: 404, + message: 'Album not found', + ); + } + + albums[index] = album; + albums.sort((a, b) => b.created_at.compareTo(a.created_at)); + + final res = await _dropboxAuthenticatedDio.req( + DropboxUploadEndpoint( + mode: 'overwrite', + autoRename: false, + content: AppMediaContent( + stream: Stream.value(utf8.encode(jsonEncode(albums))), + length: utf8.encode(jsonEncode(albums)).length, + contentType: 'application/octet-stream', + ), + filePath: "/${ProviderConstants.backupFolderName}/Albums.json", + ), + ); + + if (res.statusCode == 200) return; + + throw SomethingWentWrongError( + statusCode: res.statusCode, + message: res.statusMessage, + ); + } } diff --git a/data/lib/services/google_drive_service.dart b/data/lib/services/google_drive_service.dart index 521e74ad..c4ba390e 100644 --- a/data/lib/services/google_drive_service.dart +++ b/data/lib/services/google_drive_service.dart @@ -1,8 +1,11 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import '../apis/google_drive/google_drive_endpoint.dart'; import '../apis/network/client.dart'; import '../domain/config.dart'; +import '../models/album/album.dart'; import '../models/media/media.dart'; import '../models/media_content/media_content.dart'; import 'package:dio/dio.dart'; @@ -22,6 +25,62 @@ class GoogleDriveService extends CloudProviderService { GoogleDriveService(this._client); + // FOLDERS ------------------------------------------------------------------- + + Future getBackUpFolderId() async { + final res = await _client.req( + GoogleDriveListEndpoint( + q: "name='${ProviderConstants.backupFolderName}' and trashed=false and mimeType='application/vnd.google-apps.folder'", + pageSize: 1, + ), + ); + + if (res.statusCode == 200) { + final body = drive.FileList.fromJson(res.data); + if (body.files?.isNotEmpty ?? false) { + return body.files?.first.id; + } else { + final createRes = await _client.req( + GoogleDriveCreateFolderEndpoint( + name: ProviderConstants.backupFolderName, + ), + ); + + if (createRes.statusCode == 200) { + return drive.File.fromJson(createRes.data).id; + } + + throw SomethingWentWrongError( + statusCode: createRes.statusCode, + message: createRes.statusMessage, + ); + } + } + + throw SomethingWentWrongError( + statusCode: res.statusCode, + message: res.statusMessage, + ); + } + + @override + Future createFolder(String folderName) async { + final res = await _client.req( + GoogleDriveCreateFolderEndpoint(name: folderName), + ); + + if (res.statusCode == 200) { + return drive.File.fromJson(res.data).id; + } + + throw SomethingWentWrongError( + statusCode: res.statusCode, + message: res.statusMessage, + ); + } + + // MEDIA --------------------------------------------------------------------- + @override Future> getAllMedias({ required String folder, @@ -33,7 +92,7 @@ class GoogleDriveService extends CloudProviderService { while (hasMore) { final res = await _client.req( GoogleDriveListEndpoint( - q: "'$folder' in parents and trashed=false", + q: "'$folder' in parents and trashed=false and name!='${ProviderConstants.albumFileName}", pageSize: 1000, pageToken: pageToken, ), @@ -70,7 +129,7 @@ class GoogleDriveService extends CloudProviderService { }) async { final res = await _client.req( GoogleDriveListEndpoint( - q: "'$folder' in parents and trashed=false", + q: "'$folder' in parents and trashed=false and name!='${ProviderConstants.albumFileName}'", pageSize: pageSize, pageToken: nextPageToken, ), @@ -95,66 +154,37 @@ class GoogleDriveService extends CloudProviderService { ); } - @override - Future deleteMedia({ + Future getMedia({ required String id, - CancelToken? cancelToken, }) async { - final res = await _client.req(GoogleDriveDeleteEndpoint(id: id)); - - if (res.statusCode == 200 || res.statusCode == 204) return; - - throw SomethingWentWrongError( - statusCode: res.statusCode, - message: res.statusMessage, - ); - } + try { + final res = await _client.req(GoogleDriveGetEndpoint(id: id)); - Future getBackUpFolderId() async { - final res = await _client.req( - GoogleDriveListEndpoint( - q: "name='${ProviderConstants.backupFolderName}' and trashed=false and mimeType='application/vnd.google-apps.folder'", - pageSize: 1, - ), - ); - - if (res.statusCode == 200) { - final body = drive.FileList.fromJson(res.data); - if (body.files?.isNotEmpty ?? false) { - return body.files?.first.id; - } else { - final createRes = await _client.req( - GoogleDriveCreateFolderEndpoint( - name: ProviderConstants.backupFolderName, - ), - ); - - if (createRes.statusCode == 200) { - return drive.File.fromJson(createRes.data).id; - } + if (res.statusCode == 200) { + return AppMedia.fromGoogleDriveFile(drive.File.fromJson(res.data)); + } - throw SomethingWentWrongError( - statusCode: createRes.statusCode, - message: createRes.statusMessage, - ); + throw SomethingWentWrongError( + statusCode: res.statusCode, + message: res.statusMessage, + ); + } catch (e) { + if (e is DioException && + (e.response?.statusCode == 404 || e.response?.statusCode == 409)) { + return null; } + rethrow; } - - throw SomethingWentWrongError( - statusCode: res.statusCode, - message: res.statusMessage, - ); } @override - Future createFolder(String folderName) async { - final res = await _client.req( - GoogleDriveCreateFolderEndpoint(name: folderName), - ); + Future deleteMedia({ + required String id, + CancelToken? cancelToken, + }) async { + final res = await _client.req(GoogleDriveDeleteEndpoint(id: id)); - if (res.statusCode == 200) { - return drive.File.fromJson(res.data).id; - } + if (res.statusCode == 200 || res.statusCode == 204) return; throw SomethingWentWrongError( statusCode: res.statusCode, @@ -247,4 +277,297 @@ class GoogleDriveService extends CloudProviderService { message: res.statusMessage, ); } + + //ALBUMS --------------------------------------------------------------------- + + Future> getAlbums({required String folderId}) async { + final res = await _client.req( + GoogleDriveListEndpoint( + q: "'$folderId' in parents and trashed=false and name='${ProviderConstants.albumFileName}'", + pageSize: 1, + ), + ); + + if (res.statusCode == 200) { + final body = drive.FileList.fromJson(res.data); + if ((body.files ?? []).isNotEmpty) { + final res = await _client.req( + GoogleDriveDownloadEndpoint(id: body.files!.first.id!), + ); + if (res.statusCode == 200) { + final List bytes = []; + await for (final chunk in (res.data as ResponseBody).stream) { + bytes.addAll(chunk); + } + final json = jsonDecode(utf8.decode(bytes)); + return json is! List + ? [] + : json.map((e) => Album.fromJson(e)).toList(); + } + + throw SomethingWentWrongError( + statusCode: res.statusCode, + message: res.statusMessage, + ); + } + return []; + } + + throw SomethingWentWrongError( + statusCode: res.statusCode, + message: res.statusMessage, + ); + } + + Future createAlbum({ + required String folderId, + required Album newAlbum, + }) async { + // Fetch the album file + final listRes = await _client.req( + GoogleDriveListEndpoint( + q: "'$folderId' in parents and trashed=false and name='${ProviderConstants.albumFileName}'", + pageSize: 1, + ), + ); + + if (listRes.statusCode != 200) { + throw SomethingWentWrongError( + statusCode: listRes.statusCode, + message: listRes.statusMessage, + ); + } + + final body = drive.FileList.fromJson(listRes.data); + + if ((body.files ?? []).isNotEmpty) { + // Download the album file if it exists + final res = await _client.req( + GoogleDriveDownloadEndpoint(id: body.files!.first.id!), + ); + + if (res.statusCode != 200) { + throw SomethingWentWrongError( + statusCode: res.statusCode, + message: res.statusMessage, + ); + } + + // Convert the downloaded album file to a list of albums + final List bytes = []; + await for (final chunk in (res.data as ResponseBody).stream) { + bytes.addAll(chunk); + } + final json = jsonDecode(utf8.decode(bytes)); + final albums = json is! List + ? [] + : json.map((e) => Album.fromJson(e)).toList(); + + // Attach the new album to the list of albums + albums.add(newAlbum); + albums.sort((a, b) => a.created_at.compareTo(b.created_at)); + + // Update the album file with the new list of albums + final updateRes = await _client.req( + GoogleDriveContentUpdateEndpoint( + id: body.files!.first.id!, + content: AppMediaContent( + stream: Stream.value( + Uint8List.fromList(utf8.encode(jsonEncode(albums))), + ), + length: utf8.encode(jsonEncode(albums)).length, + contentType: 'application/json', + ), + ), + ); + + if (updateRes.statusCode == 200) return; + + throw SomethingWentWrongError( + statusCode: updateRes.statusCode, + message: updateRes.statusMessage, + ); + } + + // Create a new album file if it doesn't exist + final res = await _client.req( + GoogleDriveUploadEndpoint( + request: drive.File( + name: ProviderConstants.albumFileName, + mimeType: 'application/json', + parents: [folderId], + ), + content: AppMediaContent( + stream: Stream.value( + Uint8List.fromList(utf8.encode(jsonEncode([newAlbum.toJson()]))), + ), + length: utf8.encode(jsonEncode([newAlbum.toJson()])).length, + contentType: 'application/json', + ), + ), + ); + + if (res.statusCode == 200) return; + + throw SomethingWentWrongError( + statusCode: res.statusCode, + message: res.statusMessage, + ); + } + + Future updateAlbum({ + required String folderId, + required Album album, + }) async { + // Fetch the album file + final listRes = await _client.req( + GoogleDriveListEndpoint( + q: "'$folderId' in parents and trashed=false and name='${ProviderConstants.albumFileName}'", + pageSize: 1, + ), + ); + + if (listRes.statusCode != 200) { + throw SomethingWentWrongError( + statusCode: listRes.statusCode, + message: listRes.statusMessage, + ); + } + + final body = drive.FileList.fromJson(listRes.data); + + if ((body.files ?? []).isNotEmpty) { + // Download the album file if it exists + final res = await _client.req( + GoogleDriveDownloadEndpoint(id: body.files!.first.id!), + ); + + if (res.statusCode != 200) { + throw SomethingWentWrongError( + statusCode: res.statusCode, + message: res.statusMessage, + ); + } + + // Convert the downloaded album file to a list of albums + final List bytes = []; + await for (final chunk in (res.data as ResponseBody).stream) { + bytes.addAll(chunk); + } + final json = jsonDecode(utf8.decode(bytes)); + final albums = json is! List + ? [] + : json.map((e) => Album.fromJson(e)).toList(); + + // Attach the new album to the list of albums + if (albums.where((element) => element.id == album.id).isEmpty) { + throw SomethingWentWrongError( + message: 'Album not found', + ); + } + + albums.removeWhere((element) => element.id == album.id); + albums.add(album); + albums.sort((a, b) => a.created_at.compareTo(b.created_at)); + + // Update the album file with the new list of albums + final updateRes = await _client.req( + GoogleDriveContentUpdateEndpoint( + id: body.files!.first.id!, + content: AppMediaContent( + stream: Stream.value( + Uint8List.fromList(utf8.encode(jsonEncode(albums))), + ), + length: utf8.encode(jsonEncode(albums)).length, + contentType: 'application/json', + ), + ), + ); + + if (updateRes.statusCode == 200) return; + + throw SomethingWentWrongError( + statusCode: updateRes.statusCode, + message: updateRes.statusMessage, + ); + } + + throw SomethingWentWrongError( + message: 'Album file not found', + ); + } + + Future removeAlbum({ + required String folderId, + required String id, + }) async { + // Fetch the album file + final listRes = await _client.req( + GoogleDriveListEndpoint( + q: "'$folderId' in parents and trashed=false and name='${ProviderConstants.albumFileName}'", + pageSize: 1, + ), + ); + + if (listRes.statusCode != 200) { + throw SomethingWentWrongError( + statusCode: listRes.statusCode, + message: listRes.statusMessage, + ); + } + + final body = drive.FileList.fromJson(listRes.data); + + if ((body.files ?? []).isNotEmpty) { + // Download the album file if it exists + final res = await _client.req( + GoogleDriveDownloadEndpoint(id: body.files!.first.id!), + ); + + if (res.statusCode != 200) { + throw SomethingWentWrongError( + statusCode: res.statusCode, + message: res.statusMessage, + ); + } + + // Convert the downloaded album file to a list of albums + final List bytes = []; + await for (final chunk in (res.data as ResponseBody).stream) { + bytes.addAll(chunk); + } + final json = jsonDecode(utf8.decode(bytes)); + final albums = json is! List + ? [] + : json.map((e) => Album.fromJson(e)).toList(); + + // Attach the new album to the list of albums + albums.removeWhere((element) => element.id == id); + + // Update the album file with the new list of albums + final updateRes = await _client.req( + GoogleDriveContentUpdateEndpoint( + id: body.files!.first.id!, + content: AppMediaContent( + stream: Stream.value( + Uint8List.fromList(utf8.encode(jsonEncode(albums))), + ), + length: utf8.encode(jsonEncode(albums)).length, + contentType: 'application/json', + ), + ), + ); + + if (updateRes.statusCode == 200) return; + + throw SomethingWentWrongError( + statusCode: updateRes.statusCode, + message: updateRes.statusMessage, + ); + } + + throw SomethingWentWrongError( + message: 'Album file not found', + ); + } } diff --git a/data/lib/services/local_media_service.dart b/data/lib/services/local_media_service.dart index be92d182..3cc87ed0 100644 --- a/data/lib/services/local_media_service.dart +++ b/data/lib/services/local_media_service.dart @@ -1,5 +1,9 @@ import 'dart:async'; import 'dart:io'; +import 'package:sqflite/sqflite.dart'; +import '../domain/config.dart'; +import '../domain/json_converters/date_time_json_converter.dart'; +import '../models/album/album.dart'; import '../models/media/media.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -11,6 +15,8 @@ final localMediaServiceProvider = Provider( class LocalMediaService { const LocalMediaService(); + // MEDIA --------------------------------------------------------------------- + Future isLocalFileExist({ required AppMediaType type, required String id, @@ -68,6 +74,12 @@ class LocalMediaService { return files.nonNulls.toList(); } + Future getMedia({required String id}) async { + final asset = await AssetEntity.fromId(id); + if (asset == null) return null; + return AppMedia.fromAssetEntity(asset); + } + Future> deleteMedias(List medias) async { return await PhotoManager.editor.deleteWithIds(medias); } @@ -90,4 +102,87 @@ class LocalMediaService { } return asset != null ? AppMedia.fromAssetEntity(asset) : null; } + + // ALBUM --------------------------------------------------------------------- + + Future openAlbumDatabase() async { + return await openDatabase( + LocalDatabaseConstants.albumDatabaseName, + version: 1, + onCreate: (Database db, int version) async { + await db.execute( + 'CREATE TABLE ${LocalDatabaseConstants.albumsTable} (' + 'id TEXT PRIMARY KEY, ' + 'name TEXT NOT NULL, ' + 'source TEXT NOT NULL, ' + 'created_at TEXT NOT NULL, ' + 'medias TEXT NOT NULL ' + ')', + ); + }, + ); + } + + Future createAlbum({required String id, required String name}) async { + final db = await openAlbumDatabase(); + await db.insert( + LocalDatabaseConstants.albumsTable, + { + 'id': id, + 'name': name, + 'source': AppMediaSource.local.value, + 'created_at': DateTimeJsonConverter().toJson(DateTime.now()), + 'medias': '', + }, + ); + await db.close(); + } + + Future updateAlbum(Album album) async { + final db = await openAlbumDatabase(); + await db.rawUpdate( + 'UPDATE ${LocalDatabaseConstants.albumsTable} SET ' + 'name = ?, ' + 'medias = ? ' + 'WHERE id = ?', + [ + album.name, + album.medias.join(','), + album.id, + ], + ); + await db.close(); + } + + Future deleteAlbum(String id) async { + final db = await openAlbumDatabase(); + await db.delete( + LocalDatabaseConstants.albumsTable, + where: 'id = ?', + whereArgs: [id], + ); + await db.close(); + } + + Future> getAlbums() async { + final db = await openAlbumDatabase(); + final albums = await db.query(LocalDatabaseConstants.albumsTable); + await db.close(); + return albums + .map( + (album) => Album( + id: album['id'] as String, + name: album['name'] as String, + source: AppMediaSource.values.firstWhere( + (source) => source.value == album['source'], + ), + created_at: + DateTimeJsonConverter().fromJson(album['created_at'] as String), + medias: (album['medias'] as String).trim().isEmpty + ? [] + : (album['medias'] as String).trim().split(','), + ), + ) + .toList(); + } } diff --git a/style/lib/buttons/action_button.dart b/style/lib/buttons/action_button.dart index 43031408..2086b429 100644 --- a/style/lib/buttons/action_button.dart +++ b/style/lib/buttons/action_button.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import '../indicators/circular_progress_indicator.dart'; class ActionButton extends StatelessWidget { - final void Function() onPressed; + final void Function()? onPressed; final Widget icon; final bool progress; final MaterialTapTargetSize tapTargetSize; @@ -14,7 +14,7 @@ class ActionButton extends StatelessWidget { const ActionButton({ super.key, - required this.onPressed, + this.onPressed, required this.icon, this.size = 40, this.tapTargetSize = MaterialTapTargetSize.padded, diff --git a/style/lib/buttons/radio_selection_button.dart b/style/lib/buttons/radio_selection_button.dart new file mode 100644 index 00000000..d207be82 --- /dev/null +++ b/style/lib/buttons/radio_selection_button.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import '../animations/on_tap_scale.dart'; +import '../extensions/context_extensions.dart'; +import '../text/app_text_style.dart'; + +class RadioSelectionButton extends StatelessWidget { + final T value; + final T groupValue; + final String label; + final void Function() onTab; + + const RadioSelectionButton({ + super.key, + required this.value, + required this.groupValue, + required this.onTab, + required this.label, + }); + + @override + Widget build(BuildContext context) { + return OnTapScale( + onTap: onTab, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + border: Border.all( + color: value == groupValue + ? context.colorScheme.primary + : context.colorScheme.outline, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + IgnorePointer( + child: Material( + color: Colors.transparent, + child: Radio( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + value: value, + groupValue: groupValue, + onChanged: (_) {}, + ), + ), + ), + Text( + label, + style: AppTextStyles.subtitle1.copyWith( + color: value == groupValue + ? context.colorScheme.textPrimary + : context.colorScheme.textSecondary, + ), + ), + ], + ), + ), + ); + } +} diff --git a/style/lib/callback/on_visible_callback.dart b/style/lib/callback/on_visible_callback.dart new file mode 100644 index 00000000..be9cae69 --- /dev/null +++ b/style/lib/callback/on_visible_callback.dart @@ -0,0 +1,28 @@ +import 'package:flutter/cupertino.dart'; + +class OnVisibleCallback extends StatefulWidget { + final void Function() onVisible; + final Widget child; + + const OnVisibleCallback({ + super.key, + required this.onVisible, + required this.child, + }); + + @override + State createState() => _OnCreateState(); +} + +class _OnCreateState extends State { + @override + void initState() { + widget.onVisible(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/style/lib/text/app_text_field.dart b/style/lib/text/app_text_field.dart new file mode 100644 index 00000000..74e9188f --- /dev/null +++ b/style/lib/text/app_text_field.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../extensions/context_extensions.dart'; +import 'app_text_style.dart'; + +class AppTextField extends StatelessWidget { + final String? label; + final TextStyle? labelStyle; + final TextEditingController? controller; + final int? maxLines; + final int? minLines; + final TextStyle? style; + final bool expands; + final bool enabled; + final BorderRadius? borderRadius; + final AppTextFieldBorderType borderType; + final double borderWidth; + final TextInputAction? textInputAction; + final String? hintText; + final String? errorText; + final bool? isDense; + final EdgeInsetsGeometry? contentPadding; + final bool? isCollapsed; + final TextStyle? hintStyle; + final Function(String)? onChanged; + final bool autoFocus; + final TextInputType? keyboardType; + final FocusNode? focusNode; + final bool? filled; + final Color? fillColor; + final TextAlign textAlign; + final List? inputFormatters; + final Widget? prefix; + final Widget? suffix; + final Function(PointerDownEvent)? onTapOutside; + final Function()? onTap; + final void Function(String)? onSubmitted; + + const AppTextField({ + super.key, + this.label, + this.labelStyle, + this.controller, + this.maxLines = 1, + this.minLines, + this.style, + this.expands = false, + this.enabled = true, + this.onChanged, + this.borderType = AppTextFieldBorderType.outline, + this.borderWidth = 1, + this.textInputAction, + this.borderRadius, + this.hintText, + this.hintStyle, + this.errorText, + this.contentPadding, + this.isDense, + this.isCollapsed, + this.autoFocus = false, + this.keyboardType, + this.focusNode, + this.textAlign = TextAlign.start, + this.onTapOutside, + this.filled = true, + this.fillColor, + this.prefix, + this.suffix, + this.inputFormatters, + this.onTap, + this.onSubmitted, + }); + + @override + Widget build(BuildContext context) => + label != null ? _textFieldWithLabel(context) : _textField(context); + + Widget _textFieldWithLabel(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 12.0), + child: Text( + label!, + style: labelStyle ?? + AppTextStyles.body2.copyWith( + color: context.colorScheme.textDisabled, + ), + ), + ), + const SizedBox(height: 6), + _textField(context), + ], + ); + + Widget _textField(BuildContext context) => Material( + color: Colors.transparent, + child: TextField( + contextMenuBuilder: + (BuildContext context, EditableTextState editableTextState) { + // If supported, show the system context menu. + if (SystemContextMenu.isSupported(context)) { + return SystemContextMenu.editableText( + editableTextState: editableTextState, + ); + } + // Otherwise, show the flutter-rendered context menu for the current + // platform. + return AdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState, + ); + }, + onSubmitted: onSubmitted, + inputFormatters: inputFormatters, + controller: controller, + onChanged: onChanged, + onTap: onTap, + enabled: enabled, + maxLines: maxLines, + minLines: minLines, + expands: expands, + textInputAction: textInputAction, + autofocus: autoFocus, + keyboardType: keyboardType, + focusNode: focusNode, + textAlign: textAlign, + textCapitalization: TextCapitalization.sentences, + style: style ?? + AppTextStyles.subtitle1.copyWith( + color: context.colorScheme.textPrimary, + ), + onTapOutside: onTapOutside ?? + (event) { + FocusManager.instance.primaryFocus?.unfocus(); + }, + decoration: InputDecoration( + hintFadeDuration: const Duration(milliseconds: 300), + filled: filled, + fillColor: fillColor ?? context.colorScheme.containerLow, + isDense: isDense, + isCollapsed: isCollapsed, + hintText: hintText, + hintStyle: hintStyle ?? + AppTextStyles.body.copyWith( + color: context.colorScheme.textDisabled, + ), + focusedBorder: _border(context, true), + enabledBorder: _border(context, false), + disabledBorder: _border(context, false), + contentPadding: contentPadding ?? + const EdgeInsets.symmetric( + horizontal: 16, + vertical: 13.5, + ), + prefixIcon: prefix, + suffix: suffix, + errorText: errorText, + ), + ), + ); + + InputBorder _border(BuildContext context, bool focused) { + switch (borderType) { + case AppTextFieldBorderType.none: + return const UnderlineInputBorder( + borderSide: BorderSide.none, + ); + case AppTextFieldBorderType.outline: + return OutlineInputBorder( + borderRadius: borderRadius ?? BorderRadius.circular(12), + borderSide: BorderSide( + color: focused + ? context.colorScheme.primary + : context.colorScheme.outline, + width: borderWidth, + ), + ); + case AppTextFieldBorderType.underline: + return UnderlineInputBorder( + borderSide: BorderSide( + color: focused + ? context.colorScheme.primary + : context.colorScheme.outline, + width: borderWidth, + ), + ); + } + } +} + +enum AppTextFieldBorderType { + none, + outline, + underline, +} diff --git a/style/lib/theme/app_theme_builder.dart b/style/lib/theme/app_theme_builder.dart index 03b7e2e5..fdfa6fa7 100644 --- a/style/lib/theme/app_theme_builder.dart +++ b/style/lib/theme/app_theme_builder.dart @@ -29,6 +29,7 @@ class AppThemeBuilder { scaffoldBackgroundColor: colorScheme.surface, appBarTheme: AppBarTheme( backgroundColor: colorScheme.surface, + centerTitle: true, surfaceTintColor: colorScheme.surface, foregroundColor: colorScheme.textPrimary, scrolledUnderElevation: 3, @@ -43,7 +44,7 @@ class AppThemeBuilder { brightness: colorScheme.brightness, primaryColor: colorScheme.primary, primaryContrastingColor: colorScheme.onPrimary, - barBackgroundColor: colorScheme.barColor, + barBackgroundColor: colorScheme.surface, scaffoldBackgroundColor: colorScheme.surface, textTheme: CupertinoTextThemeData( primaryColor: colorScheme.textPrimary, diff --git a/style/lib/theme/colors.dart b/style/lib/theme/colors.dart index c41b7bc2..09a4215b 100644 --- a/style/lib/theme/colors.dart +++ b/style/lib/theme/colors.dart @@ -20,9 +20,6 @@ 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 71531a21..e9a320ef 100644 --- a/style/lib/theme/theme.dart +++ b/style/lib/theme/theme.dart @@ -32,7 +32,6 @@ class AppColorScheme { final Color outline; final Color textPrimary; final Color textSecondary; - final Color barColor; final Color textDisabled; final Color outlineInverse; final Color textInversePrimary; @@ -66,7 +65,6 @@ class AppColorScheme { required this.textDisabled, required this.outlineInverse, required this.textInversePrimary, - required this.barColor, required this.textInverseSecondary, required this.textInverseDisabled, required this.containerNormalInverse, @@ -120,7 +118,6 @@ final appColorSchemeLight = AppColorScheme( onPrimary: AppColors.textPrimaryDarkColor, onSecondary: AppColors.textSecondaryDarkColor, onDisabled: AppColors.textDisabledLightColor, - barColor: AppColors.barLightColor, brightness: Brightness.light, ); @@ -152,6 +149,5 @@ final appColorSchemeDark = AppColorScheme( onPrimary: AppColors.textPrimaryDarkColor, onSecondary: AppColors.textSecondaryDarkColor, onDisabled: AppColors.textDisabledDarkColor, - barColor: AppColors.barDarkColor, brightness: Brightness.dark, );