diff --git a/mobile/lib/pages/editing/edit.page.dart b/mobile/lib/pages/editing/edit.page.dart index c9ab014456..2889785d0b 100644 --- a/mobile/lib/pages/editing/edit.page.dart +++ b/mobile/lib/pages/editing/edit.page.dart @@ -1,6 +1,4 @@ -import 'dart:async'; import 'dart:typed_data'; -import 'dart:ui'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -12,6 +10,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/image_converter.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:path/path.dart' as p; @@ -30,27 +29,10 @@ class EditImagePage extends ConsumerWidget { final bool isEdited; const EditImagePage({super.key, required this.asset, required this.image, required this.isEdited}); - Future _imageToUint8List(Image image) async { - final Completer completer = Completer(); - image.image - .resolve(const ImageConfiguration()) - .addListener( - ImageStreamListener((ImageInfo info, bool _) { - info.image.toByteData(format: ImageByteFormat.png).then((byteData) { - if (byteData != null) { - completer.complete(byteData.buffer.asUint8List()); - } else { - completer.completeError('Failed to convert image to bytes'); - } - }); - }, onError: (exception, stackTrace) => completer.completeError(exception)), - ); - return completer.future; - } Future _saveEditedImage(BuildContext context, Asset asset, Image image, WidgetRef ref) async { try { - final Uint8List imageData = await _imageToUint8List(image); + final Uint8List imageData = await imageToUint8List(image); await ref .read(fileMediaRepositoryProvider) .saveImage(imageData, title: "${p.withoutExtension(asset.fileName)}_edited.jpg"); diff --git a/mobile/lib/presentation/pages/editing/drift_edit.page.dart b/mobile/lib/presentation/pages/editing/drift_edit.page.dart index 7e49348e19..a10202973d 100644 --- a/mobile/lib/presentation/pages/editing/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_edit.page.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:ui'; import 'package:auto_route/auto_route.dart'; import 'package:cancellation_token_http/http.dart'; @@ -14,6 +13,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/utils/image_converter.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; @@ -33,23 +33,6 @@ class DriftEditImagePage extends ConsumerWidget { final bool isEdited; const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited}); - Future _imageToUint8List(Image image) async { - final Completer completer = Completer(); - image.image - .resolve(const ImageConfiguration()) - .addListener( - ImageStreamListener((ImageInfo info, bool _) { - info.image.toByteData(format: ImageByteFormat.png).then((byteData) { - if (byteData != null) { - completer.complete(byteData.buffer.asUint8List()); - } else { - completer.completeError('Failed to convert image to bytes'); - } - }); - }, onError: (exception, stackTrace) => completer.completeError(exception)), - ); - return completer.future; - } void _exitEditing(BuildContext context) { // this assumes that the only way to get to this page is from the AssetViewerRoute @@ -58,7 +41,7 @@ class DriftEditImagePage extends ConsumerWidget { Future _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async { try { - final Uint8List imageData = await _imageToUint8List(image); + final Uint8List imageData = await imageToUint8List(image); LocalAsset? localAsset; try { diff --git a/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart new file mode 100644 index 0000000000..f460633cbb --- /dev/null +++ b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart @@ -0,0 +1,177 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:crop_image/crop_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/utils/image_converter.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_ui/immich_ui.dart'; + +@RoutePage() +class ProfilePictureCropPage extends ConsumerStatefulWidget { + final BaseAsset asset; + + const ProfilePictureCropPage({super.key, required this.asset}); + + @override + ConsumerState createState() => _ProfilePictureCropPageState(); +} + +class _ProfilePictureCropPageState extends ConsumerState { + late final CropController _cropController; + bool _isLoading = false; + bool _didInitCropController = false; + + @override + void initState() { + super.initState(); + _cropController = CropController(defaultCrop: const Rect.fromLTRB(0, 0, 1, 1)); + + // Lock aspect ratio to 1:1 for circular/square crop + // CropController depends on CropImage initializing its bitmap size. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _didInitCropController) { + return; + } + _didInitCropController = true; + + _cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9); + _cropController.aspectRatio = 1.0; + }); + } + + @override + void dispose() { + _cropController.dispose(); + super.dispose(); + } + + Future _handleDone() async { + if (_isLoading) return; + + setState(() { + _isLoading = true; + }); + + try { + final croppedImage = await _cropController.croppedImage(); + final pngBytes = await imageToUint8List(croppedImage); + final xFile = XFile.fromData(pngBytes, mimeType: 'image/png'); + final success = await ref + .read(uploadProfileImageProvider.notifier) + .upload(xFile, fileName: 'profile-picture.png'); + + if (!context.mounted) return; + + if (success) { + final profileImagePath = ref.read(uploadProfileImageProvider).profileImagePath; + ref.read(authProvider.notifier).updateUserProfileImagePath(profileImagePath); + final user = ref.read(currentUserProvider); + if (user != null) { + unawaited(ref.read(currentUserProvider.notifier).refresh()); + } + unawaited(ref.read(backupProvider.notifier).updateDiskInfo()); + + ImmichToast.show( + context: context, + msg: 'profile_picture_set'.tr(), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.success, + ); + + if (context.mounted) { + unawaited(context.maybePop()); + } + } else { + ImmichToast.show( + context: context, + msg: 'errors.unable_to_set_profile_picture'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + } catch (e) { + if (!context.mounted) return; + + ImmichToast.show( + context: context, + msg: 'errors.unable_to_set_profile_picture'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + // Create Image widget from asset + final image = Image(image: getFullImageProvider(widget.asset)); + + return Scaffold( + appBar: AppBar( + backgroundColor: context.scaffoldBackgroundColor, + title: Text("set_profile_picture".tr()), + leading: _isLoading ? null : const ImmichCloseButton(), + actions: [ + if (_isLoading) + const Padding( + padding: EdgeInsets.all(16.0), + child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), + ) + else + ImmichIconButton( + icon: Icons.done_rounded, + color: ImmichColor.primary, + variant: ImmichVariant.ghost, + onPressed: _handleDone, + ), + ], + ), + backgroundColor: context.scaffoldBackgroundColor, + body: SafeArea( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9), + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(7)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + spreadRadius: 2, + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: ClipRRect( + child: CropImage(controller: _cropController, image: image, gridColor: Colors.white), + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart new file mode 100644 index 0000000000..c8dbb7cb1f --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart @@ -0,0 +1,35 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class SetProfilePictureActionButton extends ConsumerWidget { + final BaseAsset asset; + final bool iconOnly; + final bool menuItem; + + const SetProfilePictureActionButton({super.key, required this.asset, this.iconOnly = false, this.menuItem = false}); + + void _onTap(BuildContext context) { + if (!context.mounted) { + return; + } + + context.pushRoute(ProfilePictureCropRoute(asset: asset)); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.account_circle_outlined, + label: "set_as_profile_picture".t(context: context), + iconOnly: iconOnly, + menuItem: menuItem, + onPressed: () => _onTap(context), + maxWidth: 100, + ); + } +} diff --git a/mobile/lib/providers/upload_profile_image.provider.dart b/mobile/lib/providers/upload_profile_image.provider.dart index 5aa924ed1c..a2b7a23f05 100644 --- a/mobile/lib/providers/upload_profile_image.provider.dart +++ b/mobile/lib/providers/upload_profile_image.provider.dart @@ -61,10 +61,10 @@ class UploadProfileImageNotifier extends StateNotifier final UserService _userService; - Future upload(XFile file) async { + Future upload(XFile file, {String? fileName}) async { state = state.copyWith(status: UploadProfileStatus.loading); - var profileImagePath = await _userService.createProfileImage(file.name, await file.readAsBytes()); + var profileImagePath = await _userService.createProfileImage(fileName ?? file.name, await file.readAsBytes()); if (profileImagePath != null) { dPrint(() => "Successfully upload profile image"); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 81616f8880..b385bcbf71 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -106,6 +106,7 @@ import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/editing/drift_crop.page.dart'; +import 'package:immich_mobile/presentation/pages/profile/profile_picture_crop.page.dart'; import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart'; import 'package:immich_mobile/presentation/pages/editing/drift_filter.page.dart'; import 'package:immich_mobile/presentation/pages/local_timeline.page.dart'; @@ -198,6 +199,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: EditImageRoute.page), AutoRoute(page: CropImageRoute.page), AutoRoute(page: FilterImageRoute.page), + AutoRoute(page: ProfilePictureCropRoute.page), CustomRoute( page: FavoritesRoute.page, guards: [_authGuard, _duplicateGuard], diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 86c52d90dc..2d57c16573 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -2443,6 +2443,44 @@ class PlacesCollectionRouteArgs { } } +/// generated route for +/// [ProfilePictureCropPage] +class ProfilePictureCropRoute + extends PageRouteInfo { + ProfilePictureCropRoute({ + Key? key, + required BaseAsset asset, + List? children, + }) : super( + ProfilePictureCropRoute.name, + args: ProfilePictureCropRouteArgs(key: key, asset: asset), + initialChildren: children, + ); + + static const String name = 'ProfilePictureCropRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return ProfilePictureCropPage(key: args.key, asset: args.asset); + }, + ); +} + +class ProfilePictureCropRouteArgs { + const ProfilePictureCropRouteArgs({this.key, required this.asset}); + + final Key? key; + + final BaseAsset asset; + + @override + String toString() { + return 'ProfilePictureCropRouteArgs{key: $key, asset: $asset}'; + } +} + /// generated route for /// [RecentlyTakenPage] class RecentlyTakenRoute extends PageRouteInfo { diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 78df9b3d8a..2e26d8e80d 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -24,6 +24,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cove import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; @@ -70,6 +71,7 @@ enum ActionButtonType { cast, setAlbumCover, similarPhotos, + setProfilePicture, viewInTimeline, download, upload, @@ -155,6 +157,10 @@ enum ActionButtonType { ActionButtonType.similarPhotos => !context.isInLockedView && // context.asset is RemoteAsset, + ActionButtonType.setProfilePicture => + !context.isInLockedView && // + context.asset is RemoteAsset && // + context.isOwner, ActionButtonType.openInfo => true, ActionButtonType.viewInTimeline => context.timelineOrigin != TimelineOrigin.main && @@ -235,6 +241,11 @@ enum ActionButtonType { iconOnly: iconOnly, menuItem: menuItem, ), + ActionButtonType.setProfilePicture => SetProfilePictureActionButton( + asset: context.asset, + iconOnly: iconOnly, + menuItem: menuItem, + ), ActionButtonType.openInfo => BaseActionButton( label: 'info'.tr(), iconData: Icons.info_outline, diff --git a/mobile/lib/utils/image_converter.dart b/mobile/lib/utils/image_converter.dart new file mode 100644 index 0000000000..6711e2bd56 --- /dev/null +++ b/mobile/lib/utils/image_converter.dart @@ -0,0 +1,28 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +/// Converts a Flutter [Image] widget to a [Uint8List] in PNG format. +/// +/// This function resolves the image stream and converts it to byte data. +/// Returns a [Future] that completes with the image bytes or completes with an error +/// if the conversion fails. +Future imageToUint8List(Image image) async { + final Completer completer = Completer(); + image.image + .resolve(const ImageConfiguration()) + .addListener( + ImageStreamListener((ImageInfo info, bool _) { + info.image.toByteData(format: ImageByteFormat.png).then((byteData) { + if (byteData != null) { + completer.complete(byteData.buffer.asUint8List()); + } else { + completer.completeError('Failed to convert image to bytes'); + } + }); + }, onError: (exception, stackTrace) => completer.completeError(exception)), + ); + return completer.future; +} diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart index a713a4063c..01ae50b6c4 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -637,6 +637,76 @@ void main() { }); }); + group('setProfilePicture button', () { + test('should show when owner, not locked, and asset is RemoteAsset', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.setProfilePicture.shouldShow(context), isTrue); + }); + + test('should not show when not owner', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: false, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.setProfilePicture.shouldShow(context), isFalse); + }); + + test('should not show when in locked view', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.setProfilePicture.shouldShow(context), isFalse); + }); + + test('should not show when asset is not RemoteAsset', () { + final localAsset = createLocalAsset(); + final context = ActionButtonContext( + asset: localAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.setProfilePicture.shouldShow(context), isFalse); + }); + }); + group('setAlbumCover button', () { test('should show when owner, not locked, has album, and selectedCount is 1', () { final album = createRemoteAlbum();