diff --git a/android/app/build.gradle b/android/app/build.gradle index d4ecda16..7c19d6f7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -36,7 +36,7 @@ apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { namespace "com.emilzulufov.flutter_instagram_offline_first_clone" - compileSdkVersion flutter.compileSdkVersion + compileSdkVersion 34 ndkVersion flutter.ndkVersion compileOptions { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7957d51c..dbba885d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -31,7 +31,8 @@ package="com.emilzulufov.flutter_instagram_offline_first_clone"> + android:icon="@mipmap/ic_launcher" + android:requestLegacyExternalStorage="true"> 10.0) - FirebaseCore (10.18.0): - FirebaseCoreInternal (~> 10.0) - GoogleUtilities/Environment (~> 7.12) - GoogleUtilities/Logger (~> 7.12) - - FirebaseCoreInternal (10.19.0): + - FirebaseCoreInternal (10.20.0): - "GoogleUtilities/NSData+zlib (~> 7.8)" - - FirebaseInstallations (10.19.0): + - FirebaseInstallations (10.20.0): - FirebaseCore (~> 10.0) - GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8) @@ -89,7 +59,7 @@ PODS: - FirebaseSharedSwift (~> 10.0) - GoogleUtilities/Environment (~> 7.8) - "GoogleUtilities/NSData+zlib (~> 7.8)" - - FirebaseSharedSwift (10.19.0) + - FirebaseSharedSwift (10.20.0) - Flutter (1.0.0) - flutter_image_compress_common (1.0.0): - Flutter @@ -168,10 +138,10 @@ PODS: - Flutter - FlutterMacOS - PromisesObjC (2.3.1) - - SDWebImage (5.18.3): - - SDWebImage/Core (= 5.18.3) - - SDWebImage/Core (5.18.3) - - SDWebImageWebPCoder (0.14.0): + - SDWebImage (5.18.10): + - SDWebImage/Core (= 5.18.10) + - SDWebImage/Core (5.18.10) + - SDWebImageWebPCoder (0.14.2): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - shared_preferences_foundation (0.0.1): @@ -195,17 +165,20 @@ PODS: - sqlite3/fts5 - sqlite3/perf-threadsafe - sqlite3/rtree - - SwiftyGif (5.4.4) - Toast (4.1.0) - url_launcher_ios (0.0.1): - Flutter - video_player_avfoundation (0.0.1): - Flutter - FlutterMacOS + - video_thumbnail (0.0.1): + - Flutter + - libwebp DEPENDENCIES: - app_links (from `.symlinks/plugins/app_links/ios`) - - file_picker (from `.symlinks/plugins/file_picker/ios`) + - blurhash (from `.symlinks/plugins/blurhash/ios`) + - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - firebase_remote_config (from `.symlinks/plugins/firebase_remote_config/ios`) @@ -223,12 +196,11 @@ DEPENDENCIES: - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) + - video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`) SPEC REPOS: trunk: - AppAuth - - DKImagePickerController - - DKPhotoGallery - Firebase - FirebaseABTesting - FirebaseCore @@ -250,14 +222,15 @@ SPEC REPOS: - SDWebImage - SDWebImageWebPCoder - sqlite3 - - SwiftyGif - Toast EXTERNAL SOURCES: app_links: :path: ".symlinks/plugins/app_links/ios" - file_picker: - :path: ".symlinks/plugins/file_picker/ios" + blurhash: + :path: ".symlinks/plugins/blurhash/ios" + camera_avfoundation: + :path: ".symlinks/plugins/camera_avfoundation/ios" firebase_core: :path: ".symlinks/plugins/firebase_core/ios" firebase_messaging: @@ -292,24 +265,25 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" video_player_avfoundation: :path: ".symlinks/plugins/video_player_avfoundation/darwin" + video_thumbnail: + :path: ".symlinks/plugins/video_thumbnail/ios" SPEC CHECKSUMS: app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875 AppAuth: 3bb1d1cd9340bd09f5ed189fb00b1cc28e1e8570 - DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac - DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de + blurhash: 7f29ee116e0c2674e0a765b5df1f9767239bc279 + camera_avfoundation: 8b8d780bcfb6a4a02b0fbe2b4bd17b5b71946e68 Firebase: 414ad272f8d02dfbf12662a9d43f4bba9bec2a06 firebase_core: 0af4a2b24f62071f9bf283691c0ee41556dcb3f5 firebase_messaging: 90e8a6db84b6e1e876cebce4f30f01dc495e7014 firebase_remote_config: 14ad7a3c99810d373ba1ad4a754a8cf26c91d476 - FirebaseABTesting: bfa3b384b68cee10a89183649c64cd7998a37a12 + FirebaseABTesting: 1d5d49804bcfc5fa782bc2491a8c1364e2cf7241 FirebaseCore: 2322423314d92f946219c8791674d2f3345b598f - FirebaseCoreInternal: b444828ea7cfd594fca83046b95db98a2be4f290 - FirebaseInstallations: 033d199474164db20c8350736842a94fe717b960 + FirebaseCoreInternal: efeeb171ac02d623bdaefe121539939821e10811 + FirebaseInstallations: 558b1da7d65afeb996fd5c814332f013234ece4e FirebaseMessaging: 9bc34a98d2e0237e1b121915120d4d48ddcf301e FirebaseRemoteConfig: bbd42790a4e84fde6aab7eae810b608e7b5c0bf6 - FirebaseSharedSwift: f34eeb7d3ea87a34497629b6ca41657beadef76a + FirebaseSharedSwift: 2fbf73618288b7a36b2014b957745dcdd781389e Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_image_compress_common: ec1d45c362c9d30a3f6a0426c297f47c52007e3e fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 @@ -329,16 +303,16 @@ SPEC CHECKSUMS: path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604 PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 - SDWebImage: 96e0c18ef14010b7485210e92fac888587ebb958 - SDWebImageWebPCoder: 3027af94522d94df79c21c070894c8a2128927ff + SDWebImage: fc8f2d48bbfd72ef39d70e981bd24a3f3be53fec + SDWebImageWebPCoder: 633b813fca24f1de5e076bcd7f720c038b23892b shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a sqlite3: 6e2d4a4879854d0ec86b476bf3c3e30870bac273 sqlite3_flutter_libs: eb769059df0356dc52ddda040f09cacc9391a7cf - SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f Toast: ec33c32b8688982cecc6348adeae667c1b9938da url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812 video_player_avfoundation: 02011213dab73ae3687df27ce441fbbcc82b5579 + video_thumbnail: c4e2a3c539e247d4de13cd545344fd2d26ffafd1 PODFILE CHECKSUM: 0805b11bfb13bd44fc55fe52946ce14f22a2998e diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index e14115a4..f6eedddb 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + FLTEnableImpeller + CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion diff --git a/lib/auth/components/signup/components/signup_button.dart b/lib/auth/components/signup/components/signup_button.dart index 1ca8300a..a0d36dd3 100644 --- a/lib/auth/components/signup/components/signup_button.dart +++ b/lib/auth/components/signup/components/signup_button.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:app_ui/app_ui.dart'; import 'package:flutter/material.dart'; @@ -9,12 +8,10 @@ import 'package:flutter_instagram_offline_first_clone/auth/components/signup/cub class SignupButton extends StatelessWidget { const SignupButton({ super.key, - this.imageBytes, - this.file, + this.avatarFile, }); - final Uint8List? imageBytes; - final File? file; + final File? avatarFile; @override Widget build(BuildContext context) { @@ -36,10 +33,7 @@ class SignupButton extends StatelessWidget { } return AppButton.auth( 'Sign up', - () => context.read().onSubmit( - imageBytes: imageBytes, - file: file, - ), + () => context.read().onSubmit(avatarFile: avatarFile), style: style, outlined: true, ); diff --git a/lib/auth/components/signup/cubit/signup_cubit.dart b/lib/auth/components/signup/cubit/signup_cubit.dart index c1970b75..3169637d 100644 --- a/lib/auth/components/signup/cubit/signup_cubit.dart +++ b/lib/auth/components/signup/cubit/signup_cubit.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; @@ -191,8 +190,7 @@ class SignupCubit extends Cubit { /// Defines method to submit form. It is used to check if all inputs are valid /// and if so, it is used to signup user. Future onSubmit({ - Uint8List? imageBytes, - File? file, + File? avatarFile, }) async { final email = Email.validated(state.email.value); final password = Password.validated(state.password.value); @@ -214,32 +212,28 @@ class SignupCubit extends Cubit { if (!isFormValid) return; try { - final imageFile = await PickImage.imageWithXImagePicker( - source: ImageSource.gallery, - maxHeight: 820, - maxWidth: 820, - ); - final imageBytes = - await PickImage.imageBytes(file: File(imageFile!.path)); - final avatarsStorage = Supabase.instance.client.storage.from('avatars'); - - final fileExt = imageFile.path.split('.').last; - final fileName = '${DateTime.now().toIso8601String()}.$fileExt'; - final filePath = fileName; - await avatarsStorage.uploadBinary( - filePath, - imageBytes, - fileOptions: FileOptions(contentType: imageFile.mimeType), - ); - final imageUrlResponse = await avatarsStorage.createSignedUrl( - filePath, - 60 * 60 * 24 * 365 * 10, - ); + String? imageUrlResponse; + if (avatarFile != null) { + final imageBytes = + await PickImage.imageBytes(file: File(avatarFile.path)); + final avatarsStorage = Supabase.instance.client.storage.from('avatars'); + + final fileExt = avatarFile.path.split('.').last.toLowerCase(); + final fileName = '${DateTime.now().toIso8601String()}.$fileExt'; + final filePath = fileName; + await avatarsStorage.uploadBinary( + filePath, + imageBytes, + fileOptions: FileOptions(contentType: 'image/$fileExt'), + ); + imageUrlResponse = await avatarsStorage.createSignedUrl( + filePath, + 60 * 60 * 24 * 365 * 10, + ); + } final pushToken = await _notificationsClient.getToken(); - logD(pushToken); - await _userRepository.signUpWithPassword( email: email.value, password: password.value, diff --git a/lib/auth/components/signup/view/signup_view.dart b/lib/auth/components/signup/view/signup_view.dart index ddec5715..93a8b10b 100644 --- a/lib/auth/components/signup/view/signup_view.dart +++ b/lib/auth/components/signup/view/signup_view.dart @@ -15,7 +15,7 @@ class SignupView extends StatefulWidget { class _SignupViewState extends State { Uint8List? _imageBytes; - File? _file; + File? _avatarFile; @override Widget build(BuildContext context) { @@ -54,7 +54,7 @@ class _SignupViewState extends State { onUpload: (imageBytes, file) { setState(() { _imageBytes = imageBytes; - _file = file; + _avatarFile = file; }); }, ), @@ -64,8 +64,7 @@ class _SignupViewState extends State { Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: SignupButton( - imageBytes: _imageBytes, - file: _file, + avatarFile: _avatarFile, ), ), ], diff --git a/lib/user_profile/widgets/create_post.dart b/lib/user_profile/widgets/create_post.dart new file mode 100644 index 00000000..aee19ffc --- /dev/null +++ b/lib/user_profile/widgets/create_post.dart @@ -0,0 +1,183 @@ +import 'dart:typed_data'; + +import 'package:app_ui/app_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:shared/shared.dart'; + +class CreatePostPage extends StatefulWidget { + const CreatePostPage({ + required this.selectedFilesDetails, + super.key, + }); + final SelectedImagesDetails selectedFilesDetails; + + @override + State createState() => _CreatePostPageState(); +} + +class _CreatePostPageState extends State { + bool isSwitched = false; + final _busy = ValueNotifier(false); + + TextEditingController captionController = TextEditingController(text: ''); + bool isImage = true; + late bool multiSelectionMode; + late SelectedByte firstSelectedByte; + late List selectedByte; + + late Uint8List firstFileByte; + + @override + void initState() { + super.initState(); + + selectedByte = widget.selectedFilesDetails.selectedFiles; + firstSelectedByte = widget.selectedFilesDetails.selectedFiles[0]; + multiSelectionMode = widget.selectedFilesDetails.multiSelectionMode; + isImage = firstSelectedByte.isThatImage; + firstFileByte = firstSelectedByte.selectedByte; + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + appBar: context.isMobile ? appBar(context) : null, + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsetsDirectional.only( + start: 10, + end: 10, + top: 10, + ), + child: Row( + children: [ + SizedBox( + height: 70, + width: 70, + child: Stack( + children: [ + if (isImage) Image.memory(firstFileByte), + if (multiSelectionMode) + const Padding( + padding: EdgeInsets.all(2), + child: Align( + alignment: Alignment.topLeft, + child: Icon( + Icons.copy_rounded, + color: Colors.white, + size: 20, + ), + ), + ), + if (!isImage) + const Padding( + padding: EdgeInsets.all(2), + child: Align( + child: Icon( + Icons.slow_motion_video_sharp, + color: Colors.white, + size: 20, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: TextFormField( + controller: captionController, + style: context.bodyLarge, + decoration: InputDecoration( + border: InputBorder.none, + hintText: 'Caption', + hintStyle: TextStyle( + color: context.theme.bottomAppBarTheme.color, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + AppBar appBar(BuildContext context) { + return AppBar( + elevation: 0, + title: Text( + 'New post', + style: context.titleLarge?.copyWith(fontWeight: AppFontWeight.bold), + ), + actions: actionsWidgets(context), + ); + } + + List actionsWidgets(BuildContext context) { + return [ + ValueListenableBuilder( + valueListenable: _busy, + builder: (context, busy, child) => busy + ? const AppCircularProgress(Colors.blue) + : IconButton( + onPressed: () async { + await createPost(context); + }, + icon: const Icon( + Icons.check_rounded, + size: 30, + color: Colors.blue, + ), + ), + ), + ]; + } + + Future createPost(BuildContext context) async { + WidgetsBinding.instance + .addPostFrameCallback((_) => setState(() => _busy.value = false)); + final selectedFile = firstSelectedByte.selectedFile; + Uint8List? convertedBytes; + if (!isImage) { + convertedBytes = await VideoThumbnailPlus.getVideoThumbnail(selectedFile); + final blurHash = convertedBytes != null + ? await BlurHashPlus.blurHashEncode(convertedBytes) + : ''; + } else { + final byte = await selectedFile.readAsBytes(); + final blurHash = await BlurHashPlus.blurHashEncode(byte); + } + if (!mounted) return; + + // context.read().add( + // UserProfilePostCreateRequested( + // postId: postId, + // userId: userId, + // caption: bodySmall, + // type: type, + // mediaUrl: mediaUrl, + // imagesUrl: imagesUrl, + // ), + // ); + + // if (postCubit.newPostInfo != null) { + // if (!mounted) return; + + // await UserInfoCubit.get(context).updateUserPostsInfo( + // userId: myPersonalId, + // postInfo: postCubit.newPostInfo!, + // ); + // await postCubit.getPostsInfo( + // postsIds: myPersonalInfo.posts, + // isThatMyPosts: true, + // ); + WidgetsBinding.instance.addPostFrameCallback((_) => _busy.value = false); + // } + if (!mounted) return; + } +} diff --git a/lib/user_profile/widgets/user_profile_create_post.dart b/lib/user_profile/widgets/user_profile_create_post.dart index b217f25d..3b6ec81f 100644 --- a/lib/user_profile/widgets/user_profile_create_post.dart +++ b/lib/user_profile/widgets/user_profile_create_post.dart @@ -91,28 +91,53 @@ class _CreatePostViewState extends State { onPressed: _busy ? null : () async { - await PickImage.pickFiles( + await PickImage.pickAssetsFromBoth( context, - closeOnComplete: true, - onCompleted: (exportDetails) async { - await for (final details in exportDetails) { - final files = details.croppedFiles; - final imagesFile = []; - final imagesBytes = []; - for (final file in files) { - imagesFile.add(file); - - final bytes = - await PickImage.imageBytes(file: file); - imagesBytes.add(bytes); - } - setState(() { - _imagesFile = imagesFile; - _imagesBytes = imagesBytes; - }); + onMediaPicked: (context, details) async { + final imagesFile = []; + final imagesBytes = []; + for (final file in details.selectedFiles) { + imagesFile.add(file.selectedFile); + imagesBytes.add(file.selectedByte); } + setState(() { + _imagesFile = imagesFile; + _imagesBytes = imagesBytes; + }); + context.pop(); }, + // await Navigator.of(context, rootNavigator: true) + // .push( + // MaterialPageRoute( + // builder: (context) => CreatePostPage( + // selectedFilesDetails: details, + // ), + // maintainState: false, + // ), + // ); ); + // await PickImage.pickAssets( + // context, + // closeOnComplete: true, + // onCompleted: (exportDetails) async { + // await for (final details in exportDetails) { + // final files = details.croppedFiles; + // final imagesFile = []; + // final imagesBytes = []; + // for (final file in files) { + // imagesFile.add(file); + + // final bytes = + // await PickImage.imageBytes(file: file); + // imagesBytes.add(bytes); + // } + // setState(() { + // _imagesFile = imagesFile; + // _imagesBytes = imagesBytes; + // }); + // } + // }, + // ); }, style: ElevatedButton.styleFrom( shape: const CircleBorder(), diff --git a/packages/app_ui/lib/src/extensions/build_context_extension.dart b/packages/app_ui/lib/src/extensions/build_context_extension.dart index c2d261bc..1bf1ebcd 100644 --- a/packages/app_ui/lib/src/extensions/build_context_extension.dart +++ b/packages/app_ui/lib/src/extensions/build_context_extension.dart @@ -25,7 +25,7 @@ extension BuildContextX on BuildContext { Color customAdaptiveColor({Color? light, Color? dark}) => isDark ? (light ?? Colors.white) : (dark ?? Colors.black); - /// Defines a customisable reversed adaptive [Color]. If [light] or [dark] + /// Defines a customisable reversed adaptive [Color]. If [light] or [dark] /// is not provided default reversed colors are used. Color customReversedAdaptiveColor({Color? light, Color? dark}) => isDark ? (dark ?? Colors.black) : (light ?? Colors.white); @@ -41,4 +41,13 @@ extension BuildContextX on BuildContext { /// Defines value of device current height based on [size]. double get screenHeight => size.height; + + /// Whether the current device is an `Android`. + bool get isAndroid => theme.platform == TargetPlatform.android; + + /// Whether the current device is an `iOS`. + bool get isIOS => !isAndroid; + + /// Whether the current device is a `mobile`. + bool get isMobile => isAndroid || isIOS; } diff --git a/packages/app_ui/lib/src/widgets/app_circular_progress.dart b/packages/app_ui/lib/src/widgets/app_circular_progress.dart new file mode 100644 index 00000000..61941b7e --- /dev/null +++ b/packages/app_ui/lib/src/widgets/app_circular_progress.dart @@ -0,0 +1,74 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +/// {@template app_circular_prorgress} +/// A circular progress indicator that can be used in the app. +/// {@endtemplate} +class AppCircularProgress extends StatelessWidget { + /// {@macro app_circular_progress} + const AppCircularProgress(this.color, {super.key}); + + /// The color of the circular progress indicator. + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 50, + child: Transform.scale( + scale: 0.50, + child: ClipOval( + child: context.isAndroid + ? CircularProgressIndicator( + strokeWidth: 6, + color: color, + ) + : CupertinoActivityIndicator(color: color), + ), + ), + ); + } +} + +/// {@template thine_circular_progress} +/// An Instagram-like circular progress indicator. +/// {@endtemplate} +class ThineCircularProgress extends StatelessWidget { + /// {@macro thine_circular_progress} + const ThineCircularProgress({ + super.key, + this.strokeWidth = 1.0, + this.backgroundColor, + this.color, + this.valueColor, + }); + + /// The color of the circular progress indicator. + final Color? color; + + /// The color of the background of the circular progress indicator. + final Color? backgroundColor; + + /// The width of the progress bar line. + final double strokeWidth; + + /// The color of the progress bar line. + final Animation? valueColor; + + @override + Widget build(BuildContext context) { + return Center( + child: context.isAndroid + ? CircularProgressIndicator( + backgroundColor: backgroundColor, + valueColor: valueColor, + strokeWidth: strokeWidth, + color: color ?? context.theme.focusColor, + ) + : CupertinoActivityIndicator( + color: color ?? context.theme.focusColor, + ), + ); + } +} diff --git a/packages/app_ui/lib/src/widgets/widgets.dart b/packages/app_ui/lib/src/widgets/widgets.dart index 13f93446..fd1d33c0 100644 --- a/packages/app_ui/lib/src/widgets/widgets.dart +++ b/packages/app_ui/lib/src/widgets/widgets.dart @@ -1,4 +1,5 @@ export 'app_button.dart'; +export 'app_circular_progress.dart'; export 'app_counter_number.dart'; export 'app_divider.dart'; export 'app_loading_progress.dart'; diff --git a/packages/image_picker_plus/.gitignore b/packages/image_picker_plus/.gitignore new file mode 100644 index 00000000..96486fd9 --- /dev/null +++ b/packages/image_picker_plus/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/packages/image_picker_plus/.metadata b/packages/image_picker_plus/.metadata new file mode 100644 index 00000000..756df280 --- /dev/null +++ b/packages/image_picker_plus/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + channel: stable + +project_type: package diff --git a/packages/image_picker_plus/CHANGELOG.md b/packages/image_picker_plus/CHANGELOG.md new file mode 100644 index 00000000..184b1052 --- /dev/null +++ b/packages/image_picker_plus/CHANGELOG.md @@ -0,0 +1,127 @@ +## 0.5.9 +* solve camera button bug + +## 0.5.8 +* solve photo permission bug #56, #68 +* solve crop image bugs + +## 0.5.7 +* solve #63 issue "dependencies bugs" + +## 0.5.6+2 +* update flutter version + +## 0.5.6+1 +* handle camera preview + +## 0.5.6 +* reformat the code +* return callbackFunction + +## 0.5.5+3 +* add maximumSelection as a parameter + +## 0.5.5+2 +* update dependencies + +## 0.5.5+1 +* solve tab bar bug + +## 0.5.5 +* refactoring the code + +## 0.5.4 +* edit video display +* add CI/CD + +## 0.5.3 +* edit video display + +## 0.5.2 +* update README +* create custom route + +## 0.5.1 +* update README +* create sendRequestFunction + +## 0.5.0 +* handle multi selection images bugs + +## 0.4.0 +* change the way of pick images + +## 0.3.9 +* update the dependencies +* update README.md + +## 0.3.8 +* handle crop keys + +## 0.3.7 +* solve drop frames bug when page view is moving + +## 0.3.6 +* fix permission bug +* add INTERNET permission in manifest.xml +* update README.md + +## 0.3.5 +* solve warning of uses or overrides a deprecated API. + +## 0.3.4 +* solve dropped frames issue in grid view when scrolling +* solve the issue in grid view when the tap bar is moved +* remove the unuseful package + +## 0.3.3+1 +* reformat the code +## 0.3.3 +* rename tabs texts + +## 0.3.2 +* solve box constraints bug + +## 0.3.1 +* restructure gallery display +* handling video lag + +## 0.2.8 +* change the paint color to white + +## 0.2.7 + +* Solve permissions bugs +* Solve camera initializes bugs +* Edit README + +## 0.2.6 + +* Edit README + +## 0.2.5 + +* Ignores deprecation warnings + +## 0.2.4 + +* Solve front camera bugs +* Add some different cases as in example + +## 0.2.3 + +* Solve camera package bugs +* Write a documentation + +## 0.2.2 + +* Add an example to the package + +## 0.2.1 + +* make camera self initialize + +## 0.2.0 + +* Add a normal display +* Solve multi-selection mode bugs \ No newline at end of file diff --git a/packages/image_picker_plus/LICENSE b/packages/image_picker_plus/LICENSE new file mode 100644 index 00000000..083777d2 --- /dev/null +++ b/packages/image_picker_plus/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Ahmed Abdo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/image_picker_plus/README.md b/packages/image_picker_plus/README.md new file mode 100644 index 00000000..819fee88 --- /dev/null +++ b/packages/image_picker_plus/README.md @@ -0,0 +1,122 @@ + +

Image picker plus

+ +When you try to add a package (like image_picker) to pick an image from a gallery/camera, you will face a lot of issues like:- +- If your app supports multi-themes image picker will not respond with that. +- If your app supports multi-languages image picker will not respond with that. +- If your app has a beautiful design and a good user experience, image_picker will break all of this, Because image_picker has a traditional UI of Gallery display. + +In (image_picker_plus), we solve all those issues and many other features like:- +- You can customize the UI of displaying the gallery. +- You can crop the selected image(s) with different aspect ratios. +- You can display photos and videos and choose from both of them. +- You can display a gallery, camera, and video, and the user can choose between them. + +

+ + +

+ +

+ + +

+

+ + Pub Package + + + License: MIT + +

+ + +# Installing + +## IOS + +\* The camera plugin compiles for any version of iOS, but its functionality +requires iOS 10 or higher. If compiling for iOS 9, make sure to programmatically +check the version of iOS running on the device before using any camera plugin features. +The [device_info_plus](https://pub.dev/packages/device_info_plus) plugin, for example, can be used to check the iOS version. + +Add two rows to the `ios/Runner/Info.plist`: + +* one with the key `Privacy - Photo Usage Description` and a usage description. +* and one with the key `Privacy - Camera Usage Description` and a usage description. +* and one with the key `Privacy - Microphone Usage Description` and a usage description. + +If editing `Info.plist` as text, add: + +```xml +NSPhotoLibraryUsageDescription +your usage description here +NSCameraUsageDescription +your usage description here +NSMicrophoneUsageDescription +your usage description here +``` + +## Android + +* Change the minimum Android sdk version to 21 (or higher), and compile sdk to 31 (or higher) in your `android/app/build.gradle` file. + +```java +compileSdkVersion 33 + minSdkVersion 21 +``` + +* Add this permission into your AndroidManifest.xml +````xml + + ... + + + + + + + + +```` + +### 1. Depend on it + +Add this to your package's `pubspec.yaml` file: + +```yaml +dependencies: + image_picker_plus: [last_version] +``` + +### 2. Install it + +You can install packages from the command line: + +with `pub`: + +``` +$ pub get image_picker_plus +``` + +with `Flutter`: + +``` +$ flutter pub add image_picker_plus +``` + +### 3. Import it + +In your `Dart` code, you can use: + +```dart +import 'package:image_picker_plus/image_picker_plus.dart'; +``` \ No newline at end of file diff --git a/packages/image_picker_plus/analysis_options.yaml b/packages/image_picker_plus/analysis_options.yaml new file mode 100644 index 00000000..a5744c1c --- /dev/null +++ b/packages/image_picker_plus/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/image_picker_plus/lib/image_picker_plus.dart b/packages/image_picker_plus/lib/image_picker_plus.dart new file mode 100644 index 00000000..7169c616 --- /dev/null +++ b/packages/image_picker_plus/lib/image_picker_plus.dart @@ -0,0 +1,8 @@ +library image_picker_plus; + +export 'src/entities/app_theme.dart'; +export 'src/entities/image_picker_display.dart'; +export 'src/entities/selected_image_details.dart'; +export 'src/entities/tabs_texts.dart'; +export 'src/image_picker_plus.dart'; +export 'src/utilities/utilities.dart'; \ No newline at end of file diff --git a/packages/image_picker_plus/lib/src/camera_display.dart b/packages/image_picker_plus/lib/src/camera_display.dart new file mode 100644 index 00000000..bd472ff8 --- /dev/null +++ b/packages/image_picker_plus/lib/src/camera_display.dart @@ -0,0 +1,459 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:camera/camera.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker_plus/src/custom_crop.dart'; +import 'package:image_picker_plus/src/entities/app_theme.dart'; +import 'package:image_picker_plus/src/entities/selected_image_details.dart'; +import 'package:image_picker_plus/src/entities/tabs_texts.dart'; +import 'package:image_picker_plus/src/utilities/enum.dart'; +import 'package:image_picker_plus/src/utilities/extensions/file_extension.dart'; +import 'package:image_picker_plus/src/video_layout/record_count.dart'; +import 'package:image_picker_plus/src/video_layout/record_fade_animation.dart'; +import 'package:insta_assets_crop/insta_assets_crop.dart'; +import 'package:photo_manager/photo_manager.dart'; + +class CustomCameraDisplay extends StatefulWidget { + final bool selectedVideo; + final AppTheme appTheme; + final TabsTexts tabsNames; + final bool enableCamera; + final bool enableVideo; + final VoidCallback moveToVideoScreen; + final ValueNotifier selectedCameraImage; + final ValueNotifier redDeleteText; + final ValueChanged replacingTabBar; + final ValueNotifier clearVideoRecord; + final AsyncValueSetter? callbackFunction; + + const CustomCameraDisplay({ + Key? key, + required this.appTheme, + required this.tabsNames, + required this.selectedCameraImage, + required this.enableCamera, + required this.enableVideo, + required this.redDeleteText, + required this.selectedVideo, + required this.replacingTabBar, + required this.clearVideoRecord, + required this.moveToVideoScreen, + required this.callbackFunction, + }) : super(key: key); + + @override + CustomCameraDisplayState createState() => CustomCameraDisplayState(); +} + +class CustomCameraDisplayState extends State { + ValueNotifier startVideoCount = ValueNotifier(false); + + bool initializeDone = false; + bool allPermissionsAccessed = true; + bool _hasCamera = false; + + List? cameras; + CameraController? controller; + + final cropKey = GlobalKey(); + + Flash currentFlashMode = Flash.auto; + late Widget videoStatusAnimation; + int selectedCamera = 0; + File? videoRecordFile; + + @override + void dispose() { + startVideoCount.dispose(); + controller?.dispose(); + super.dispose(); + } + + @override + void initState() { + videoStatusAnimation = Container(); + _initializeCamera(); + + super.initState(); + } + + Future _initializeCamera() async { + try { + final state = await PhotoManager.requestPermissionExtend(); + log('state: $state'); + if (!state.hasAccess || !state.isAuth) { + log('Has no access'); + allPermissionsAccessed = false; + return; + } + allPermissionsAccessed = true; + if (!mounted) return; + cameras = await availableCameras(); + if (cameras == null || (cameras?.isEmpty ?? true)) { + setState(() { + _hasCamera = false; + initializeDone = true; + }); + return; + } + controller = CameraController( + cameras![0], + ResolutionPreset.high, + enableAudio: true, + ); + await controller?.initialize(); + initializeDone = true; + _hasCamera = true; + } catch (e) { + log('Initialize camera error', error: e); + allPermissionsAccessed = false; + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Material( + color: widget.appTheme.primaryColor, + child: allPermissionsAccessed + ? (initializeDone + ? _hasCamera + ? buildBody() + : noCameraFound() + : loadingProgress()) + : failedPermissions(), + ); + } + + Widget failedPermissions() { + return Center( + child: Text( + widget.tabsNames.acceptAllPermissions, + style: TextStyle(color: widget.appTheme.focusColor), + ), + ); + } + + Widget noCameraFound() { + return Center( + child: Text( + // widget.tabsNames.acceptAllPermissions, + 'No camera found!', + style: TextStyle(color: widget.appTheme.focusColor), + ), + ); + } + + Center loadingProgress() { + return Center( + child: CircularProgressIndicator( + color: widget.appTheme.focusColor, + strokeWidth: 1, + ), + ); + } + + Widget buildBody() { + Color whiteColor = widget.appTheme.primaryColor; + File? selectedImage = widget.selectedCameraImage.value; + return Column( + children: [ + appBar(), + Flexible( + child: Stack( + children: [ + if (selectedImage == null) ...[ + if (controller != null) + SizedBox( + width: double.infinity, + child: CameraPreview(controller!), + ), + ] else ...[ + Align( + alignment: Alignment.topCenter, + child: Container( + color: whiteColor, + height: 360, + width: double.infinity, + child: buildCrop(selectedImage), + ), + ) + ], + buildFlashIcons(), + buildPickImageContainer(whiteColor, context), + ], + ), + ), + ], + ); + } + + Align buildPickImageContainer(Color whiteColor, BuildContext context) { + return Align( + alignment: Alignment.bottomCenter, + child: Container( + height: 270, + color: whiteColor, + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(top: 1.0), + child: Align( + alignment: Alignment.topCenter, + child: RecordCount( + appTheme: widget.appTheme, + startVideoCount: startVideoCount, + makeProgressRed: widget.redDeleteText, + clearVideoRecord: widget.clearVideoRecord, + ), + ), + ), + const Spacer(), + Stack( + alignment: Alignment.topCenter, + children: [ + Container( + padding: const EdgeInsets.all(60), + child: Align( + alignment: Alignment.center, + child: cameraButton(context), + ), + ), + Positioned(bottom: 120, child: videoStatusAnimation), + ], + ), + const Spacer(), + ], + ), + ), + ); + } + + Align buildFlashIcons() { + return Align( + alignment: Alignment.centerRight, + child: IconButton( + onPressed: () { + setState(() { + currentFlashMode = currentFlashMode == Flash.off + ? Flash.auto + : (currentFlashMode == Flash.auto ? Flash.on : Flash.off); + }); + currentFlashMode == Flash.on + ? controller?.setFlashMode(FlashMode.torch) + : currentFlashMode == Flash.off + ? controller?.setFlashMode(FlashMode.off) + : controller?.setFlashMode(FlashMode.auto); + }, + icon: Icon( + currentFlashMode == Flash.on + ? Icons.flash_on_rounded + : (currentFlashMode == Flash.auto + ? Icons.flash_auto_rounded + : Icons.flash_off_rounded), + color: Colors.white), + ), + ); + } + + CustomCrop buildCrop(File selectedImage) { + bool isThatVideo = selectedImage.isVideo; + return CustomCrop( + image: selectedImage, + isThatImage: !isThatVideo, + key: cropKey, + alwaysShowGrid: true, + paintColor: widget.appTheme.primaryColor, + ); + } + + AppBar appBar() { + Color whiteColor = widget.appTheme.primaryColor; + Color blackColor = widget.appTheme.focusColor; + File? selectedImage = widget.selectedCameraImage.value; + return AppBar( + backgroundColor: whiteColor, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.clear_rounded, color: blackColor, size: 30), + onPressed: () { + Navigator.of(context).maybePop(null); + }, + ), + actions: [ + AnimatedSwitcher( + duration: const Duration(seconds: 1), + switchInCurve: Curves.easeIn, + child: IconButton( + icon: const Icon(Icons.arrow_forward_rounded, + color: Colors.blue, size: 30), + onPressed: () async { + if (videoRecordFile != null) { + Uint8List byte = await videoRecordFile!.readAsBytes(); + SelectedByte selectedByte = SelectedByte( + isThatImage: false, + selectedFile: videoRecordFile!, + selectedByte: byte, + ); + SelectedImagesDetails details = SelectedImagesDetails( + multiSelectionMode: false, + selectedFiles: [selectedByte], + aspectRatio: 1.0, + ); + if (!mounted) return; + + if (widget.callbackFunction != null) { + await widget.callbackFunction!(details); + } else { + Navigator.of(context).maybePop(details); + } + } else if (selectedImage != null) { + File? croppedByte = await cropImage(selectedImage); + if (croppedByte != null) { + Uint8List byte = await croppedByte.readAsBytes(); + + SelectedByte selectedByte = SelectedByte( + isThatImage: true, + selectedFile: croppedByte, + selectedByte: byte, + ); + + SelectedImagesDetails details = SelectedImagesDetails( + selectedFiles: [selectedByte], + multiSelectionMode: false, + aspectRatio: 1.0, + ); + if (!mounted) return; + + if (widget.callbackFunction != null) { + await widget.callbackFunction!(details); + } else { + Navigator.of(context).maybePop(details); + } + } + } + }, + ), + ), + ], + ); + } + + Future cropImage(File imageFile) async { + await InstaAssetsCrop.requestPermissions(); + final scale = cropKey.currentState!.scale; + final area = cropKey.currentState!.area; + if (area == null) { + return null; + } + final sample = await InstaAssetsCrop.sampleImage( + file: imageFile, + preferredSize: (2000 / scale).round(), + ); + final File file = await InstaAssetsCrop.cropImage( + file: sample, + area: area, + ); + sample.delete(); + return file; + } + + GestureDetector cameraButton(BuildContext context) { + Color whiteColor = widget.appTheme.primaryColor; + return GestureDetector( + onTap: widget.enableCamera ? onPress : null, + onLongPress: widget.enableVideo ? onLongTap : null, + onLongPressUp: widget.enableVideo ? onLongTapUp : onPress, + child: CircleAvatar( + backgroundColor: Colors.grey[400], + radius: 40, + child: CircleAvatar( + radius: 24, + backgroundColor: whiteColor, + )), + ); + } + + void onPress() async { + try { + if (!widget.selectedVideo) { + final image = await controller?.takePicture(); + if (image == null) return; + File selectedImage = File(image.path); + setState(() { + widget.selectedCameraImage.value = selectedImage; + widget.replacingTabBar(true); + }); + } else { + setState(() { + videoStatusAnimation = buildFadeAnimation(); + }); + } + } catch (e) { + if (kDebugMode) print(e); + } + } + + void onLongTap() { + controller?.startVideoRecording(); + widget.moveToVideoScreen(); + setState(() { + startVideoCount.value = true; + }); + } + + void onLongTapUp() async { + setState(() { + startVideoCount.value = false; + widget.replacingTabBar(true); + }); + final video = await controller?.stopVideoRecording(); + if (video == null) return; + videoRecordFile = File(video.path); + } + + RecordFadeAnimation buildFadeAnimation() { + return RecordFadeAnimation(child: buildMessage()); + } + + Widget buildMessage() { + return Stack( + alignment: Alignment.topCenter, + children: [ + Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(10.0)), + color: Color.fromARGB(255, 54, 53, 53), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + Text( + widget.tabsNames.holdButtonText, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ], + ), + ), + ), + const Align( + alignment: Alignment.bottomCenter, + child: Center( + child: Icon( + Icons.arrow_drop_down_rounded, + color: Color.fromARGB(255, 49, 49, 49), + size: 65, + ), + ), + ), + ], + ); + } +} diff --git a/packages/image_picker_plus/lib/src/crop_image_view.dart b/packages/image_picker_plus/lib/src/crop_image_view.dart new file mode 100644 index 00000000..ff333956 --- /dev/null +++ b/packages/image_picker_plus/lib/src/crop_image_view.dart @@ -0,0 +1,176 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:image_picker_plus/image_picker_plus.dart'; +import 'package:image_picker_plus/src/custom_crop.dart'; + +class CropImageView extends StatefulWidget { + final ValueNotifier> cropKey; + final ValueNotifier> indexOfSelectedImages; + + final ValueNotifier multiSelectionMode; + final ValueNotifier expandImage; + final ValueNotifier expandHeight; + final ValueNotifier expandImageView; + + /// To avoid lag when you interacting with image when it expanded + final ValueNotifier enableVerticalTapping; + final ValueNotifier selectedImage; + + final VoidCallback clearMultiImages; + + final AppTheme appTheme; + final ValueNotifier noDuration; + final Color whiteColor; + final double? topPosition; + final bool withMultiSelection; + + const CropImageView({ + Key? key, + required this.indexOfSelectedImages, + required this.cropKey, + required this.multiSelectionMode, + required this.expandImage, + required this.expandHeight, + required this.clearMultiImages, + required this.expandImageView, + required this.enableVerticalTapping, + required this.selectedImage, + required this.appTheme, + required this.noDuration, + required this.whiteColor, + required this.withMultiSelection, + this.topPosition, + }) : super(key: key); + + @override + State createState() => _CropImageViewState(); +} + +class _CropImageViewState extends State { + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.enableVerticalTapping, + builder: (context, bool enableTappingValue, child) => GestureDetector( + onVerticalDragUpdate: enableTappingValue && widget.topPosition != null + ? (details) { + widget.expandImageView.value = true; + widget.expandHeight.value = details.globalPosition.dy - 56; + setState(() => widget.noDuration.value = true); + } + : null, + onVerticalDragEnd: enableTappingValue && widget.topPosition != null + ? (details) { + widget.expandHeight.value = + widget.expandHeight.value > 260 ? 360 : 0; + if (widget.topPosition == -360) { + widget.enableVerticalTapping.value = true; + } + if (widget.topPosition == 0) { + widget.enableVerticalTapping.value = false; + } + setState(() => widget.noDuration.value = false); + } + : null, + child: ValueListenableBuilder( + valueListenable: widget.selectedImage, + builder: (context, selectedImageValue, child) { + if (selectedImageValue != null) { + return showSelectedImage(context, selectedImageValue); + } else { + return Container(key: GlobalKey(debugLabel: "do not have")); + } + }, + ), + ), + ); + } + + Container showSelectedImage(BuildContext context, File selectedImageValue) { + double width = MediaQuery.of(context).size.width; + return Container( + key: GlobalKey(debugLabel: "have image"), + color: widget.whiteColor, + height: 360, + width: width, + child: ValueListenableBuilder( + valueListenable: widget.multiSelectionMode, + builder: (context, multiSelectionModeValue, child) => Stack( + children: [ + ValueListenableBuilder( + valueListenable: widget.expandImage, + builder: (context, expandMedia, _) => CropPreview( + selectedMedia: selectedImageValue, + cropKey: widget.cropKey.value, + expandMedia: expandMedia, + paintColor: widget.appTheme.primaryColor, + ), + ), + if (widget.topPosition != null) ...[ + if (widget.withMultiSelection) + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.all(10.0), + child: GestureDetector( + onTap: () { + if (multiSelectionModeValue) widget.clearMultiImages(); + setState(() { + widget.multiSelectionMode.value = + !multiSelectionModeValue; + }); + }, + child: Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: multiSelectionModeValue + ? Colors.blue + : const Color.fromARGB(165, 58, 58, 58), + border: Border.all( + color: const Color.fromARGB(45, 250, 250, 250), + ), + shape: BoxShape.circle, + ), + child: const Center( + child: + Icon(Icons.copy, color: Colors.white, size: 17), + ), + ), + ), + ), + ), + ], + ], + ), + ), + ); + } +} + +class CropPreview extends StatelessWidget { + const CropPreview({ + super.key, + required this.selectedMedia, + required this.cropKey, + required this.paintColor, + required this.expandMedia, + }); + + final File selectedMedia; + final GlobalKey cropKey; + final Color paintColor; + final bool expandMedia; + + @override + Widget build(BuildContext context) { + return CustomCrop( + key: cropKey, + image: selectedMedia, + isThatImage: !selectedMedia.isVideo, + paintColor: paintColor, + aspectRatio: expandMedia ? 4 / 5 : 1.0, + ); + } +} diff --git a/packages/image_picker_plus/lib/src/custom_crop.dart b/packages/image_picker_plus/lib/src/custom_crop.dart new file mode 100644 index 00000000..e4691b48 --- /dev/null +++ b/packages/image_picker_plus/lib/src/custom_crop.dart @@ -0,0 +1,751 @@ +import 'dart:io'; +import 'dart:math'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +const _kCropGridColumnCount = 3; +const _kCropGridRowCount = 3; +const _kCropGridColor = Color.fromRGBO(0xd0, 0xd0, 0xd0, 0.9); +const _kCropHandleSize = 0.0; +const _kCropHandleHitSize = 48.0; + +enum _CropAction { none, moving, cropping, scaling } + +enum _CropHandleSide { none, topLeft, topRight, bottomLeft, bottomRight } + +class CustomCrop extends StatefulWidget { + final File image; + final double? aspectRatio; + final double maximumScale; + + final bool alwaysShowGrid; + final Color? paintColor; + final ImageErrorListener? onImageError; + final ValueChanged? scrollCustomList; + final bool isThatImage; + + const CustomCrop({ + Key? key, + this.aspectRatio, + this.paintColor, + this.scrollCustomList, + this.maximumScale = 2.0, + this.alwaysShowGrid = false, + this.isThatImage = true, + this.onImageError, + required this.image, + }) : super(key: key); + + @override + State createState() => CustomCropState(); + + static CustomCropState? of(BuildContext context) => + context.findAncestorStateOfType(); +} + +class CustomCropState extends State with TickerProviderStateMixin { + final _surfaceKey = GlobalKey(); + + late final AnimationController _activeController; + late final AnimationController _settleController; + + double _scale = 1.0; + double _ratio = 1.0; + Rect _view = Rect.zero; + Rect _area = Rect.zero; + Offset _lastFocalPoint = Offset.zero; + _CropAction _action = _CropAction.none; + _CropHandleSide _handle = _CropHandleSide.none; + + late double _startScale; + late Rect _startView; + late Tween _viewTween; + late Tween _scaleTween; + + ImageStream? _imageStream; + ui.Image? _image; + ImageStreamListener? _imageListener; + + double get scale => _area.shortestSide / _scale; + + Rect? get area => _view.isEmpty + ? null + : Rect.fromLTWH( + _area.left * _view.width / _scale - _view.left, + _area.top * _view.height / _scale - _view.top, + _area.width * _view.width / _scale, + _area.height * _view.height / _scale, + ); + bool get _isEnabled => _view.isEmpty == false && _image != null; + + final Map _maxAreaWidthMap = {}; + + int pointers = 0; + + @override + void initState() { + super.initState(); + + _activeController = AnimationController( + vsync: this, + value: widget.alwaysShowGrid ? 1.0 : 0.0, + )..addListener(() => setState(() {})); + _settleController = AnimationController(vsync: this) + ..addListener(_settleAnimationChanged); + } + + @override + void dispose() { + final listener = _imageListener; + if (listener != null) { + _imageStream?.removeListener(listener); + } + _activeController.dispose(); + _settleController.dispose(); + + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _getImage(); + } + + @override + void didUpdateWidget(CustomCrop oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.image != oldWidget.image) { + _getImage(); + } else if (widget.aspectRatio != oldWidget.aspectRatio) { + // _area = _calculateDefaultArea( + // viewWidth: _view.width, + // viewHeight: _view.height, + // imageWidth: _image?.width, + // imageHeight: _image?.height, + // ); + WidgetsBinding.instance.addPostFrameCallback((_) => _updateView()); + } + if (widget.alwaysShowGrid != oldWidget.alwaysShowGrid) { + if (widget.alwaysShowGrid) { + _activate(); + } else { + _deactivate(); + } + } + } + + void _getImage({bool force = false}) { + if (widget.isThatImage) { + final oldImageStream = _imageStream; + FileImage image = FileImage(widget.image, scale: 1.0); + final newImageStream = + image.resolve(createLocalImageConfiguration(context)); + _imageStream = newImageStream; + if (newImageStream.key != oldImageStream?.key || force) { + final oldImageListener = _imageListener; + if (oldImageListener != null) { + oldImageStream?.removeListener(oldImageListener); + } + final newImageListener = + ImageStreamListener(_updateImage, onError: widget.onImageError); + _imageListener = newImageListener; + newImageStream.addListener(newImageListener); + } + } + } + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints.expand(), + child: Listener( + onPointerDown: (event) => pointers++, + onPointerUp: (event) => pointers = 0, + child: GestureDetector( + key: _surfaceKey, + behavior: HitTestBehavior.opaque, + onScaleStart: _isEnabled ? _handleScaleStart : null, + onScaleUpdate: _isEnabled ? _handleScaleUpdate : null, + onScaleEnd: _isEnabled ? _handleScaleEnd : null, + child: AnimatedBuilder( + builder: (context, child) { + if (widget.isThatImage) { + return buildCustomPaint(); + } else { + return _DisplayVideo(selectedFile: widget.image); + } + }, + animation: _activeController, + ), + ), + ), + ); + } + + void _handleScaleStart(ScaleStartDetails details) { + if (widget.scrollCustomList != null) widget.scrollCustomList!(true); + + _activate(); + _settleController.stop(canceled: false); + _lastFocalPoint = details.focalPoint; + _action = _CropAction.none; + _handle = _hitCropHandle(_getLocalPoint(details.focalPoint)); + _startScale = _scale; + _startView = _view; + } + + void _handleScaleUpdate(ScaleUpdateDetails details) { + if (_action == _CropAction.none) { + if (_handle == _CropHandleSide.none) { + _action = pointers == 2 ? _CropAction.scaling : _CropAction.moving; + } else { + _action = _CropAction.cropping; + } + } + if (_action == _CropAction.cropping) { + final boundaries = _boundaries; + if (boundaries == null) return; + } else if (_action == _CropAction.moving) { + final image = _image; + if (image == null) return; + + final delta = details.focalPoint - _lastFocalPoint; + _lastFocalPoint = details.focalPoint; + + setState(() { + _view = _view.translate( + delta.dx / (image.width * _scale * _ratio), + delta.dy / (image.height * _scale * _ratio), + ); + }); + } else if (_action == _CropAction.scaling) { + final image = _image; + final boundaries = _boundaries; + if (image == null || boundaries == null) return; + + setState(() { + _scale = _startScale * details.scale; + + final dx = boundaries.width * + (1.0 - details.scale) / + (image.width * _scale * _ratio); + final dy = boundaries.height * + (1.0 - details.scale) / + (image.height * _scale * _ratio); + _view = Rect.fromLTWH( + _startView.left + dx / 2, + _startView.top + dy / 2, + _startView.width, + _startView.height, + ); + }); + } + } + + void _handleScaleEnd(ScaleEndDetails details) { + if (widget.scrollCustomList != null) widget.scrollCustomList!(false); + + _deactivate(); + final minimumScale = _minimumScale; + if (minimumScale == null) return; + + final targetScale = _scale.clamp(minimumScale, _maximumScale); + _scaleTween = Tween( + begin: _scale, + end: targetScale, + ); + + _startView = _view; + _viewTween = RectTween( + begin: _view, + end: _getViewInBoundaries(targetScale), + ); + + _settleController.value = 0.0; + _settleController.animateTo( + 1.0, + curve: Curves.fastOutSlowIn, + duration: const Duration(milliseconds: 350), + ); + } + + Rect _getViewInBoundaries(double scale) => + Offset( + max( + min(_view.left, _area.left * _view.width / scale), + _area.right * _view.width / scale - 1.0, + ), + max( + min( + _view.top, + _area.top * _view.height / scale, + ), + _area.bottom * _view.height / scale - 1.0), + ) & + _view.size; + + double get _maximumScale => widget.maximumScale; + + double? get _minimumScale { + final boundaries = _boundaries; + final image = _image; + if (boundaries == null || image == null) { + return null; + } + + final scaleX = boundaries.width * _area.width / (image.width * _ratio); + final scaleY = boundaries.height * _area.height / (image.height * _ratio); + return min(_maximumScale, max(scaleX, scaleY)); + } + + Widget buildCustomPaint() { + return CustomPaint( + painter: _CropPainter( + image: _image, + ratio: _ratio, + view: _view, + area: _area, + scale: _scale, + active: _activeController.value, + paintColor: widget.paintColor ?? Colors.white, + ), + ); + } + + void _activate() { + _activeController.animateTo( + 1.0, + curve: Curves.fastOutSlowIn, + duration: const Duration(milliseconds: 250), + ); + } + + void _deactivate() { + if (widget.alwaysShowGrid == false) { + _activeController.animateTo( + 0.0, + curve: Curves.fastOutSlowIn, + duration: const Duration(milliseconds: 250), + ); + } + } + + Size? get _boundaries { + final context = _surfaceKey.currentContext; + if (context == null) return null; + + final box = context.findRenderObject() as RenderBox; + final size = box.size; + + return size - const Offset(_kCropHandleSize, _kCropHandleSize) as Size; + } + + Offset? _getLocalPoint(Offset point) { + final context = _surfaceKey.currentContext; + if (context == null) return null; + + final box = context.findRenderObject() as RenderBox; + + return box.globalToLocal(point); + } + + void _settleAnimationChanged() { + setState(() { + _scale = _scaleTween.transform(_settleController.value); + final nextView = _viewTween.transform(_settleController.value); + if (nextView != null) { + _view = nextView; + } + }); + } + + Rect _calculateDefaultArea({ + required int? imageWidth, + required int? imageHeight, + required double viewWidth, + required double viewHeight, + }) { + if (imageWidth == null || imageHeight == null) { + return Rect.zero; + } + + double height; + double width; + if ((widget.aspectRatio ?? 1.0) < 1) { + height = 1.0; + width = + ((widget.aspectRatio ?? 1.0) * imageHeight * viewHeight * height) / + imageWidth / + viewWidth; + if (width > 1.0) { + width = 1.0; + height = (imageWidth * viewWidth * width) / + (imageHeight * viewHeight * (widget.aspectRatio ?? 1.0)); + } + } else { + width = 1.0; + height = (imageWidth * viewWidth * width) / + (imageHeight * viewHeight * (widget.aspectRatio ?? 1.0)); + if (height > 1.0) { + height = 1.0; + width = + ((widget.aspectRatio ?? 1.0) * imageHeight * viewHeight * height) / + imageWidth / + viewWidth; + } + } + final aspectRatio = _maxAreaWidthMap[widget.aspectRatio]; + if (aspectRatio != null) { + _maxAreaWidthMap[aspectRatio] = width; + } + + return Rect.fromLTWH((1.0 - width) / 2, (1.0 - height) / 2, width, height); + } + + void _updateImage(ImageInfo imageInfo, bool synchronousCall) { + final boundaries = _boundaries; + if (boundaries == null) return; + + final image = imageInfo.image; + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + setState(() { + // _image = image; + // _scale = imageInfo.scale; + // _ratio = max( + // boundaries.width / image.width, + // boundaries.height / image.height, + // ); + + // final viewWidth = boundaries.width / (image.width * _scale * _ratio); + // final viewHeight = boundaries.height / (image.height * _scale * _ratio); + // _area = _calculateDefaultArea( + // viewWidth: viewWidth, + // viewHeight: viewHeight, + // imageWidth: image.width, + // imageHeight: image.height, + // ); + // _view = Rect.fromLTWH( + // (viewWidth - 1.0) / 2, + // (viewHeight - 1.0) / 2, + // viewWidth, + // viewHeight, + // ); + _image = image; + + _scale = imageInfo.scale; + _ratio = max( + boundaries.width / image.width, + boundaries.height / image.height, + ); + + _updateView(boundaries); + }); + }); + + WidgetsBinding.instance.ensureVisualUpdate(); + } + + void _updateView([Size? b]) { + final boundaries = b ?? _boundaries; + if (boundaries == null) { + return; + } + + final viewWidth = + boundaries.width / ((_image?.width ?? 0) * _scale * _ratio); + final viewHeight = + boundaries.height / ((_image?.height ?? 0) * _scale * _ratio); + + setState(() { + _area = _calculateDefaultArea( + viewWidth: viewWidth, + viewHeight: viewHeight, + imageWidth: _image?.width, + imageHeight: _image?.height, + ); + _view = Rect.fromLTWH( + (viewWidth - 1.0) / 2, + (viewHeight - 1.0) / 2, + viewWidth, + viewHeight, + ); + // disable initial magnification + _scale = _minimumScale ?? 1.0; + _view = _getViewInBoundaries(_scale); + }); + } + + _CropHandleSide _hitCropHandle(Offset? localPoint) { + final boundaries = _boundaries; + if (localPoint == null || boundaries == null) { + return _CropHandleSide.none; + } + + final viewRect = Rect.fromLTWH( + boundaries.width * _area.left, + boundaries.height * _area.top, + boundaries.width * _area.width, + boundaries.height * _area.height, + ).deflate(_kCropHandleSize / 2); + + if (Rect.fromLTWH( + viewRect.left - _kCropHandleHitSize / 2, + viewRect.top - _kCropHandleHitSize / 2, + _kCropHandleHitSize, + _kCropHandleHitSize, + ).contains(localPoint)) { + return _CropHandleSide.topLeft; + } + + if (Rect.fromLTWH( + viewRect.right - _kCropHandleHitSize / 2, + viewRect.top - _kCropHandleHitSize / 2, + _kCropHandleHitSize, + _kCropHandleHitSize, + ).contains(localPoint)) { + return _CropHandleSide.topRight; + } + + if (Rect.fromLTWH( + viewRect.left - _kCropHandleHitSize / 2, + viewRect.bottom - _kCropHandleHitSize / 2, + _kCropHandleHitSize, + _kCropHandleHitSize, + ).contains(localPoint)) { + return _CropHandleSide.bottomLeft; + } + + if (Rect.fromLTWH( + viewRect.right - _kCropHandleHitSize / 2, + viewRect.bottom - _kCropHandleHitSize / 2, + _kCropHandleHitSize, + _kCropHandleHitSize, + ).contains(localPoint)) { + return _CropHandleSide.bottomRight; + } + + return _CropHandleSide.none; + } +} + +class _CropPainter extends CustomPainter { + final ui.Image? image; + final Rect view; + final double ratio; + final Rect area; + final double scale; + final double active; + final Color paintColor; + + _CropPainter({ + required this.image, + required this.view, + required this.ratio, + required this.area, + required this.scale, + required this.active, + required this.paintColor, + }); + + @override + bool shouldRepaint(_CropPainter oldDelegate) { + return oldDelegate.image != image || + oldDelegate.view != view || + oldDelegate.ratio != ratio || + oldDelegate.area != area || + oldDelegate.active != active || + oldDelegate.scale != scale; + } + + @override + void paint(Canvas canvas, Size size) { + final rect = Rect.fromLTWH( + _kCropHandleSize / 2, + _kCropHandleSize / 2, + size.width - _kCropHandleSize, + size.height - _kCropHandleSize, + ); + canvas.save(); + canvas.translate(rect.left, rect.top); + + final paint = Paint()..isAntiAlias = false; + + final image = this.image; + if (image != null) { + final src = Rect.fromLTWH( + 0.0, + 0.0, + image.width.toDouble(), + image.height.toDouble(), + ); + final dst = Rect.fromLTWH( + view.left * image.width * scale * ratio, + view.top * image.height * scale * ratio, + image.width * scale * ratio, + image.height * scale * ratio, + ); + + canvas.save(); + canvas.clipRect(Rect.fromLTWH(0.0, 0.0, rect.width, rect.height)); + canvas.drawImageRect(image, src, dst, paint); + canvas.restore(); + } + + paint.color = paintColor; + + final boundaries = Rect.fromLTWH( + rect.width * area.left, + rect.height * area.top, + rect.width * area.width, + rect.height * area.height, + ); + canvas.drawRect(Rect.fromLTRB(0.0, 0.0, rect.width, boundaries.top), paint); + canvas.drawRect( + Rect.fromLTRB(0.0, boundaries.bottom, rect.width, rect.height), paint); + canvas.drawRect( + Rect.fromLTRB(0.0, boundaries.top, boundaries.left, boundaries.bottom), + paint); + canvas.drawRect( + Rect.fromLTRB( + boundaries.right, boundaries.top, rect.width, boundaries.bottom), + paint); + + if (boundaries.isEmpty == false) { + _drawGrid(canvas, boundaries); + } + + canvas.restore(); + } + + void _drawGrid(Canvas canvas, Rect boundaries) { + if (active == 0.0) return; + + final paint = Paint() + ..isAntiAlias = false + ..color = _kCropGridColor.withOpacity(_kCropGridColor.opacity * active) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + + final path = Path() + ..moveTo(boundaries.left, boundaries.top) + ..lineTo(boundaries.right, boundaries.top) + ..lineTo(boundaries.right, boundaries.bottom) + ..lineTo(boundaries.left, boundaries.bottom) + ..lineTo(boundaries.left, boundaries.top); + for (var column = 1; column < _kCropGridColumnCount; column++) { + path + ..moveTo( + boundaries.left + column * boundaries.width / _kCropGridColumnCount, + boundaries.top) + ..lineTo( + boundaries.left + column * boundaries.width / _kCropGridColumnCount, + boundaries.bottom); + } + + for (var row = 1; row < _kCropGridRowCount; row++) { + path + ..moveTo(boundaries.left, + boundaries.top + row * boundaries.height / _kCropGridRowCount) + ..lineTo(boundaries.right, + boundaries.top + row * boundaries.height / _kCropGridRowCount); + } + + canvas.drawPath(path, paint); + } +} + +class _DisplayVideo extends StatefulWidget { + final File selectedFile; + const _DisplayVideo({Key? key, required this.selectedFile}) : super(key: key); + + @override + State<_DisplayVideo> createState() => _DisplayVideoState(); +} + +class _DisplayVideoState extends State<_DisplayVideo> { + late VideoPlayerController controller; + late Future initializeVideoPlayerFuture; + + @override + void initState() { + super.initState(); + _initVideoController(); + } + + @override + void didUpdateWidget(covariant _DisplayVideo oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectedFile != widget.selectedFile) { + _initVideoController(); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _initVideoController(); + } + + void _initVideoController() { + controller = VideoPlayerController.file(widget.selectedFile); + initializeVideoPlayerFuture = controller.initialize(); + controller.setLooping(true); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: initializeVideoPlayerFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + return Stack( + alignment: Alignment.center, + children: [ + InteractiveViewer( + minScale: 1, + child: SizedBox( + height: double.infinity, + width: double.infinity, + child: VideoPlayer(controller)), + ), + Align( + alignment: Alignment.center, + child: GestureDetector( + onTap: () { + setState(() { + if (controller.value.isPlaying) { + controller.pause(); + } else { + controller.play(); + } + }); + }, + child: Icon( + controller.value.isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 45, + ), + ), + ) + ], + ); + } else { + return const Center( + child: CircularProgressIndicator(strokeWidth: 1), + ); + } + }, + ); + } +} diff --git a/packages/image_picker_plus/lib/src/custom_expand_icon.dart b/packages/image_picker_plus/lib/src/custom_expand_icon.dart new file mode 100644 index 00000000..5e0330dc --- /dev/null +++ b/packages/image_picker_plus/lib/src/custom_expand_icon.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'dart:math' as math; + +class CustomExpandIcon extends StatelessWidget { + const CustomExpandIcon({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Padding( + padding: const EdgeInsets.all(5.0), + child: Align( + alignment: Alignment.topRight, + child: Transform.rotate( + angle: 180 * math.pi / 250, + child: const Icon( + Icons.arrow_back_ios_rounded, + color: Colors.white, + size: 12, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(5.0), + child: Align( + alignment: Alignment.bottomLeft, + child: Transform.rotate( + angle: 180 * math.pi / 255, + child: const Icon( + Icons.arrow_forward_ios_rounded, + color: Colors.white, + size: 12, + ), + ), + ), + ), + ], + ); + } +} diff --git a/packages/image_picker_plus/lib/src/entities/app_theme.dart b/packages/image_picker_plus/lib/src/entities/app_theme.dart new file mode 100644 index 00000000..78d87f1a --- /dev/null +++ b/packages/image_picker_plus/lib/src/entities/app_theme.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + final Color primaryColor; + final Color focusColor; + final Color shimmerBaseColor; + final Color shimmerHighlightColor; + + AppTheme({ + this.primaryColor = Colors.white, + this.focusColor = Colors.black, + this.shimmerBaseColor = const Color.fromARGB(255, 185, 185, 185), + this.shimmerHighlightColor = const Color.fromARGB(255, 209, 209, 209), + }); +} diff --git a/packages/image_picker_plus/lib/src/entities/image_picker_display.dart b/packages/image_picker_plus/lib/src/entities/image_picker_display.dart new file mode 100644 index 00000000..5930552a --- /dev/null +++ b/packages/image_picker_plus/lib/src/entities/image_picker_display.dart @@ -0,0 +1,28 @@ +import 'package:flutter/foundation.dart'; +import 'package:image_picker_plus/image_picker_plus.dart'; +import 'package:flutter/material.dart'; + +/// [GalleryDisplaySettings] When you make ImageSource from the camera these settings will be disabled because they belong to the gallery. +class GalleryDisplaySettings { + AppTheme? appTheme; + TabsTexts? tabsTexts; + SliverGridDelegateWithFixedCrossAxisCount gridDelegate; + bool showImagePreview; + int maximumSelection; + final AsyncValueSetter? callbackFunction; + + /// If [cropImage] true [showImagePreview] will be true + /// Right now this package not support crop video + bool cropImage; + + GalleryDisplaySettings({ + this.appTheme, + this.tabsTexts, + this.callbackFunction, + this.gridDelegate = const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, crossAxisSpacing: 1.7, mainAxisSpacing: 1.5), + this.showImagePreview = false, + this.cropImage = false, + this.maximumSelection = 10, + }); +} diff --git a/packages/image_picker_plus/lib/src/entities/selected_image_details.dart b/packages/image_picker_plus/lib/src/entities/selected_image_details.dart new file mode 100644 index 00000000..0547842b --- /dev/null +++ b/packages/image_picker_plus/lib/src/entities/selected_image_details.dart @@ -0,0 +1,28 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +class SelectedImagesDetails { + final List selectedFiles; + final double aspectRatio; + final bool multiSelectionMode; + + SelectedImagesDetails({ + required this.selectedFiles, + required this.aspectRatio, + required this.multiSelectionMode, + }); +} + +class SelectedByte { + final File selectedFile; + final Uint8List selectedByte; + + final bool isThatImage; + + SelectedByte({ + required this.isThatImage, + required this.selectedFile, + required this.selectedByte, + }); +} diff --git a/packages/image_picker_plus/lib/src/entities/tabs_texts.dart b/packages/image_picker_plus/lib/src/entities/tabs_texts.dart new file mode 100644 index 00000000..aec1da0f --- /dev/null +++ b/packages/image_picker_plus/lib/src/entities/tabs_texts.dart @@ -0,0 +1,27 @@ +class TabsTexts { + final String videoText; + final String photoText; + final String galleryText; + final String deletingText; + + /// [limitingText] if the maximumSelection = 10 it will be "The limit is 10 photos or videos." + String? limitingText; + final String holdButtonText; + final String clearImagesText; + final String notFoundingCameraText; + final String noImagesFounded; + final String acceptAllPermissions; + + TabsTexts({ + this.videoText = "VIDEO", + this.photoText = "PHOTO", + this.clearImagesText = "Clear selected images", + this.galleryText = "GALLERY", + this.deletingText = "DELETE", + this.limitingText, + this.notFoundingCameraText = "No secondary camera found", + this.holdButtonText = "Press and hold to record", + this.noImagesFounded = "There is no images", + this.acceptAllPermissions = "Failed! accept all access permissions.", + }); +} diff --git a/packages/image_picker_plus/lib/src/gallery_display.dart b/packages/image_picker_plus/lib/src/gallery_display.dart new file mode 100644 index 00000000..f6117f44 --- /dev/null +++ b/packages/image_picker_plus/lib/src/gallery_display.dart @@ -0,0 +1,442 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker_plus/image_picker_plus.dart'; +import 'package:image_picker_plus/src/camera_display.dart'; +import 'package:image_picker_plus/src/images_view_page.dart'; + +class CustomImagePicker extends StatefulWidget { + final ImageSource source; + final bool multiSelection; + final GalleryDisplaySettings? galleryDisplaySettings; + final PickerSource pickerSource; + + const CustomImagePicker({ + required this.source, + required this.multiSelection, + required this.galleryDisplaySettings, + required this.pickerSource, + super.key, + }); + + @override + CustomImagePickerState createState() => CustomImagePickerState(); +} + +class CustomImagePickerState extends State + with TickerProviderStateMixin { + final pageController = ValueNotifier(PageController()); + final clearVideoRecord = ValueNotifier(false); + final redDeleteText = ValueNotifier(false); + final selectedPage = ValueNotifier(SelectedPage.left); + ValueNotifier> multiSelectedImage = ValueNotifier([]); + final multiSelectionMode = ValueNotifier(false); + final showDeleteText = ValueNotifier(false); + final selectedVideo = ValueNotifier(false); + bool noGallery = true; + ValueNotifier selectedCameraImage = ValueNotifier(null); + late bool cropImage; + late AppTheme appTheme; + late TabsTexts tabsNames; + late bool showImagePreview; + late int maximumSelection; + final isImagesReady = ValueNotifier(false); + final currentPage = ValueNotifier(0); + final lastPage = ValueNotifier(0); + + late Color whiteColor; + late Color blackColor; + late GalleryDisplaySettings imagePickerDisplay; + + late bool enableCamera; + late bool enableVideo; + late String limitingText; + + late bool showInternalVideos; + late bool showInternalImages; + late SliverGridDelegateWithFixedCrossAxisCount gridDelegate; + late bool cameraAndVideoEnabled; + late bool cameraVideoOnlyEnabled; + late bool showAllTabs; + late AsyncValueSetter? callbackFunction; + + @override + void initState() { + _initializeVariables(); + super.initState(); + } + + _initializeVariables() { + imagePickerDisplay = + widget.galleryDisplaySettings ?? GalleryDisplaySettings(); + appTheme = imagePickerDisplay.appTheme ?? AppTheme(); + tabsNames = imagePickerDisplay.tabsTexts ?? TabsTexts(); + callbackFunction = imagePickerDisplay.callbackFunction; + cropImage = imagePickerDisplay.cropImage; + maximumSelection = imagePickerDisplay.maximumSelection; + limitingText = tabsNames.limitingText ?? + "The limit is $maximumSelection photos or videos."; + + showImagePreview = cropImage || imagePickerDisplay.showImagePreview; + gridDelegate = imagePickerDisplay.gridDelegate; + + showInternalImages = widget.pickerSource != PickerSource.video; + showInternalVideos = widget.pickerSource != PickerSource.image; + + noGallery = widget.source != ImageSource.camera; + bool notGallery = widget.source != ImageSource.gallery; + + enableCamera = showInternalImages && notGallery; + enableVideo = showInternalVideos && notGallery; + cameraAndVideoEnabled = enableCamera && enableVideo; + cameraVideoOnlyEnabled = + cameraAndVideoEnabled && widget.source == ImageSource.camera; + showAllTabs = cameraAndVideoEnabled && noGallery; + whiteColor = appTheme.primaryColor; + blackColor = appTheme.focusColor; + } + + @override + void dispose() { + showDeleteText.dispose(); + selectedVideo.dispose(); + selectedPage.dispose(); + selectedCameraImage.dispose(); + pageController.dispose(); + clearVideoRecord.dispose(); + redDeleteText.dispose(); + multiSelectionMode.dispose(); + multiSelectedImage.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return tabController(); + } + + Widget tabBarMessage(bool isThatDeleteText) { + Color deleteColor = redDeleteText.value ? Colors.red : appTheme.focusColor; + return Center( + child: Padding( + padding: const EdgeInsets.all(14.0), + child: GestureDetector( + onTap: () async { + if (isThatDeleteText) { + setState(() { + if (!redDeleteText.value) { + redDeleteText.value = true; + } else { + selectedCameraImage.value = null; + clearVideoRecord.value = true; + showDeleteText.value = false; + redDeleteText.value = false; + } + }); + } + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (isThatDeleteText) + Icon(Icons.arrow_back_ios_rounded, + color: deleteColor, size: 15), + Text( + isThatDeleteText ? tabsNames.deletingText : limitingText, + style: TextStyle( + fontSize: 14, + color: deleteColor, + fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + ); + } + + Widget clearSelectedImages() { + return Center( + child: Padding( + padding: const EdgeInsets.all(14.0), + child: GestureDetector( + onTap: () async { + setState(() { + multiSelectionMode.value = !multiSelectionMode.value; + multiSelectedImage.value.clear(); + }); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + tabsNames.clearImagesText, + style: TextStyle( + fontSize: 14, + color: appTheme.focusColor, + fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + ); + } + + replacingDeleteWidget(bool showDeleteText) { + this.showDeleteText.value = showDeleteText; + } + + moveToVideo() { + setState(() { + selectedPage.value = SelectedPage.right; + selectedVideo.value = true; + }); + } + + DefaultTabController tabController() { + return DefaultTabController( + length: 2, child: Material(color: whiteColor, child: safeArea())); + } + + SafeArea safeArea() { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: ValueListenableBuilder( + valueListenable: pageController, + builder: (context, PageController pageControllerValue, child) => + PageView( + controller: pageControllerValue, + dragStartBehavior: DragStartBehavior.start, + physics: const NeverScrollableScrollPhysics(), + children: [ + if (noGallery) imagesViewPage(), + if (enableCamera || enableVideo) cameraPage(), + ], + ), + ), + ), + if (multiSelectedImage.value.length < maximumSelection) ...[ + ValueListenableBuilder( + valueListenable: multiSelectionMode, + builder: (context, bool multiSelectionModeValue, child) { + if (enableVideo || enableCamera) { + if (!showImagePreview) { + if (multiSelectionModeValue) { + return clearSelectedImages(); + } else { + return buildTabBar(); + } + } else { + return Visibility( + visible: !multiSelectionModeValue, + child: buildTabBar(), + ); + } + } else { + return multiSelectionModeValue + ? clearSelectedImages() + : const SizedBox(); + } + }, + ) + ] else ...[ + tabBarMessage(false) + ], + ], + ), + ); + } + + ValueListenableBuilder cameraPage() { + return ValueListenableBuilder( + valueListenable: selectedVideo, + builder: (context, bool selectedVideoValue, child) => CustomCameraDisplay( + appTheme: appTheme, + selectedCameraImage: selectedCameraImage, + tabsNames: tabsNames, + enableCamera: enableCamera, + enableVideo: enableVideo, + replacingTabBar: replacingDeleteWidget, + clearVideoRecord: clearVideoRecord, + redDeleteText: redDeleteText, + moveToVideoScreen: moveToVideo, + selectedVideo: selectedVideoValue, + callbackFunction: callbackFunction, + ), + ); + } + + void clearMultiImages() { + setState(() { + multiSelectedImage.value.clear(); + multiSelectionMode.value = false; + }); + } + + ImagesViewPage imagesViewPage() { + return ImagesViewPage( + appTheme: appTheme, + clearMultiImages: clearMultiImages, + callbackFunction: callbackFunction, + gridDelegate: gridDelegate, + multiSelectionMode: multiSelectionMode, + blackColor: blackColor, + showImagePreview: showImagePreview, + tabsTexts: tabsNames, + multiSelectedImages: multiSelectedImage, + whiteColor: whiteColor, + cropImage: cropImage, + multiSelection: widget.multiSelection, + showInternalVideos: showInternalVideos, + showInternalImages: showInternalImages, + maximumSelection: maximumSelection, + ); + } + + ValueListenableBuilder buildTabBar() { + return ValueListenableBuilder( + valueListenable: showDeleteText, + builder: (context, bool showDeleteTextValue, child) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeInOutQuart, + child: widget.source == ImageSource.both || + widget.pickerSource == PickerSource.both + ? (showDeleteTextValue ? tabBarMessage(true) : tabBar()) + : const SizedBox(), + ), + ); + } + + Widget tabBar() { + double widthOfScreen = MediaQuery.of(context).size.width; + int divideNumber = showAllTabs ? 3 : 2; + double widthOfTab = widthOfScreen / divideNumber; + return ValueListenableBuilder( + valueListenable: selectedPage, + builder: (context, SelectedPage selectedPageValue, child) { + Color photoColor = + selectedPageValue == SelectedPage.center ? blackColor : Colors.grey; + return Stack( + alignment: Alignment.bottomLeft, + children: [ + Row( + children: [ + if (noGallery) galleryTabBar(widthOfTab, selectedPageValue), + if (enableCamera) photoTabBar(widthOfTab, photoColor), + if (enableVideo) videoTabBar(widthOfTab), + ], + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOutQuad, + right: selectedPageValue == SelectedPage.center + ? widthOfTab + : (selectedPageValue == SelectedPage.right + ? 0 + : (divideNumber == 2 ? widthOfTab : widthOfScreen / 1.5)), + child: Container(height: 1, width: widthOfTab, color: blackColor), + ), + ], + ); + }, + ); + } + + GestureDetector galleryTabBar( + double widthOfTab, SelectedPage selectedPageValue) { + return GestureDetector( + onTap: () { + setState(() { + centerPage(numPage: 0, selectedPage: SelectedPage.left); + }); + }, + child: SizedBox( + width: widthOfTab, + height: 40, + child: Center( + child: Text( + tabsNames.galleryText, + style: TextStyle( + color: selectedPageValue == SelectedPage.left + ? blackColor + : Colors.grey, + fontSize: 14, + fontWeight: FontWeight.w500), + ), + ), + ), + ); + } + + GestureDetector photoTabBar(double widthOfTab, Color textColor) { + return GestureDetector( + onTap: () => centerPage( + numPage: cameraVideoOnlyEnabled ? 0 : 1, + selectedPage: + cameraVideoOnlyEnabled ? SelectedPage.left : SelectedPage.center), + child: SizedBox( + width: widthOfTab, + height: 40, + child: Center( + child: Text( + tabsNames.photoText, + style: TextStyle( + color: textColor, fontSize: 14, fontWeight: FontWeight.w500), + ), + ), + ), + ); + } + + centerPage({required int numPage, required SelectedPage selectedPage}) { + if (!enableVideo && numPage == 1) selectedPage = SelectedPage.right; + + setState(() { + this.selectedPage.value = selectedPage; + pageController.value.animateToPage(numPage, + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOutQuad); + selectedVideo.value = false; + }); + } + + GestureDetector videoTabBar(double widthOfTab) { + return GestureDetector( + onTap: () { + setState( + () { + pageController.value.animateToPage(cameraVideoOnlyEnabled ? 0 : 1, + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOutQuad); + selectedPage.value = SelectedPage.right; + selectedVideo.value = true; + }, + ); + }, + child: SizedBox( + width: widthOfTab, + height: 40, + child: ValueListenableBuilder( + valueListenable: selectedVideo, + builder: (context, bool selectedVideoValue, child) => Center( + child: Text( + tabsNames.videoText, + style: TextStyle( + fontSize: 14, + color: selectedVideoValue ? blackColor : Colors.grey, + fontWeight: FontWeight.w500), + ), + ), + ), + ), + ); + } +} diff --git a/packages/image_picker_plus/lib/src/image.dart b/packages/image_picker_plus/lib/src/image.dart new file mode 100644 index 00000000..b4de5ed5 --- /dev/null +++ b/packages/image_picker_plus/lib/src/image.dart @@ -0,0 +1,48 @@ +import 'dart:typed_data'; +import 'package:image_picker_plus/image_picker_plus.dart'; +import 'package:flutter/material.dart'; + +class MemoryImageDisplay extends StatefulWidget { + final Uint8List imageBytes; + final AppTheme appTheme; + + const MemoryImageDisplay( + {Key? key, required this.imageBytes, required this.appTheme}) + : super(key: key); + + @override + State createState() => _NetworkImageDisplayState(); +} + +class _NetworkImageDisplayState extends State { + @override + void didChangeDependencies() { + precacheImage(MemoryImage(widget.imageBytes), context); + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + return buildOctoImage(); + } + + Widget buildOctoImage() { + return Container( + width: double.infinity, + color: widget.appTheme.shimmerBaseColor, + child: Image.memory( + widget.imageBytes, + errorBuilder: (context, url, error) => buildError(), + fit: BoxFit.cover, + width: double.infinity, + ), + ); + } + + SizedBox buildError() { + return SizedBox( + width: double.infinity, + child: Icon(Icons.warning_amber_rounded, + color: widget.appTheme.focusColor)); + } +} diff --git a/packages/image_picker_plus/lib/src/image_picker_plus.dart b/packages/image_picker_plus/lib/src/image_picker_plus.dart new file mode 100644 index 00000000..8ed1258e --- /dev/null +++ b/packages/image_picker_plus/lib/src/image_picker_plus.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:image_picker_plus/image_picker_plus.dart'; +import 'package:image_picker_plus/src/gallery_display.dart'; + +class ImagePickerPlus { + final BuildContext _context; + ImagePickerPlus(this._context); + + Future pickImage({ + required ImageSource source, + GalleryDisplaySettings? galleryDisplaySettings, + bool multiImages = false, + }) async { + return _pushToCustomPicker( + galleryDisplaySettings: galleryDisplaySettings, + multiSelection: multiImages, + pickerSource: PickerSource.image, + source: source, + ); + } + + Future pickVideo({ + required ImageSource source, + GalleryDisplaySettings? galleryDisplaySettings, + bool multiVideos = false, + }) async { + return _pushToCustomPicker( + galleryDisplaySettings: galleryDisplaySettings, + multiSelection: multiVideos, + pickerSource: PickerSource.video, + source: source, + ); + } + + Future pickBoth({ + required ImageSource source, + GalleryDisplaySettings? galleryDisplaySettings, + bool multiSelection = false, + }) async { + return _pushToCustomPicker( + galleryDisplaySettings: galleryDisplaySettings, + multiSelection: multiSelection, + pickerSource: PickerSource.both, + source: source, + ); + } + + Future _pushToCustomPicker({ + required ImageSource source, + GalleryDisplaySettings? galleryDisplaySettings, + bool multiSelection = false, + required PickerSource pickerSource, + }) => + Navigator.of(_context, rootNavigator: true).push( + MaterialPageRoute( + builder: (context) => CustomImagePicker( + galleryDisplaySettings: galleryDisplaySettings, + multiSelection: multiSelection, + pickerSource: pickerSource, + source: source, + ), + maintainState: false, + ), + ); +} diff --git a/packages/image_picker_plus/lib/src/images_view_page.dart b/packages/image_picker_plus/lib/src/images_view_page.dart new file mode 100644 index 00000000..791f889b --- /dev/null +++ b/packages/image_picker_plus/lib/src/images_view_page.dart @@ -0,0 +1,747 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker_plus/image_picker_plus.dart'; +import 'package:image_picker_plus/src/crop_image_view.dart'; +import 'package:image_picker_plus/src/custom_crop.dart'; +import 'package:image_picker_plus/src/image.dart'; +import 'package:image_picker_plus/src/multi_selection_mode.dart'; +import 'package:insta_assets_crop/insta_assets_crop.dart'; +import 'package:photo_manager/photo_manager.dart'; +import 'package:shimmer/shimmer.dart'; + +class ImagesViewPage extends StatefulWidget { + final ValueNotifier> multiSelectedImages; + final ValueNotifier multiSelectionMode; + final TabsTexts tabsTexts; + final bool cropImage; + final bool multiSelection; + final bool showInternalVideos; + final bool showInternalImages; + final int maximumSelection; + final AsyncValueSetter? callbackFunction; + + /// To avoid lag when you interacting with image when it expanded + final AppTheme appTheme; + final VoidCallback clearMultiImages; + final Color whiteColor; + final Color blackColor; + final bool showImagePreview; + final SliverGridDelegateWithFixedCrossAxisCount gridDelegate; + const ImagesViewPage({ + Key? key, + required this.multiSelectedImages, + required this.multiSelectionMode, + required this.clearMultiImages, + required this.appTheme, + required this.tabsTexts, + required this.whiteColor, + required this.cropImage, + required this.multiSelection, + required this.showInternalVideos, + required this.showInternalImages, + required this.blackColor, + required this.showImagePreview, + required this.gridDelegate, + required this.maximumSelection, + this.callbackFunction, + }) : super(key: key); + + @override + State createState() => _ImagesViewPageState(); +} + +class _ImagesViewPageState extends State + with AutomaticKeepAliveClientMixin { + final ValueNotifier>> _mediaList = + ValueNotifier([]); + + ValueNotifier> allImages = ValueNotifier([]); + final ValueNotifier> scaleOfCropsKeys = ValueNotifier([]); + final ValueNotifier> areaOfCropsKeys = ValueNotifier([]); + + ValueNotifier selectedImage = ValueNotifier(null); + ValueNotifier> indexOfSelectedImages = ValueNotifier([]); + + ScrollController scrollController = ScrollController(); + + final expandImage = ValueNotifier(false); + final expandHeight = ValueNotifier(0.0); + final moveAwayHeight = ValueNotifier(0.0); + final expandImageView = ValueNotifier(false); + + final isImagesReady = ValueNotifier(false); + final currentPage = ValueNotifier(0); + final lastPage = ValueNotifier(0); + + /// To avoid lag when you interacting with image when it expanded + final enableVerticalTapping = ValueNotifier(false); + final cropKey = ValueNotifier(GlobalKey()); + bool noPaddingForGridView = false; + + double scrollPixels = 0.0; + bool isScrolling = false; + bool noImages = false; + final noDuration = ValueNotifier(false); + int indexOfLatestImage = -1; + + @override + void dispose() { + _mediaList.dispose(); + allImages.dispose(); + scrollController.dispose(); + isImagesReady.dispose(); + lastPage.dispose(); + expandImage.dispose(); + expandHeight.dispose(); + moveAwayHeight.dispose(); + expandImageView.dispose(); + enableVerticalTapping.dispose(); + cropKey.dispose(); + noDuration.dispose(); + scaleOfCropsKeys.dispose(); + areaOfCropsKeys.dispose(); + indexOfSelectedImages.dispose(); + super.dispose(); + } + + late Widget forBack; + @override + void initState() { + _fetchNewMedia(currentPageValue: 0); + super.initState(); + } + + bool _handleScrollEvent(ScrollNotification scroll, + {required int currentPageValue, required int lastPageValue}) { + if (scroll.metrics.pixels / scroll.metrics.maxScrollExtent > 0.33 && + currentPageValue != lastPageValue) { + _fetchNewMedia(currentPageValue: currentPageValue); + return true; + } + return false; + } + + _fetchNewMedia({required int currentPageValue}) async { + lastPage.value = currentPageValue; + PermissionState result = await PhotoManager.requestPermissionExtend(); + if (result.isAuth) { + RequestType type = widget.showInternalVideos && widget.showInternalImages + ? RequestType.common + : (widget.showInternalImages ? RequestType.image : RequestType.video); + + List albums = + await PhotoManager.getAssetPathList(onlyAll: true, type: type); + if (albums.isEmpty) { + WidgetsBinding.instance + .addPostFrameCallback((_) => setState(() => noImages = true)); + return; + } else if (noImages) { + noImages = false; + } + List media = + await albums[0].getAssetListPaged(page: currentPageValue, size: 60); + List> temp = []; + List imageTemp = []; + + for (int i = 0; i < media.length; i++) { + FutureBuilder gridViewImage = + await getImageGallery(media, i); + File? image = await highQualityImage(media, i); + temp.add(gridViewImage); + imageTemp.add(image); + } + _mediaList.value.addAll(temp); + allImages.value.addAll(imageTemp); + selectedImage.value = allImages.value[0]; + currentPage.value++; + isImagesReady.value = true; + WidgetsBinding.instance.addPostFrameCallback((_) => setState(() {})); + } else { + await PhotoManager.requestPermissionExtend(); + PhotoManager.openSetting(); + } + } + + Future> getImageGallery( + List media, int i) async { + bool highResolution = widget.gridDelegate.crossAxisCount <= 3; + FutureBuilder futureBuilder = FutureBuilder( + future: media[i].thumbnailDataWithSize(highResolution + ? const ThumbnailSize(350, 350) + : const ThumbnailSize(200, 200)), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Uint8List? image = snapshot.data; + if (image != null) { + return Container( + color: const Color.fromARGB(255, 189, 189, 189), + child: Stack( + children: [ + Positioned.fill( + child: MemoryImageDisplay( + imageBytes: image, appTheme: widget.appTheme), + ), + if (media[i].type == AssetType.video) + const Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: EdgeInsets.only(right: 5, bottom: 5), + child: Icon( + Icons.slow_motion_video_rounded, + color: Colors.white, + ), + ), + ), + ], + ), + ); + } + } + return const SizedBox(); + }, + ); + return futureBuilder; + } + + Future highQualityImage(List media, int i) async => + media[i].file; + + @override + Widget build(BuildContext context) { + super.build(context); + return noImages + ? Center( + child: Text( + widget.tabsTexts.noImagesFounded, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ) + : buildGridView(); + } + + ValueListenableBuilder buildGridView() { + return ValueListenableBuilder( + valueListenable: isImagesReady, + builder: (context, bool isImagesReadyValue, child) { + if (isImagesReadyValue) { + return ValueListenableBuilder( + valueListenable: _mediaList, + builder: (context, List> mediaListValue, + child) { + return ValueListenableBuilder( + valueListenable: lastPage, + builder: (context, int lastPageValue, child) => + ValueListenableBuilder( + valueListenable: currentPage, + builder: (context, int currentPageValue, child) { + if (!widget.showImagePreview) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + normalAppBar(), + Flexible( + child: normalGridView(mediaListValue, + currentPageValue, lastPageValue)), + ], + ); + } else { + return instagramGridView( + mediaListValue, currentPageValue, lastPageValue); + } + }, + ), + ); + }, + ); + } else { + return loadingWidget(); + } + }, + ); + } + + Widget loadingWidget() { + return SingleChildScrollView( + child: Column( + children: [ + appBar(), + Shimmer.fromColors( + baseColor: widget.appTheme.shimmerBaseColor, + highlightColor: widget.appTheme.shimmerHighlightColor, + child: Column( + children: [ + if (widget.showImagePreview) ...[ + Container( + color: const Color(0xff696969), + height: 360, + width: double.infinity), + const SizedBox(height: 1), + ], + Padding( + padding: EdgeInsets.symmetric( + horizontal: widget.gridDelegate.crossAxisSpacing), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + primary: false, + gridDelegate: widget.gridDelegate, + itemBuilder: (context, index) { + return Container( + color: const Color(0xff696969), + width: double.infinity); + }, + itemCount: 40, + ), + ), + ], + ), + ), + ], + ), + ); + } + + AppBar appBar() { + return AppBar( + backgroundColor: widget.appTheme.primaryColor, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.clear_rounded, + color: widget.appTheme.focusColor, size: 30), + onPressed: () { + Navigator.of(context).maybePop(null); + }, + ), + ); + } + + Widget normalAppBar() { + double width = MediaQuery.of(context).size.width; + return Container( + color: widget.whiteColor, + height: 56, + width: width, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + existButton(), + const Spacer(), + doneButton(), + ], + ), + ); + } + + IconButton existButton() { + return IconButton( + icon: Icon(Icons.clear_rounded, color: widget.blackColor, size: 30), + onPressed: () { + Navigator.of(context).maybePop(null); + }, + ); + } + + Widget doneButton() { + return ValueListenableBuilder( + valueListenable: indexOfSelectedImages, + builder: (context, List indexOfSelectedImagesValue, child) => + IconButton( + icon: const Icon(Icons.arrow_forward_rounded, + color: Colors.blue, size: 30), + onPressed: () async { + final aspect = expandImage.value ? 4 / 5 : 1.0; + if (widget.multiSelectionMode.value && widget.multiSelection) { + if (areaOfCropsKeys.value.length != + widget.multiSelectedImages.value.length) { + scaleOfCropsKeys.value.add(cropKey.value.currentState?.scale); + areaOfCropsKeys.value.add(cropKey.value.currentState?.area); + } else { + if (indexOfLatestImage != -1) { + scaleOfCropsKeys.value[indexOfLatestImage] = + cropKey.value.currentState?.scale; + areaOfCropsKeys.value[indexOfLatestImage] = + cropKey.value.currentState?.area; + } + } + + final selectedBytes = []; + for (int i = 0; i < widget.multiSelectedImages.value.length; i++) { + final currentImage = widget.multiSelectedImages.value[i]; + final isThatVideo = currentImage.isVideo; + final croppedImage = !isThatVideo && widget.cropImage + ? await cropImage(currentImage, indexOfCropImage: i) + : null; + final image = croppedImage ?? currentImage; + final byte = await image.readAsBytes(); + final img = SelectedByte( + isThatImage: !isThatVideo, + selectedFile: image, + selectedByte: byte, + ); + selectedBytes.add(img); + } + if (selectedBytes.isNotEmpty) { + SelectedImagesDetails details = SelectedImagesDetails( + selectedFiles: selectedBytes, + multiSelectionMode: true, + aspectRatio: aspect, + ); + if (!mounted) return; + + if (widget.callbackFunction != null) { + await widget.callbackFunction!(details); + } else { + Navigator.of(context).maybePop(details); + } + } + } else { + final image = selectedImage.value; + if (image == null) return; + final isThatVideo = image.isVideo; + final croppedImage = !isThatVideo && widget.cropImage + ? await cropImage(image) + : null; + final img = croppedImage ?? image; + final byte = await img.readAsBytes(); + + final selectedByte = SelectedByte( + isThatImage: !isThatVideo, + selectedFile: img, + selectedByte: byte, + ); + final details = SelectedImagesDetails( + multiSelectionMode: false, + aspectRatio: aspect, + selectedFiles: [selectedByte], + ); + if (!mounted) return; + + if (widget.callbackFunction != null) { + await widget.callbackFunction!(details); + } else { + Navigator.of(context).maybePop(details); + } + } + }, + ), + ); + } + + Widget normalGridView(List> mediaListValue, + int currentPageValue, int lastPageValue) { + return NotificationListener( + onNotification: (ScrollNotification notification) { + _handleScrollEvent(notification, + currentPageValue: currentPageValue, lastPageValue: lastPageValue); + return true; + }, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: widget.gridDelegate.crossAxisSpacing), + child: GridView.builder( + gridDelegate: widget.gridDelegate, + itemBuilder: (context, index) { + return buildImage(mediaListValue, index); + }, + itemCount: mediaListValue.length, + ), + ), + ); + } + + ValueListenableBuilder buildImage( + List> mediaListValue, int index) { + return ValueListenableBuilder( + valueListenable: selectedImage, + builder: (context, File? selectedImageValue, child) { + return ValueListenableBuilder( + valueListenable: allImages, + builder: (context, List allImagesValue, child) { + return ValueListenableBuilder( + valueListenable: widget.multiSelectedImages, + builder: (context, List selectedImagesValue, child) { + FutureBuilder mediaList = mediaListValue[index]; + File? image = allImagesValue[index]; + if (image != null) { + bool imageSelected = selectedImagesValue.contains(image); + List multiImages = selectedImagesValue; + return Stack( + children: [ + gestureDetector(image, index, mediaList), + if (selectedImageValue == image) + gestureDetector(image, index, blurContainer()), + MultiSelectionMode( + image: image, + multiSelectionMode: widget.multiSelectionMode, + imageSelected: imageSelected, + multiSelectedImage: multiImages, + ), + ], + ); + } else { + return Container(); + } + }, + ); + }, + ); + }, + ); + } + + Container blurContainer() { + return Container( + width: double.infinity, + color: const Color.fromARGB(184, 234, 234, 234), + height: double.maxFinite, + ); + } + + Widget gestureDetector(File image, int index, Widget childWidget) { + return ValueListenableBuilder( + valueListenable: widget.multiSelectionMode, + builder: (context, bool multipleValue, child) => ValueListenableBuilder( + valueListenable: widget.multiSelectedImages, + builder: (context, List selectedImagesValue, child) => + GestureDetector( + onTap: () => onTapImage(image, selectedImagesValue, index), + onLongPress: () { + if (widget.multiSelection) { + widget.multiSelectionMode.value = true; + } + }, + onLongPressUp: () { + if (multipleValue) { + selectionImageCheck(image, selectedImagesValue, index, + enableCopy: true); + expandImageView.value = false; + moveAwayHeight.value = 0; + + enableVerticalTapping.value = false; + setState(() => noPaddingForGridView = true); + } else { + onTapImage(image, selectedImagesValue, index); + } + }, + child: childWidget), + ), + ); + } + + onTapImage(File image, List selectedImagesValue, int index) { + setState(() { + if (widget.multiSelectionMode.value) { + bool close = selectionImageCheck(image, selectedImagesValue, index); + if (close) return; + } + selectedImage.value = image; + expandImageView.value = false; + moveAwayHeight.value = 0; + enableVerticalTapping.value = false; + noPaddingForGridView = true; + }); + } + + bool selectionImageCheck( + File image, List multiSelectionValue, int index, + {bool enableCopy = false}) { + selectedImage.value = image; + if (multiSelectionValue.contains(image) && selectedImage.value == image) { + setState(() { + int indexOfImage = + multiSelectionValue.indexWhere((element) => element == image); + multiSelectionValue.removeAt(indexOfImage); + if (multiSelectionValue.isNotEmpty && + indexOfImage < scaleOfCropsKeys.value.length) { + indexOfSelectedImages.value.remove(index); + + scaleOfCropsKeys.value.removeAt(indexOfImage); + areaOfCropsKeys.value.removeAt(indexOfImage); + indexOfLatestImage = -1; + } + }); + + return true; + } else { + if (multiSelectionValue.length < widget.maximumSelection) { + setState(() { + if (!multiSelectionValue.contains(image)) { + multiSelectionValue.add(image); + if (multiSelectionValue.length > 1) { + scaleOfCropsKeys.value.add(cropKey.value.currentState?.scale); + areaOfCropsKeys.value.add(cropKey.value.currentState?.area); + indexOfSelectedImages.value.add(index); + } + } else if (areaOfCropsKeys.value.length != + multiSelectionValue.length) { + scaleOfCropsKeys.value.add(cropKey.value.currentState?.scale); + areaOfCropsKeys.value.add(cropKey.value.currentState?.area); + } + if (widget.showImagePreview && multiSelectionValue.contains(image)) { + int index = + multiSelectionValue.indexWhere((element) => element == image); + if (indexOfLatestImage != -1) { + scaleOfCropsKeys.value[indexOfLatestImage] = + cropKey.value.currentState?.scale; + areaOfCropsKeys.value[indexOfLatestImage] = + cropKey.value.currentState?.area; + } + indexOfLatestImage = index; + } + + if (enableCopy) selectedImage.value = image; + }); + } + return false; + } + } + + Future cropImage(File imageFile, {int? indexOfCropImage}) async { + await InstaAssetsCrop.requestPermissions(); + final double? scale; + final Rect? area; + if (indexOfCropImage == null) { + scale = cropKey.value.currentState?.scale; + area = cropKey.value.currentState?.area; + } else { + scale = scaleOfCropsKeys.value[indexOfCropImage]; + area = areaOfCropsKeys.value[indexOfCropImage]; + } + + if (area == null || scale == null) return null; + + final sample = await InstaAssetsCrop.sampleImage( + file: imageFile, + preferredSize: (2000 / scale).round(), + ); + + final file = await InstaAssetsCrop.cropImage( + file: sample, + area: area, + ); + sample.delete(); + return file; + } + + void clearMultiImages() { + setState(() { + widget.multiSelectedImages.value = []; + widget.clearMultiImages(); + indexOfSelectedImages.value.clear(); + scaleOfCropsKeys.value.clear(); + areaOfCropsKeys.value.clear(); + }); + } + + Widget instagramGridView( + List> mediaListValue, + int currentPageValue, + int lastPageValue, + ) { + return ValueListenableBuilder( + valueListenable: expandHeight, + builder: (context, double expandedHeightValue, child) { + return ValueListenableBuilder( + valueListenable: moveAwayHeight, + builder: (context, double moveAwayHeightValue, child) => + ValueListenableBuilder( + valueListenable: expandImageView, + builder: (context, bool expandImageValue, child) { + double a = expandedHeightValue - 360; + double expandHeightV = a < 0 ? a : 0; + double moveAwayHeightV = + moveAwayHeightValue < 360 ? moveAwayHeightValue * -1 : -360; + double topPosition = + expandImageValue ? expandHeightV : moveAwayHeightV; + enableVerticalTapping.value = !(topPosition == 0); + double padding = 2; + if (scrollPixels < 416) { + double pixels = 416 - scrollPixels; + padding = pixels >= 58 ? pixels + 2 : 58; + } else if (expandImageValue) { + padding = 58; + } else if (noPaddingForGridView) { + padding = 58; + } else { + padding = topPosition + 418; + } + int duration = noDuration.value ? 0 : 250; + + return Stack( + children: [ + Padding( + padding: EdgeInsets.only(top: padding), + child: NotificationListener( + onNotification: (ScrollNotification notification) { + expandImageView.value = false; + moveAwayHeight.value = scrollController.position.pixels; + scrollPixels = scrollController.position.pixels; + setState(() { + isScrolling = true; + noPaddingForGridView = false; + noDuration.value = false; + if (notification is ScrollEndNotification) { + expandHeight.value = + expandedHeightValue > 240 ? 360 : 0; + isScrolling = false; + } + }); + + _handleScrollEvent(notification, + currentPageValue: currentPageValue, + lastPageValue: lastPageValue); + return true; + }, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: widget.gridDelegate.crossAxisSpacing), + child: GridView.builder( + gridDelegate: widget.gridDelegate, + controller: scrollController, + itemBuilder: (context, index) { + return buildImage(mediaListValue, index); + }, + itemCount: mediaListValue.length, + ), + ), + ), + ), + AnimatedPositioned( + top: topPosition, + duration: Duration(milliseconds: duration), + child: Column( + children: [ + normalAppBar(), + CropImageView( + cropKey: cropKey, + indexOfSelectedImages: indexOfSelectedImages, + withMultiSelection: widget.multiSelection, + selectedImage: selectedImage, + appTheme: widget.appTheme, + multiSelectionMode: widget.multiSelectionMode, + enableVerticalTapping: enableVerticalTapping, + expandHeight: expandHeight, + expandImage: expandImage, + expandImageView: expandImageView, + noDuration: noDuration, + clearMultiImages: clearMultiImages, + topPosition: topPosition, + whiteColor: widget.whiteColor, + ), + ], + ), + ), + ], + ); + }, + ), + ); + }, + ); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/packages/image_picker_plus/lib/src/multi_selection_mode.dart b/packages/image_picker_plus/lib/src/multi_selection_mode.dart new file mode 100644 index 00000000..5c40fcb1 --- /dev/null +++ b/packages/image_picker_plus/lib/src/multi_selection_mode.dart @@ -0,0 +1,55 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +class MultiSelectionMode extends StatelessWidget { + final ValueNotifier multiSelectionMode; + final bool imageSelected; + final List multiSelectedImage; + + final File image; + const MultiSelectionMode({ + Key? key, + required this.image, + required this.imageSelected, + required this.multiSelectedImage, + required this.multiSelectionMode, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: multiSelectionMode, + builder: (context, bool multiSelectionModeValue, child) => Visibility( + visible: multiSelectionModeValue, + child: Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.all(3), + child: Container( + height: 25, + width: 25, + decoration: BoxDecoration( + color: imageSelected + ? Colors.blue + : const Color.fromARGB(115, 222, 222, 222), + border: Border.all( + color: Colors.white, + ), + shape: BoxShape.circle, + ), + child: imageSelected + ? Center( + child: Text( + "${multiSelectedImage.indexOf(image) + 1}", + style: const TextStyle(color: Colors.white), + ), + ) + : Container(), + ), + ), + ), + ), + ); + } +} diff --git a/packages/image_picker_plus/lib/src/utilities/enum.dart b/packages/image_picker_plus/lib/src/utilities/enum.dart new file mode 100644 index 00000000..adbdb493 --- /dev/null +++ b/packages/image_picker_plus/lib/src/utilities/enum.dart @@ -0,0 +1,5 @@ +enum Flash { off, auto, on } + +enum SelectedPage { left, center, right } + +enum PickerSource { image, video, both } diff --git a/packages/image_picker_plus/lib/src/utilities/enum_image_source.dart b/packages/image_picker_plus/lib/src/utilities/enum_image_source.dart new file mode 100644 index 00000000..c2f2720a --- /dev/null +++ b/packages/image_picker_plus/lib/src/utilities/enum_image_source.dart @@ -0,0 +1 @@ +enum ImageSource { camera, gallery, both } diff --git a/packages/image_picker_plus/lib/src/utilities/extensions/extensions.dart b/packages/image_picker_plus/lib/src/utilities/extensions/extensions.dart new file mode 100644 index 00000000..07a8438c --- /dev/null +++ b/packages/image_picker_plus/lib/src/utilities/extensions/extensions.dart @@ -0,0 +1 @@ +export 'file_extension.dart'; diff --git a/packages/image_picker_plus/lib/src/utilities/extensions/file_extension.dart b/packages/image_picker_plus/lib/src/utilities/extensions/file_extension.dart new file mode 100644 index 00000000..ee901295 --- /dev/null +++ b/packages/image_picker_plus/lib/src/utilities/extensions/file_extension.dart @@ -0,0 +1,5 @@ +import 'dart:io'; + +extension FileExtension on File { + bool get isVideo => path.toLowerCase().contains('mp4', path.length - 5); +} diff --git a/packages/image_picker_plus/lib/src/utilities/typedef.dart b/packages/image_picker_plus/lib/src/utilities/typedef.dart new file mode 100644 index 00000000..2babc2a8 --- /dev/null +++ b/packages/image_picker_plus/lib/src/utilities/typedef.dart @@ -0,0 +1,2 @@ +typedef CustomThreeAsyncValueSetter = A Function(B value, C value2); +typedef CustomTwoAsyncValueSetter = Future Function(B value); diff --git a/packages/image_picker_plus/lib/src/utilities/utilities.dart b/packages/image_picker_plus/lib/src/utilities/utilities.dart new file mode 100644 index 00000000..f33b2103 --- /dev/null +++ b/packages/image_picker_plus/lib/src/utilities/utilities.dart @@ -0,0 +1,4 @@ +export 'extensions/extensions.dart'; +export 'enum_image_source.dart'; +export 'enum.dart'; +export 'typedef.dart'; \ No newline at end of file diff --git a/packages/image_picker_plus/lib/src/video_layout/record_count.dart b/packages/image_picker_plus/lib/src/video_layout/record_count.dart new file mode 100644 index 00000000..055f660a --- /dev/null +++ b/packages/image_picker_plus/lib/src/video_layout/record_count.dart @@ -0,0 +1,147 @@ +import 'package:image_picker_plus/image_picker_plus.dart'; +import 'package:flutter/material.dart'; + +class RecordCount extends StatefulWidget { + final ValueNotifier startVideoCount; + final ValueNotifier makeProgressRed; + final ValueNotifier clearVideoRecord; + final AppTheme appTheme; + + const RecordCount({ + Key? key, + required this.appTheme, + required this.startVideoCount, + required this.makeProgressRed, + required this.clearVideoRecord, + }) : super(key: key); + + @override + RecordCountState createState() => RecordCountState(); +} + +class RecordCountState extends State + with TickerProviderStateMixin { + late AnimationController controller; + double opacityLevel = 1.0; + bool isPlaying = false; + + String get countText { + Duration count = controller.duration! * controller.value; + if (controller.isDismissed) { + return '0:00'; + } else { + return '${(count.inMinutes % 60).toString().padLeft(1, '0')}:${(count.inSeconds % 60).toString().padLeft(2, '0')}'; + } + } + + double progress = 0; + + @override + void initState() { + super.initState(); + controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 60), + ); + + controller.addListener(() { + if (controller.isAnimating) { + setState(() { + progress = controller.value; + }); + } else { + setState(() { + progress = 0; + isPlaying = false; + }); + } + }); + } + + @override + void didUpdateWidget(RecordCount oldWidget) { + if (widget.startVideoCount.value) { + controller.forward(from: controller.value == 1.0 ? 0 : controller.value); + setState(() { + isPlaying = true; + opacityLevel = opacityLevel == 0 ? 1.0 : 0.0; + }); + } else { + if (widget.clearVideoRecord.value) { + widget.clearVideoRecord.value = false; + controller.reset(); + setState(() { + isPlaying = false; + }); + } else { + controller.stop(); + setState(() { + isPlaying = false; + }); + } + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LinearProgressIndicator( + color: widget.makeProgressRed.value + ? Colors.red + : widget.appTheme.focusColor, + backgroundColor: Colors.transparent, + value: progress, + minHeight: 3, + ), + Visibility( + visible: widget.startVideoCount.value, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: Padding( + padding: const EdgeInsets.only(top: 30), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedOpacity( + opacity: opacityLevel, + duration: const Duration(seconds: 1), + child: const Icon(Icons.fiber_manual_record_rounded, + color: Colors.red, size: 10), + onEnd: () { + if (isPlaying) { + setState( + () => opacityLevel = opacityLevel == 0 ? 1.0 : 0.0); + } + }, + ), + const SizedBox(width: 5), + AnimatedBuilder( + animation: controller, + builder: (context, child) => Text( + countText, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.normal, + color: widget.appTheme.focusColor, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/packages/image_picker_plus/lib/src/video_layout/record_fade_animation.dart b/packages/image_picker_plus/lib/src/video_layout/record_fade_animation.dart new file mode 100644 index 00000000..88abe531 --- /dev/null +++ b/packages/image_picker_plus/lib/src/video_layout/record_fade_animation.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +class RecordFadeAnimation extends StatefulWidget { + const RecordFadeAnimation({Key? key, required this.child}) : super(key: key); + + final Widget child; + + @override + RecordFadeAnimationState createState() => RecordFadeAnimationState(); +} + +class RecordFadeAnimationState extends State + with TickerProviderStateMixin { + late AnimationController _controller; + late final Animation _animation = CurvedAnimation( + parent: _controller, + curve: Curves.fastOutSlowIn, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void initState() { + _controller = + AnimationController(duration: const Duration(seconds: 1), vsync: this); + _controller.addListener(() async { + if (_controller.isCompleted) { + await Future.delayed(const Duration(seconds: 3)).then((value) { + _controller.reverse(); + }); + } + }); + super.initState(); + } + + @override + void didUpdateWidget(RecordFadeAnimation oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.child != widget.child) { + _controller.forward(from: 0.0); + } + } + + @override + Widget build(BuildContext context) { + return Center( + child: ScaleTransition( + scale: _animation, + child: widget.child, + ), + ); + } +} diff --git a/packages/image_picker_plus/pub_login.sh b/packages/image_picker_plus/pub_login.sh new file mode 100644 index 00000000..2b044995 --- /dev/null +++ b/packages/image_picker_plus/pub_login.sh @@ -0,0 +1,35 @@ +his script creates/updates credentials.json file which is used +# to authorize publisher when publishing packages to pub.dev + +# Checking whether the secrets are available as environment +# variables or not. +if [ -z "${PUB_DEV_PUBLISH_ACCESS_TOKEN}" ]; then + echo "Missing PUB_DEV_PUBLISH_ACCESS_TOKEN environment variable" + exit 1 +fi + +if [ -z "${PUB_DEV_PUBLISH_REFRESH_TOKEN}" ]; then + echo "Missing PUB_DEV_PUBLISH_REFRESH_TOKEN environment variable" + exit 1 +fi + +if [ -z "${PUB_DEV_PUBLISH_TOKEN_ENDPOINT}" ]; then + echo "Missing PUB_DEV_PUBLISH_TOKEN_ENDPOINT environment variable" + exit 1 +fi + +if [ -z "${PUB_DEV_PUBLISH_EXPIRATION}" ]; then + echo "Missing PUB_DEV_PUBLISH_EXPIRATION environment variable" + exit 1 +fi + +# Create credentials.json file. +cat < ~/.pub-cache/credentials.json +{ + "accessToken":"${PUB_DEV_PUBLISH_ACCESS_TOKEN}", + "refreshToken":"${PUB_DEV_PUBLISH_REFRESH_TOKEN}", + "tokenEndpoint":"${PUB_DEV_PUBLISH_TOKEN_ENDPOINT}", + "scopes":["https://www.googleapis.com/auth/userinfo.email","openid"], + "expiration":${PUB_DEV_PUBLISH_EXPIRATION} +} +EOF \ No newline at end of file diff --git a/packages/image_picker_plus/pubspec.yaml b/packages/image_picker_plus/pubspec.yaml new file mode 100644 index 00000000..592325ef --- /dev/null +++ b/packages/image_picker_plus/pubspec.yaml @@ -0,0 +1,25 @@ +name: image_picker_plus +description: Customization of the gallery display or even camera and video. +version: 0.5.9 +homepage: https://github.com/AhmedAbdoElhawary/image_picker_plus.git + +environment: + sdk: '>=2.17.1 <4.0.0' + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + photo_manager: ^3.0.0-dev.5 + shimmer: ^3.0.0 + camera: ^0.10.5+2 + image: ^4.0.17 + video_player: ^2.7.0 + image_picker: ^1.0.1 + insta_assets_crop: ^0.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^2.0.0 diff --git a/packages/image_picker_plus/test/custom_gallery_display_test.dart b/packages/image_picker_plus/test/custom_gallery_display_test.dart new file mode 100644 index 00000000..9a3bf783 --- /dev/null +++ b/packages/image_picker_plus/test/custom_gallery_display_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('adds one to input values', () { + // final calculator = Calculator(); + // expect(calculator.addOne(2), 3); + // expect(calculator.addOne(-7), -6); + // expect(calculator.addOne(0), 1); + }); +} diff --git a/packages/instagram_blocks_ui/lib/src/user_profile/user_profile_avatar.dart b/packages/instagram_blocks_ui/lib/src/user_profile/user_profile_avatar.dart index 3711fbbf..d7c65fbd 100644 --- a/packages/instagram_blocks_ui/lib/src/user_profile/user_profile_avatar.dart +++ b/packages/instagram_blocks_ui/lib/src/user_profile/user_profile_avatar.dart @@ -96,22 +96,24 @@ class UserProfileAvatar extends StatelessWidget { ), ); - Future _pickImage() async { - final imageFile = await PickImage.imageWithXImagePicker( - source: ImageSource.gallery, - ); + Future _pickImage(BuildContext context) async { + final imageFile = + await PickImage.pickImage(context, source: ImageSource.both); if (imageFile == null) return; + final selectedFile = imageFile.selectedFiles.firstOrNull; + if (selectedFile == null) return; final avatarsStorage = Supabase.instance.client.storage.from('avatars'); - final bytes = await imageFile.readAsBytes(); - final fileExt = imageFile.path.split('.').last; + final bytes = selectedFile.selectedByte; + final fileExt = + selectedFile.selectedFile.path.split('.').last.toLowerCase(); final fileName = '${DateTime.now().toIso8601String()}.$fileExt'; final filePath = fileName; await avatarsStorage.uploadBinary( filePath, bytes, - fileOptions: FileOptions(contentType: imageFile.mimeType), + fileOptions: FileOptions(contentType: 'image/$fileExt'), ); final imageUrlResponse = await avatarsStorage.createSignedUrl(filePath, 60 * 60 * 24 * 365 * 10); @@ -218,7 +220,7 @@ class UserProfileAvatar extends StatelessWidget { return Tappable( onTap: onTap == null ? null : () => onTap?.call(avatarUrl), - onLongPress: isImagePicker ? _pickImage : onLongPress, + onLongPress: isImagePicker ? () => _pickImage.call(context) : onLongPress, animationEffect: animationEffect, scaleStrength: scaleStrength, child: avatar, diff --git a/packages/instagram_blocks_ui/lib/src/widgets/avatar_image_picker.dart b/packages/instagram_blocks_ui/lib/src/widgets/avatar_image_picker.dart index 35976461..71b47927 100644 --- a/packages/instagram_blocks_ui/lib/src/widgets/avatar_image_picker.dart +++ b/packages/instagram_blocks_ui/lib/src/widgets/avatar_image_picker.dart @@ -25,20 +25,22 @@ class AvatarImagePicker extends StatelessWidget { final double placeholderSize; final bool withPlaceholder; - Future _pickImage() async { - final file = await PickImage.imageWithImagePicker( - source: ImageSource.gallery, - compress: compress, + Future _pickImage(BuildContext context) async { + final file = await PickImage.pickImage( + context, + source: ImageSource.both, ); if (file == null) return; - final imageBytes = await PickImage.imageBytes(file: file); - onUpload?.call(imageBytes, file); + + final selectedFile = file.selectedFiles.firstOrNull; + if (selectedFile == null) return; + onUpload?.call(selectedFile.selectedByte, selectedFile.selectedFile); } @override Widget build(BuildContext context) { return Tappable( - onTap: _pickImage, + onTap: () => _pickImage.call(context), child: Stack( children: [ CircleAvatar( diff --git a/packages/shared/lib/shared.dart b/packages/shared/lib/shared.dart index b0a4a0b9..63c3b9b0 100644 --- a/packages/shared/lib/shared.dart +++ b/packages/shared/lib/shared.dart @@ -1,6 +1,7 @@ /// A package that manages shared data. library shared; +export 'package:image_picker_plus/image_picker_plus.dart'; export 'package:insta_blocks/insta_blocks.dart'; export 'src/config/config.dart'; diff --git a/packages/shared/lib/src/config/blur_hash.dart b/packages/shared/lib/src/config/blur_hash.dart new file mode 100644 index 00000000..4a025236 --- /dev/null +++ b/packages/shared/lib/src/config/blur_hash.dart @@ -0,0 +1,12 @@ +import 'dart:typed_data'; + +import 'package:blurhash/blurhash.dart'; + +/// {@template video_thumbnail_plus} +/// A package that manages video thumbnail. +/// {@endtemplate} +class BlurHashPlus { + /// Returns a [Uint8List] containing the thumbnail of the video. + static Future blurHashEncode(Uint8List pixels) => + BlurHash.encode(pixels, 4, 3); +} diff --git a/packages/shared/lib/src/config/config.dart b/packages/shared/lib/src/config/config.dart index 8df94394..83de95cb 100644 --- a/packages/shared/lib/src/config/config.dart +++ b/packages/shared/lib/src/config/config.dart @@ -1,5 +1,4 @@ -export 'package:image_picker/image_picker.dart'; - +export 'blur_hash.dart'; export 'debouncer.dart'; export 'image_picker.dart'; export 'logger.dart'; @@ -7,3 +6,4 @@ export 'throttler.dart'; export 'tld.dart'; export 'uid.dart'; export 'utilities/utilities.dart'; +export 'video_thumbnail.dart'; diff --git a/packages/shared/lib/src/config/image_compress.dart b/packages/shared/lib/src/config/image_compress.dart new file mode 100644 index 00000000..e6d208e0 --- /dev/null +++ b/packages/shared/lib/src/config/image_compress.dart @@ -0,0 +1,43 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_image_compress/flutter_image_compress.dart'; + +/// {@template image_compress} +/// Allows compressing image files and bytes. +/// {@endtemplate} +class ImageCompress { + const ImageCompress._(); + + /// Compress image byte. + static Future compressByte(Uint8List? file) async { + if (file == null) return null; + if (file.lengthInBytes > 200000) { + final result = await FlutterImageCompress.compressWithList( + file, + quality: file.lengthInBytes > 4000000 ? 90 : 72, + ); + return result; + } else { + return file; + } + } + + /// Compress image file. + static Future compressFile(File? file, {int quality = 5}) async { + if (file == null) return null; + final filePath = file.absolute.path; + + final lastIndex = filePath.lastIndexOf(RegExp('.jp')); + if (lastIndex == -1) { + return null; + } + final split = filePath.substring(0, lastIndex); + final outPath = '${split}_out${filePath.substring(lastIndex)}'; + return FlutterImageCompress.compressAndGetFile( + file.absolute.path, + outPath, + quality: quality, + ); + } +} diff --git a/packages/shared/lib/src/config/image_picker.dart b/packages/shared/lib/src/config/image_picker.dart index bef3e2dc..647da5a9 100644 --- a/packages/shared/lib/src/config/image_picker.dart +++ b/packages/shared/lib/src/config/image_picker.dart @@ -2,23 +2,35 @@ import 'dart:io'; -import 'package:app_ui/app_ui.dart'; -import 'package:file_picker/file_picker.dart'; +import 'package:app_ui/app_ui.dart' hide AppTheme; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_image_compress/flutter_image_compress.dart'; +import 'package:image_picker_plus/image_picker_plus.dart'; import 'package:insta_assets_picker/insta_assets_picker.dart'; -import 'package:shared/shared.dart'; +// import 'package:images_picker/images_picker.dart'; class PickImage { /// {@macro image_picker} const PickImage._(); - static final _imagePicker = ImagePicker(); + static AppTheme _appTheme(BuildContext context) => AppTheme( + focusColor: Colors.white, + primaryColor: Colors.black, + ); + + static TabsTexts _tabsTexts(BuildContext context) => TabsTexts(); - static final _filePicker = FilePicker.platform; + static SliverGridDelegateWithFixedCrossAxisCount _sliverGridDelegate() => + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 1.7, + mainAxisSpacing: 1.5, + ); - static Future?> pickFiles( + static ImagePickerPlus _imagePickerPlus(BuildContext context) => + ImagePickerPlus(context); + + static Future?> pickAssets( BuildContext context, { required ValueSetter> onCompleted, int maxAssets = 10, @@ -32,134 +44,77 @@ class PickImage { closeOnComplete: closeOnComplete, ); - /// Pick image with image picker from `source` (Gallery or Camera). - static Future imageWithXImagePicker({ - required ImageSource source, - double? maxHeight, - double? maxWidth, - bool requestFullMetaData = true, - bool compress = true, - }) async { - final file = await _imagePicker.pickImage( - source: source, - maxHeight: maxHeight, - maxWidth: maxWidth, - requestFullMetadata: requestFullMetaData, - ); - if (file == null) return null; - final imageFile = File(file.path); - if (!compress) return file; - logD('Compressing image...'); + static Future pickAssetsFromBoth( + BuildContext context, { + required Future Function( + BuildContext context, + SelectedImagesDetails, + ) onMediaPicked, + bool cropImage = true, + bool showPreview = true, + int maxSelection = 10, + bool multiSelection = true, + }) => + _imagePickerPlus(context).pickBoth( + source: ImageSource.both, + multiSelection: multiSelection, + galleryDisplaySettings: GalleryDisplaySettings( + maximumSelection: maxSelection, + showImagePreview: showPreview, + cropImage: cropImage, + tabsTexts: _tabsTexts(context), + appTheme: _appTheme(context), + callbackFunction: (details) => onMediaPicked.call(context, details), + ), + ); - final compressedImage = await compressFile(imageFile); - if (compressedImage == null) return file; - return compressedImage; - } + static Future pickImage( + BuildContext context, { + ImageSource source = ImageSource.gallery, + int maxSelection = 1, + bool cropImage = true, + bool multiImages = false, + bool showPreview = true, + }) => + _imagePickerPlus(context).pickImage( + source: source, + multiImages: multiImages, + galleryDisplaySettings: GalleryDisplaySettings( + cropImage: cropImage, + maximumSelection: maxSelection, + showImagePreview: showPreview, + tabsTexts: _tabsTexts(context), + appTheme: _appTheme(context), + gridDelegate: _sliverGridDelegate(), + ), + ); - /// Pick image with image picker from `source` (Gallery or Camera). - static Future imageWithImagePicker({ - required ImageSource source, - double? maxHeight, - double? maxWidth, - bool requestFullMetaData = true, - bool compress = true, + static Future pickVideo( + BuildContext context, { + required Future Function( + BuildContext context, + SelectedImagesDetails, + ) onMediaPicked, + ImageSource source = ImageSource.both, + int maxSelection = 10, + bool cropImage = true, + bool multiImages = false, + bool showPreview = true, }) async { - final file = await _imagePicker.pickImage( + await _imagePickerPlus(context).pickVideo( source: source, - maxHeight: maxHeight, - maxWidth: maxWidth, - requestFullMetadata: requestFullMetaData, + galleryDisplaySettings: GalleryDisplaySettings( + showImagePreview: showPreview, + cropImage: cropImage, + maximumSelection: maxSelection, + tabsTexts: _tabsTexts(context), + appTheme: _appTheme(context), + callbackFunction: (details) => onMediaPicked.call(context, details), + ), ); - if (file == null) return null; - final imageFile = File(file.path); - if (!compress) return imageFile; - logD('Compressing image...'); - - final compressedImage = await compressFile(imageFile); - if (compressedImage == null) return imageFile; - return File(compressedImage.path); - } - - /// Pick image with image picker from `source` (Gallery or Camera). - static Future> multipleImagesWithImagePicker({ - double? maxHeight, - double? maxWidth, - bool requestFullMetaData = true, - bool compress = false, - }) async { - final files = await _imagePicker.pickMultiImage( - maxHeight: maxHeight, - maxWidth: maxWidth, - requestFullMetadata: requestFullMetaData, - ); - if (files.isEmpty) return []; - - final imagesFile = []; - for (final file in files) { - final imageFile = File(file.path); - if (!compress) { - imagesFile.add(imageFile); - continue; - } - logD('Compressing image...'); - - final compressedImage = await compressFile(imageFile); - if (compressedImage == null) { - imagesFile.add(imageFile); - continue; - } - final compressedImageFile = File(compressedImage.path); - - imagesFile.add(compressedImageFile); - } - return imagesFile; - } - - /// Pick image from `source` (Gallery or Camera). - static Future imageWithFilePicker({ - bool compress = true, - }) async { - final picker = await _filePicker.pickFiles( - type: FileType.custom, - allowedExtensions: ['jpeg', 'jpg', 'png'], - ); - if (picker == null || picker.files.isEmpty) return null; - - final file = picker.files.first; - final imageFile = File(file.path!); - if (!compress) return imageFile; - logD('Compressing image...'); - - final compressedImage = await compressFile(imageFile); - if (compressedImage == null) return imageFile; - return File(compressedImage.path); } /// Reads image as bytes. static Future imageBytes({required File file}) => compute((file) => file.readAsBytes(), file); - - /// Compresses file. - static Future compressFile( - File file, { - int quality = 5, - }) async { - final filePath = file.absolute.path; - - // Create output file path - // eg:- "Volume/VM/abcd_out.jpeg" - final lastIndex = filePath.lastIndexOf(RegExp('.jp')); - if (lastIndex == -1) { - return null; - } - final splitted = filePath.substring(0, lastIndex); - final outPath = '${splitted}_out${filePath.substring(lastIndex)}'; - final result = await FlutterImageCompress.compressAndGetFile( - file.absolute.path, - outPath, - quality: quality, - ); - - return result; - } } diff --git a/packages/shared/lib/src/config/video_thumbnail.dart b/packages/shared/lib/src/config/video_thumbnail.dart new file mode 100644 index 00000000..c0046901 --- /dev/null +++ b/packages/shared/lib/src/config/video_thumbnail.dart @@ -0,0 +1,13 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:video_thumbnail/video_thumbnail.dart'; + +/// {@template video_thumbnail_plus} +/// A package that manages video thumbnail. +/// {@endtemplate} +class VideoThumbnailPlus { + /// Returns a [Uint8List] containing the thumbnail of the video. + static Future getVideoThumbnail(File video) => + VideoThumbnail.thumbnailData(video: video.path); +} diff --git a/packages/shared/pubspec.yaml b/packages/shared/pubspec.yaml index dabff4ab..0c8f47aa 100644 --- a/packages/shared/pubspec.yaml +++ b/packages/shared/pubspec.yaml @@ -10,16 +10,19 @@ environment: dependencies: app_ui: path: ../app_ui + blurhash: ^1.1.1 dio: ^5.4.0 equatable: ^2.0.5 - file_picker: ^6.1.1 flutter: sdk: flutter flutter_image_compress: ^2.1.0 freezed_annotation: ^2.4.1 http_parser: ^4.0.2 image_picker: ^1.0.7 + image_picker_plus: + path: ../image_picker_plus image_size_getter: ^2.1.2 + # images_picker: ^1.2.11 insta_assets_picker: ^2.2.1 insta_blocks: path: ../insta_blocks @@ -34,6 +37,7 @@ dependencies: user_repository: path: ../user_repository uuid: ^4.3.3 + video_thumbnail: ^0.5.3 dev_dependencies: build_runner: ^2.4.8 diff --git a/packages/stories_editor/lib/src/presentation/utils/color_detection.dart b/packages/stories_editor/lib/src/presentation/utils/color_detection.dart index 5c731eba..64bb0c2d 100644 --- a/packages/stories_editor/lib/src/presentation/utils/color_detection.dart +++ b/packages/stories_editor/lib/src/presentation/utils/color_detection.dart @@ -33,12 +33,12 @@ class ColorDetection { double px = localPosition.dx; double py = localPosition.dy; - //int pixel32 = photo!.getPixelSafe(px.toInt(), py.toInt()); - int pixel32 = photo!.getPixelSafe(px.toInt(), py.toInt()); - int hex = abgrToArgb(pixel32); + final pixel32 = + photo!.getPixelSafe(px.toInt(), py.toInt()).toList().cast(); + final hex = pixel32ToArgb(pixel32); - stateController!.add(Color(hex)); - return Color(hex); + stateController!.add(Color(hex).withOpacity(.95)); + return Color(hex).withOpacity(.95); } Future loadSnapshotBytes() async { @@ -58,9 +58,18 @@ class ColorDetection { } } -// image lib uses uses KML color format, convert #AABBGGRR to regular #AARRGGBB +// image lib uses KML color format, convert #AABBGGRR to regular #AARRGGBB int abgrToArgb(int argbColor) { int r = (argbColor >> 16) & 0xFF; int b = argbColor & 0xFF; return (argbColor & 0xFF00FF00) | (b << 16) | r; } + +int pixel32ToArgb(List pixel) { + if (pixel.length != 4) { + throw ArgumentError('Pixel must have four components.'); + } + + // Shift and pack the channels into a single integer in ARGB format + return (pixel[3] << 24) | (pixel[0] << 16) | (pixel[1] << 8) | pixel[2]; +} \ No newline at end of file diff --git a/packages/stories_editor/lib/src/presentation/widgets/file_image_bg.dart b/packages/stories_editor/lib/src/presentation/widgets/file_image_bg.dart index 30855a8b..ce479213 100644 --- a/packages/stories_editor/lib/src/presentation/widgets/file_image_bg.dart +++ b/packages/stories_editor/lib/src/presentation/widgets/file_image_bg.dart @@ -13,7 +13,7 @@ class FileImageBG extends StatefulWidget { required this.filePath, required this.generatedGradient, }); - + @override State createState() => _FileImageBGState(); } @@ -28,48 +28,64 @@ class _FileImageBGState extends State { Color color1 = const Color(0xFFFFFFFF); Color color2 = const Color(0xFFFFFFFF); + Timer? _timer; + @override void initState() { currentKey = paintKey; - Timer.periodic(const Duration(milliseconds: 500), (callback) async { - if (imageKey.currentState!.context.size!.height == 0.0) { - } else { - var cd1 = await ColorDetection( - currentKey: currentKey, - paintKey: paintKey, - stateController: stateController, - ).searchPixel( - Offset(imageKey.currentState!.context.size!.width / 2, 480)); - var cd12 = await ColorDetection( - currentKey: currentKey, - paintKey: paintKey, - stateController: stateController, - ).searchPixel( - Offset(imageKey.currentState!.context.size!.width / 2.03, 530)); - color1 = cd1; - color2 = cd12; - if (mounted) setState(() {}); - widget.generatedGradient(color1, color2); - callback.cancel(); - stateController.close(); - } - }); + if (mounted) { + _timer = + Timer.periodic(const Duration(milliseconds: 500), _periodicFunction); + } super.initState(); } + Future _periodicFunction(Timer callback) async { + if (imageKey.currentState?.context.size?.height == 0.0) { + } else { + var cd1 = await ColorDetection( + currentKey: currentKey, + paintKey: paintKey, + stateController: stateController, + ).searchPixel( + Offset(imageKey.currentState!.context.size!.width / 2, 480)); + var cd12 = await ColorDetection( + currentKey: currentKey, + paintKey: paintKey, + stateController: stateController, + ).searchPixel( + Offset(imageKey.currentState!.context.size!.width / 2.03, 530)); + color1 = cd1; + color2 = cd12; + if (mounted) setState(() {}); + widget.generatedGradient(color1, color2); + callback.cancel(); + stateController.close(); + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { final ScreenUtil screenUtil = ScreenUtil(); return SizedBox( - height: screenUtil.screenHeight, - width: screenUtil.screenWidth, - child: RepaintBoundary( - key: paintKey, - child: Center( - child: Image.file( - File(widget.filePath!.path), - key: imageKey, - filterQuality: FilterQuality.high, - )))); + height: screenUtil.screenHeight, + width: screenUtil.screenWidth, + child: RepaintBoundary( + key: paintKey, + child: Center( + child: Image.file( + File(widget.filePath!.path), + key: imageKey, + filterQuality: FilterQuality.high, + ), + ), + ), + ); } } diff --git a/packages/stories_editor/pubspec.yaml b/packages/stories_editor/pubspec.yaml index b504ceba..9b240c94 100644 --- a/packages/stories_editor/pubspec.yaml +++ b/packages/stories_editor/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: # state management provider: ^6.1.1 # image - image: ^3.0.2 + image: ^4.1.4 path_provider: ^2.1.2 photo_view: ^0.14.0 # with this ref fix `Looking up a deactivated widget's ancestor is unsafe`. diff --git a/pubspec.lock b/pubspec.lock index fe41a726..5b6c4a4e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -111,6 +111,22 @@ packages: url: "https://pub.dev" source: hosted version: "9.1.5" + blurhash: + dependency: transitive + description: + name: blurhash + sha256: "9606bcbb744e99f66e75eaf25227966dc59a9ab8ce9ad8e66faab0ffe6edbdea" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + blurhash_dart: + dependency: transitive + description: + name: blurhash_dart + sha256: "43955b6c2e30a7d440028d1af0fa185852f3534b795cc6eb81fbf397b464409f" + url: "https://pub.dev" + source: hosted + version: "1.2.1" boolean_selector: dependency: transitive description: @@ -215,6 +231,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + camera: + dependency: transitive + description: + name: camera + sha256: "9499cbc2e51d8eb0beadc158b288380037618ce4e30c9acbc4fae1ac3ecb5797" + url: "https://pub.dev" + source: hosted + version: "0.10.5+9" + camera_android: + dependency: transitive + description: + name: camera_android + sha256: "351429510121d179b9aac5a2e8cb525c3cd6c39f4d709c5f72dfb21726e52371" + url: "https://pub.dev" + source: hosted + version: "0.10.8+16" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "608b56b0880722f703871329c4d7d4c2f379c8e2936940851df7fc041abc6f51" + url: "https://pub.dev" + source: hosted + version: "0.9.13+10" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: e971ebca970f7cfee396f76ef02070b5e441b4aa04942da9c108d725f57bbd32 + url: "https://pub.dev" + source: hosted + version: "2.7.2" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: f18ccfb33b2a7c49a52ad5aa3f07330b7422faaecbdfd9b9fe8e51182f6ad67d + url: "https://pub.dev" + source: hosted + version: "0.3.2+4" carousel_slider: dependency: "direct main" description: @@ -412,14 +468,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" - file_picker: - dependency: transitive - description: - name: file_picker - sha256: "4e42aacde3b993c5947467ab640882c56947d9d27342a5b6f2895b23956954a6" - url: "https://pub.dev" - source: hosted - version: "6.1.1" file_selector_linux: dependency: transitive description: @@ -928,10 +976,10 @@ packages: dependency: transitive description: name: image - sha256: "8e9d133755c3e84c73288363e6343157c383a0c6c56fc51afcc5d4d7180306d6" + sha256: "004a2e90ce080f8627b5a04aecb4cdfac87d2c3f3b520aa291260be5a32c033d" url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "4.1.4" image_gallery_saver: dependency: transitive description: @@ -996,6 +1044,13 @@ packages: url: "https://pub.dev" source: hosted version: "2.9.3" + image_picker_plus: + dependency: transitive + description: + path: "packages/image_picker_plus" + relative: true + source: path + version: "0.5.9" image_picker_windows: dependency: transitive description: @@ -2037,6 +2092,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + video_thumbnail: + dependency: transitive + description: + name: video_thumbnail + sha256: "3455c189d3f0bb4e3fc2236475aa84fe598b9b2d0e08f43b9761f5bc44210016" + url: "https://pub.dev" + source: hosted + version: "0.5.3" visibility_detector: dependency: "direct main" description: