Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mobile): adds crop and rotate to mobile #10989

Merged
merged 26 commits into from
Jul 28, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions mobile/lib/pages/editing/crop.page.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:crop_image/crop_image.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
import 'edit.page.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.
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<double?>(null);

return Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
leading: const CloseButton(color: Colors.white),
actions: [
IconButton(
icon: const Icon(Icons.done_rounded, color: Colors.white, size: 24),
onPressed: () async {
Image croppedImage = await cropController.croppedImage();
Navigator.push(
context,
MaterialPageRoute(builder: (context) => EditImagePage(image: croppedImage)),
);
},
),
],
),
body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Column(
children: [
Container(
padding: const EdgeInsets.only(top: 20),
width: constraints.maxWidth * 0.8,
height: constraints.maxHeight * 0.6,
child: CropImage(
controller: cropController,
image: image,
gridColor: Colors.white,
),
),
Expanded(
child: Container(
width: double.infinity,
decoration: const BoxDecoration(
color: Colors.black,
borderRadius: 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: const Icon(Icons.rotate_left,
color: Colors.white),
onPressed: () {
cropController.rotateLeft();
},
),
IconButton(
icon: const Icon(Icons.rotate_right,
color: Colors.white),
onPressed: () {
cropController.rotateRight();
},
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
_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<double?> 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 : Colors.white,
),
onPressed: () {
aspectRatio.value = ratio;
cropController.aspectRatio = ratio;
},
),
Text(label, style: Theme.of(context).textTheme.bodyMedium),
],
);
}
}
152 changes: 152 additions & 0 deletions mobile/lib/pages/editing/edit.page.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import 'dart:io';
import 'dart:typed_data';
import 'dart:async';
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:immich_mobile/pages/editing/crop.page.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:photo_manager/photo_manager.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 converstion 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 gets backed up to the server.
@immutable
class EditImagePage extends StatelessWidget {
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<Uint8List> _imageToUint8List(Image image) async {
final Completer<Uint8List> 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) {
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: Colors.black,
leading: IconButton(
icon: const Icon(Icons.close_rounded, color: Colors.white, size: 24),
onPressed: () =>
Navigator.of(context).popUntil((route) => route.isFirst),
),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.done_rounded, color: Colors.white, size: 24),
onPressed: () async {
if (image == null) {
ImmichToast.show(
durationInSecond: 1,
context: context,
msg: 'No edits made!',
gravity: ToastGravity.BOTTOM,
);
} else {
try {
final Uint8List imageData = await _imageToUint8List(image!);
ImmichToast.show(
durationInSecond: 1,
context: context,
msg: 'Image Saved!',
gravity: ToastGravity.BOTTOM,
);
///Ignore the warning here, planning to modify this in future PRs
final AssetEntity? entity = await PhotoManager.editor.saveImage(imageData, title: "_edited.jpg");
Navigator.of(context).popUntil((route) => route.isFirst);
} catch (e) {
ImmichToast.show(
durationInSecond: 6,
context: context,
msg: 'Error: ${e.toString()}',
gravity: ToastGravity.BOTTOM,
);
}
}
},
),
],
),
body: Column(
children: <Widget>[
Expanded(
child: Image(image: provider),
),
Container(
height: 80,
color: Colors.black,
),
],
),
bottomNavigationBar: Container(
height: 60,
margin: const EdgeInsets.only(bottom: 50, right: 10, left: 10, top: 10),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(30),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
IconButton(
icon: Icon(
Platform.isAndroid
? Icons.crop_rotate_rounded
: Icons.crop_rotate_rounded,
color: Colors.white,
),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CropImagePage(image: imageWidget),
),
),
),
],
),
),
);
}
}
9 changes: 9 additions & 0 deletions mobile/lib/utils/hooks/crop_controller_hook.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:crop_image/crop_image.dart';
import 'dart:ui'; // Import the dart:ui library for Rect

CropController useCropController() {
return useMemoized(() => CropController(
defaultCrop: const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9),
));
}
Loading