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