From 3d5693939b60b800654321742bbf01f08c6f6914 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Sun, 25 Jan 2026 14:12:50 +0100 Subject: [PATCH] init --- .../profile/profile_picture_crop.page.dart | 214 ++++++++++++++++++ ..._profile_picture_action_button.widget.dart | 40 ++++ mobile/lib/utils/action_button.utils.dart | 11 + 3 files changed, 265 insertions(+) create mode 100644 mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart create mode 100644 mobile/lib/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart 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..9658859aef --- /dev/null +++ b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart @@ -0,0 +1,214 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +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:flutter_hooks/flutter_hooks.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/hooks/crop_controller_hook.dart'; +import 'package:immich_ui/immich_ui.dart'; + +@RoutePage() +class ProfilePictureCropPage extends HookConsumerWidget { + final BaseAsset asset; + + const ProfilePictureCropPage({super.key, required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cropController = useCropController(); + final isLoading = useState(false); + + // Lock aspect ratio to 1:1 for circular/square crop + useEffect(() { + cropController.aspectRatio = 1.0; + cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9); + return null; + }, []); + + // Create Image widget from asset + final image = Image(image: getFullImageProvider(asset)); + + Future handleDone() async { + if (isLoading.value) return; + + isLoading.value = true; + + try { + // Get cropped image widget + final croppedImage = await cropController.croppedImage(); + + // Convert Image widget to ui.Image + final completer = Completer(); + croppedImage.image.resolve(ImageConfiguration.empty).addListener( + ImageStreamListener((ImageInfo info, bool _) { + completer.complete(info.image); + }), + ); + final uiImage = await completer.future; + + // Convert ui.Image to Uint8List + final byteData = await uiImage.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + throw Exception('Failed to convert image to bytes'); + } + final pngBytes = byteData.buffer.asUint8List(); + + // Create XFile and upload + final xFile = XFile.fromData(pngBytes, mimeType: 'image/png', name: 'profile-picture.png'); + final success = await ref.read(uploadProfileImageProvider.notifier).upload(xFile); + + if (!context.mounted) return; + + if (success) { + // Update user state + 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()); + + // Show success message and navigate back + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + "profile_picture_set".tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), + ); + + if (context.mounted) { + unawaited(context.maybePop()); + } + } else { + // Show error message + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + "errors.unable_to_set_profile_picture".tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), + ); + } + } catch (e) { + if (!context.mounted) return; + + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + "errors.unable_to_set_profile_picture".tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), + ); + } finally { + isLoading.value = false; + } + } + + return Scaffold( + appBar: AppBar( + backgroundColor: context.scaffoldBackgroundColor, + title: Text("set_profile_picture".tr()), + leading: isLoading.value + ? null + : const ImmichCloseButton(), + actions: [ + if (isLoading.value) + 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 Column( + children: [ + Container( + padding: const EdgeInsets.only(top: 20), + width: constraints.maxWidth * 0.9, + height: constraints.maxHeight * 0.6, + child: CropImage(controller: cropController, image: image, gridColor: Colors.white), + ), + Expanded( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: context.scaffoldBackgroundColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(left: 20, right: 20, bottom: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ImmichIconButton( + icon: Icons.rotate_left, + variant: ImmichVariant.ghost, + color: ImmichColor.secondary, + onPressed: isLoading.value + ? () {} + : () => cropController.rotateLeft(), + ), + ImmichIconButton( + icon: Icons.rotate_right, + variant: ImmichVariant.ghost, + color: ImmichColor.secondary, + onPressed: isLoading.value + ? () {} + : () => cropController.rotateRight(), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ); + }, + ), + ), + ); + } +} 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..dcd9e8a265 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart @@ -0,0 +1,40 @@ +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'; + + +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(ProfilePictureCropPage(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/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 1a2883bee7..f0872f398c 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -23,6 +23,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lo 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'; @@ -66,6 +67,7 @@ enum ActionButtonType { shareLink, cast, similarPhotos, + setProfilePicture, viewInTimeline, download, upload, @@ -146,6 +148,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 && @@ -220,6 +226,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,