diff --git a/mobile/lib/pages/editing/crop.page.dart b/mobile/lib/pages/editing/crop.page.dart new file mode 100644 index 0000000000..315ec7ef19 --- /dev/null +++ b/mobile/lib/pages/editing/crop.page.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:crop_image/crop_image.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart'; +import 'edit.page.dart'; +import 'package:auto_route/auto_route.dart'; + +/// A widget for cropping an image. +/// This widget uses [HookWidget] to manage its lifecycle and state. It allows +/// users to crop an image and then navigate to the [EditImagePage] with the +/// cropped image. + +@RoutePage() +class CropImagePage extends HookWidget { + final Image image; + const CropImagePage({super.key, required this.image}); + + @override + Widget build(BuildContext context) { + final cropController = useCropController(); + final aspectRatio = useState(null); + + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).bottomAppBarTheme.color, + leading: CloseButton(color: Theme.of(context).iconTheme.color), + actions: [ + IconButton( + icon: Icon( + Icons.done_rounded, + color: Theme.of(context).iconTheme.color, + size: 24, + ), + onPressed: () async { + final croppedImage = await cropController.croppedImage(); + context.pushRoute(EditImageRoute(image: croppedImage)); + }, + ), + ], + ), + body: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Column( + children: [ + Container( + padding: const EdgeInsets.only(top: 20), + width: double.infinity, + height: constraints.maxHeight * 0.6, + child: CropImage( + controller: cropController, + image: image, + gridColor: Colors.white, + ), + ), + Expanded( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: Theme.of(context).bottomAppBarTheme.color, + 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: [ + IconButton( + icon: Icon( + Icons.rotate_left, + color: Theme.of(context).iconTheme.color, + ), + onPressed: () { + cropController.rotateLeft(); + }, + ), + IconButton( + icon: Icon( + Icons.rotate_right, + color: Theme.of(context).iconTheme.color, + ), + onPressed: () { + cropController.rotateRight(); + }, + ), + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: null, + label: 'Free', + ), + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 1.0, + label: '1:1', + ), + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 16.0 / 9.0, + label: '16:9', + ), + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 3.0 / 2.0, + label: '3:2', + ), + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 7.0 / 5.0, + label: '7:5', + ), + ], + ), + ], + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +class _AspectRatioButton extends StatelessWidget { + final CropController cropController; + final ValueNotifier aspectRatio; + final double? ratio; + final String label; + + const _AspectRatioButton({ + required this.cropController, + required this.aspectRatio, + required this.ratio, + required this.label, + }); + + @override + Widget build(BuildContext context) { + IconData iconData; + switch (label) { + case 'Free': + iconData = Icons.crop_free_rounded; + break; + case '1:1': + iconData = Icons.crop_square_rounded; + break; + case '16:9': + iconData = Icons.crop_16_9_rounded; + break; + case '3:2': + iconData = Icons.crop_3_2_rounded; + break; + case '7:5': + iconData = Icons.crop_7_5_rounded; + break; + default: + iconData = Icons.crop_free_rounded; + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + iconData, + color: aspectRatio.value == ratio + ? Colors.indigo + : Theme.of(context).iconTheme.color, + ), + onPressed: () { + aspectRatio.value = ratio; + cropController.aspectRatio = ratio; + }, + ), + Text(label, style: Theme.of(context).textTheme.bodyMedium), + ], + ); + } +} diff --git a/mobile/lib/pages/editing/edit.page.dart b/mobile/lib/pages/editing/edit.page.dart new file mode 100644 index 0000000000..f7b431564b --- /dev/null +++ b/mobile/lib/pages/editing/edit.page.dart @@ -0,0 +1,158 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/widgets/common/immich_image.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; + +/// A stateless widget that provides functionality for editing an image. +/// +/// This widget allows users to edit an image provided either as an [Asset] or +/// directly as an [Image]. It ensures that exactly one of these is provided. +/// +/// It also includes a conversion method to convert an [Image] to a [Uint8List] to save the image on the user's phone +/// They automatically navigate to the [HomePage] with the edited image saved and they eventually get backed up to the server. +@immutable +@RoutePage() +class EditImagePage extends ConsumerWidget { + final Asset? asset; + final Image? image; + + const EditImagePage({ + super.key, + this.image, + this.asset, + }) : assert( + (image != null && asset == null) || (image == null && asset != null), + 'Must supply one of asset or image', + ); + + 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; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ImageProvider provider = (asset != null) + ? ImmichImage.imageProvider(asset: asset!) + : (image != null) + ? image!.image + : throw Exception('Invalid image source type'); + + final Image imageWidget = (asset != null) + ? Image(image: ImmichImage.imageProvider(asset: asset!)) + : (image != null) + ? image! + : throw Exception('Invalid image source type'); + + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).appBarTheme.backgroundColor, + leading: IconButton( + icon: Icon( + Icons.close_rounded, + color: Theme.of(context).iconTheme.color, + size: 24, + ), + onPressed: () => + Navigator.of(context).popUntil((route) => route.isFirst), + ), + actions: [ + if (image != null) + TextButton( + onPressed: () async { + try { + final Uint8List imageData = await _imageToUint8List(image!); + ImmichToast.show( + durationInSecond: 3, + context: context, + msg: 'Image Saved!', + gravity: ToastGravity.CENTER, + ); + + await PhotoManager.editor + .saveImage(imageData, title: "_edited.jpg"); + await ref.read(albumProvider.notifier).getDeviceAlbums(); + Navigator.of(context).popUntil((route) => route.isFirst); + } catch (e) { + ImmichToast.show( + durationInSecond: 6, + context: context, + msg: 'Error: ${e.toString()}', + gravity: ToastGravity.BOTTOM, + ); + } + }, + child: Text( + 'Save to gallery', + style: Theme.of(context).textTheme.displayMedium, + ), + ), + ], + ), + body: Column( + children: [ + Expanded( + child: Image(image: provider), + ), + Container( + height: 80, + color: Theme.of(context).bottomAppBarTheme.color, + ), + ], + ), + bottomNavigationBar: Container( + height: 80, + margin: const EdgeInsets.only(bottom: 20, right: 10, left: 10, top: 10), + decoration: BoxDecoration( + color: Theme.of(context).bottomAppBarTheme.color, + borderRadius: BorderRadius.circular(30), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: Icon( + Platform.isAndroid + ? Icons.crop_rotate_rounded + : Icons.crop_rotate_rounded, + color: Theme.of(context).iconTheme.color, + ), + onPressed: () { + context.pushRoute(CropImageRoute(image: imageWidget)); + }, + ), + Text('Crop', style: Theme.of(context).textTheme.displayMedium), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 7ed45acf07..3b28c73b27 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -28,6 +28,8 @@ import 'package:immich_mobile/pages/common/headers_settings.page.dart'; import 'package:immich_mobile/pages/common/settings.page.dart'; import 'package:immich_mobile/pages/common/splash_screen.page.dart'; import 'package:immich_mobile/pages/common/tab_controller.page.dart'; +import 'package:immich_mobile/pages/editing/edit.page.dart'; +import 'package:immich_mobile/pages/editing/crop.page.dart'; import 'package:immich_mobile/pages/library/archive.page.dart'; import 'package:immich_mobile/pages/library/favorite.page.dart'; import 'package:immich_mobile/pages/library/library.page.dart'; @@ -133,6 +135,8 @@ class AppRouter extends _$AppRouter { page: CreateAlbumRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute(page: EditImageRoute.page), + AutoRoute(page: CropImageRoute.page), AutoRoute(page: FavoritesRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AllVideosRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute( diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 51de44dd46..77d031b5ed 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -165,6 +165,28 @@ abstract class _$AppRouter extends RootStackRouter { ), ); }, + CropImageRoute.name: (routeData) { + final args = routeData.argsAs(); + return AutoRoutePage( + routeData: routeData, + child: CropImagePage( + key: args.key, + image: args.image, + ), + ); + }, + EditImageRoute.name: (routeData) { + final args = routeData.argsAs( + orElse: () => const EditImageRouteArgs()); + return AutoRoutePage( + routeData: routeData, + child: EditImagePage( + key: args.key, + image: args.image, + asset: args.asset, + ), + ); + }, FailedBackupStatusRoute.name: (routeData) { return AutoRoutePage( routeData: routeData, @@ -836,6 +858,87 @@ class CreateAlbumRouteArgs { } } +/// generated route for +/// [CropImagePage] +class CropImageRoute extends PageRouteInfo { + CropImageRoute({ + Key? key, + required Image image, + List? children, + }) : super( + CropImageRoute.name, + args: CropImageRouteArgs( + key: key, + image: image, + ), + initialChildren: children, + ); + + static const String name = 'CropImageRoute'; + + static const PageInfo page = + PageInfo(name); +} + +class CropImageRouteArgs { + const CropImageRouteArgs({ + this.key, + required this.image, + }); + + final Key? key; + + final Image image; + + @override + String toString() { + return 'CropImageRouteArgs{key: $key, image: $image}'; + } +} + +/// generated route for +/// [EditImagePage] +class EditImageRoute extends PageRouteInfo { + EditImageRoute({ + Key? key, + Image? image, + Asset? asset, + List? children, + }) : super( + EditImageRoute.name, + args: EditImageRouteArgs( + key: key, + image: image, + asset: asset, + ), + initialChildren: children, + ); + + static const String name = 'EditImageRoute'; + + static const PageInfo page = + PageInfo(name); +} + +class EditImageRouteArgs { + const EditImageRouteArgs({ + this.key, + this.image, + this.asset, + }); + + final Key? key; + + final Image? image; + + final Asset? asset; + + @override + String toString() { + return 'EditImageRouteArgs{key: $key, image: $image, asset: $asset}'; + } +} + /// generated route for /// [FailedBackupStatusPage] class FailedBackupStatusRoute extends PageRouteInfo { diff --git a/mobile/lib/utils/hooks/crop_controller_hook.dart b/mobile/lib/utils/hooks/crop_controller_hook.dart new file mode 100644 index 0000000000..b03d9ccdb0 --- /dev/null +++ b/mobile/lib/utils/hooks/crop_controller_hook.dart @@ -0,0 +1,12 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:crop_image/crop_image.dart'; +import 'dart:ui'; // Import the dart:ui library for Rect + +/// A hook that provides a [CropController] instance. +CropController useCropController() { + return useMemoized( + () => CropController( + defaultCrop: const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9), + ), + ); +} diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index a4370cab84..478387ee4f 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -21,6 +21,7 @@ import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/pages/editing/edit.page.dart'; class BottomGalleryBar extends ConsumerWidget { final Asset asset; @@ -69,6 +70,12 @@ class BottomGalleryBar extends ConsumerWidget { label: 'control_bottom_app_bar_share'.tr(), tooltip: 'control_bottom_app_bar_share'.tr(), ), + if (asset.isImage) + BottomNavigationBarItem( + icon: const Icon(Icons.edit_outlined), + label: 'control_bottom_app_bar_edit'.tr(), + tooltip: 'control_bottom_app_bar_edit'.tr(), + ), if (isOwner) asset.isArchived ? BottomNavigationBarItem( @@ -280,6 +287,24 @@ class BottomGalleryBar extends ConsumerWidget { ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context); } + void handleEdit() async { + if (asset.isOffline) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_edit_err_offline'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + EditImagePage(asset: asset), // Send the Asset object + ), + ); + } + handleArchive() { ref.read(assetProvider.notifier).toggleArchive([asset]); if (isParent) { @@ -343,6 +368,7 @@ class BottomGalleryBar extends ConsumerWidget { List actionslist = [ (_) => shareAsset(), + if (asset.isImage) (_) => handleEdit(), if (isOwner) (_) => handleArchive(), if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(), if (isOwner) (_) => handleDelete(), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index e3e7d4e40c..c7e397999c 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -273,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + crop_image: + dependency: "direct main" + description: + name: crop_image + sha256: "6cf20655ecbfba99c369d43ec7adcfa49bf135af88fb75642173d6224a95d3f1" + url: "https://pub.dev" + source: hosted + version: "1.0.13" cross_file: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index fbe935b4df..1d11021ee9 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -62,6 +62,8 @@ dependencies: thumbhash: 0.1.0+1 async: ^2.11.0 + #image editing packages + crop_image: ^1.0.13 openapi: path: openapi