feat: mobile editing

This commit is contained in:
bwees 2026-01-20 10:23:43 -06:00
parent 0edbca24e4
commit edae7b0b61
No known key found for this signature in database
24 changed files with 1075 additions and 630 deletions

View File

@ -561,6 +561,8 @@
"asset_adding_to_album": "Adding to album…",
"asset_created": "Asset created",
"asset_description_updated": "Asset description has been updated",
"asset_edit_failed": "Asset edit failed",
"asset_edit_success": "Asset edited successfully",
"asset_filename_is_offline": "Asset {filename} is offline",
"asset_has_unassigned_faces": "Asset has unassigned faces",
"asset_hashing": "Hashing…",

View File

@ -68,6 +68,8 @@ sealed class BaseAsset {
bool get isLocalOnly => storage == AssetState.local;
bool get isRemoteOnly => storage == AssetState.remote;
bool get isEditable => isImage && !isMotionPhoto && this is RemoteAsset;
// Overridden in subclasses
AssetState get storage;
String? get localId;

View File

@ -7,6 +7,8 @@ class ExifInfo {
final String? timeZone;
final DateTime? dateTimeOriginal;
final int? rating;
final int? width;
final int? height;
// GPS
final double? latitude;
@ -48,6 +50,8 @@ class ExifInfo {
this.timeZone,
this.dateTimeOriginal,
this.rating,
this.width,
this.height,
this.isFlipped = false,
this.latitude,
this.longitude,
@ -74,6 +78,8 @@ class ExifInfo {
other.timeZone == timeZone &&
other.dateTimeOriginal == dateTimeOriginal &&
other.rating == rating &&
other.width == width &&
other.height == height &&
other.latitude == latitude &&
other.longitude == longitude &&
other.city == city &&
@ -98,6 +104,8 @@ class ExifInfo {
timeZone.hashCode ^
dateTimeOriginal.hashCode ^
rating.hashCode ^
width.hashCode ^
height.hashCode ^
latitude.hashCode ^
longitude.hashCode ^
city.hashCode ^
@ -123,6 +131,8 @@ isFlipped: $isFlipped,
timeZone: ${timeZone ?? 'NA'},
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
rating: ${rating ?? 'NA'},
width: ${width ?? 'NA'},
height: ${height ?? 'NA'},
latitude: ${latitude ?? 'NA'},
longitude: ${longitude ?? 'NA'},
city: ${city ?? 'NA'},
@ -146,6 +156,8 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
String? timeZone,
DateTime? dateTimeOriginal,
int? rating,
int? width,
int? height,
double? latitude,
double? longitude,
String? city,
@ -168,6 +180,8 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
timeZone: timeZone ?? this.timeZone,
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
rating: rating ?? this.rating,
width: width ?? this.width,
height: height ?? this.height,
isFlipped: isFlipped ?? this.isFlipped,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,

View File

@ -1,5 +1,6 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
@ -116,4 +117,12 @@ class AssetService {
Future<List<LocalAlbum>> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) {
return _localAssetRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
}
Future<List<AssetEdit>> getAssetEdits(String assetId) {
return _remoteAssetRepository.getAssetEdits(assetId);
}
Future<void> editAsset(String assetId, List<AssetEdit> edits) {
return _remoteAssetRepository.editAsset(assetId, edits);
}
}

View File

@ -152,6 +152,8 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
fileSize: fileSize,
dateTimeOriginal: dateTimeOriginal,
rating: rating,
width: width,
height: height,
timeZone: timeZone,
make: make,
model: model,

View File

@ -1,7 +1,10 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' hide ExifInfo;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
@ -9,6 +12,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:uuid/uuid.dart';
class RemoteAssetRepository extends DriftDatabaseRepository {
final Drift _db;
@ -264,4 +268,35 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
Future<int> getCount() {
return _db.managers.remoteAssetEntity.count();
}
Future<List<AssetEdit>> getAssetEdits(String assetId) async {
final query = _db.assetEditEntity.select()
..where((row) => row.assetId.equals(assetId))
..orderBy([(row) => OrderingTerm.asc(row.sequence)]);
return query.map((row) => row.toDto()).get();
}
Future<void> editAsset(String assetId, List<AssetEdit> edits) async {
await _db.transaction(() async {
await _db.batch((batch) async {
// delete existing edits
batch.deleteWhere(_db.assetEditEntity, (row) => row.assetId.equals(assetId));
// insert new edits
for (var i = 0; i < edits.length; i++) {
final edit = edits[i];
final companion = AssetEditEntityCompanion(
id: Value(const Uuid().v4()),
assetId: Value(assetId),
action: Value(edit.action),
parameters: Value(edit.parameters),
sequence: Value(i),
);
batch.insert(_db.assetEditEntity, companion);
}
});
});
}
}

View File

@ -0,0 +1,382 @@
import 'dart:async';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:crop_image/crop_image.dart';
import 'package:easy_localization/easy_localization.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/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/editor.utils.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:openapi/api.dart' show CropParameters, RotateParameters, MirrorParameters, MirrorAxis;
@RoutePage()
class DriftEditImagePage extends ConsumerStatefulWidget {
final Image image;
final BaseAsset asset;
final List<AssetEdit> edits;
final ExifInfo exifInfo;
final Future<void> Function(List<AssetEdit> edits) applyEdits;
const DriftEditImagePage({
super.key,
required this.image,
required this.asset,
required this.edits,
required this.exifInfo,
required this.applyEdits,
});
@override
ConsumerState<DriftEditImagePage> createState() => _DriftEditImagePageState();
}
typedef AspectRatio = ({double? ratio, String label});
class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with TickerProviderStateMixin {
late final CropController cropController;
Duration _rotationAnimationDuration = const Duration(milliseconds: 250);
int _rotationAngle = 0;
bool _flipHorizontal = false;
bool _flipVertical = false;
double? aspectRatio;
late final originalWidth = widget.exifInfo.isFlipped ? widget.exifInfo.height : widget.exifInfo.width;
late final originalHeight = widget.exifInfo.isFlipped ? widget.exifInfo.width : widget.exifInfo.height;
bool isEditing = false;
List<AspectRatio> aspectRatios = const [
(ratio: null, label: 'Free'),
(ratio: 1.0, label: '1:1'),
(ratio: 16.0 / 9.0, label: '16:9'),
(ratio: 3.0 / 2.0, label: '3:2'),
(ratio: 7.0 / 5.0, label: '7:5'),
(ratio: 9.0 / 16.0, label: '9:16'),
(ratio: 2.0 / 3.0, label: '2:3'),
(ratio: 5.0 / 7.0, label: '5:7'),
];
void initEditor() {
final existingCrop = widget.edits.firstWhereOrNull((edit) => edit.action == AssetEditAction.crop);
Rect crop = existingCrop != null && originalWidth != null && originalHeight != null
? convertCropParametersToRect(
CropParameters.fromJson(existingCrop.parameters)!,
originalWidth!,
originalHeight!,
)
: const Rect.fromLTRB(0, 0, 1, 1);
cropController = CropController(defaultCrop: crop);
final transform = normalizeTransformEdits(widget.edits);
// dont animate to initial rotation
_rotationAnimationDuration = const Duration(milliseconds: 0);
_rotationAngle = transform.rotation.toInt();
_flipHorizontal = transform.mirrorHorizontal;
_flipVertical = transform.mirrorVertical;
}
Future<void> _saveEditedImage() async {
setState(() {
isEditing = true;
});
final cropParameters = convertRectToCropParameters(cropController.crop, originalWidth ?? 0, originalHeight ?? 0);
final normalizedRotation = (_rotationAngle % 360 + 360) % 360;
final edits = <AssetEdit>[];
if (cropParameters.width != originalWidth || cropParameters.height != originalHeight) {
edits.add(AssetEdit(action: AssetEditAction.crop, parameters: cropParameters.toJson()));
}
if (_flipHorizontal) {
edits.add(
AssetEdit(
action: AssetEditAction.mirror,
parameters: MirrorParameters(axis: MirrorAxis.horizontal).toJson(),
),
);
}
if (_flipVertical) {
edits.add(
AssetEdit(
action: AssetEditAction.mirror,
parameters: MirrorParameters(axis: MirrorAxis.vertical).toJson(),
),
);
}
if (normalizedRotation != 0) {
edits.add(
AssetEdit(
action: AssetEditAction.rotate,
parameters: RotateParameters(angle: normalizedRotation).toJson(),
),
);
}
await widget.applyEdits(edits);
setState(() {
isEditing = false;
});
}
@override
void initState() {
super.initState();
initEditor();
}
@override
void dispose() {
cropController.dispose();
super.dispose();
}
void _rotateLeft() {
setState(() {
_rotationAnimationDuration = const Duration(milliseconds: 150);
_rotationAngle -= 90;
});
}
void _rotateRight() {
setState(() {
_rotationAnimationDuration = const Duration(milliseconds: 150);
_rotationAngle += 90;
});
}
void _flipHorizontally() {
setState(() {
if (_rotationAngle % 180 != 0) {
// When rotated 90 or 270 degrees, flipping horizontally is equivalent to flipping vertically
_flipVertical = !_flipVertical;
} else {
_flipHorizontal = !_flipHorizontal;
}
});
}
void _flipVertically() {
setState(() {
if (_rotationAngle % 180 != 0) {
// When rotated 90 or 270 degrees, flipping vertically is equivalent to flipping horizontally
_flipHorizontal = !_flipHorizontal;
} else {
_flipVertical = !_flipVertical;
}
});
}
@override
Widget build(BuildContext context) {
return Theme(
data: getThemeData(colorScheme: ref.watch(immichThemeProvider).dark, locale: context.locale),
child: Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
title: Text("edit".tr()),
leading: const ImmichCloseButton(),
actions: [
isEditing
? const Padding(
padding: EdgeInsets.all(8.0),
child: SizedBox(width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 2.5)),
)
: ImmichIconButton(
icon: Icons.done_rounded,
color: ImmichColor.primary,
variant: ImmichVariant.ghost,
onPressed: _saveEditedImage,
),
],
),
backgroundColor: Colors.black,
body: SafeArea(
bottom: false,
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
// Calculate the bounding box size needed for the rotated container
final baseWidth = constraints.maxWidth * 0.9;
final baseHeight = constraints.maxHeight * 0.8;
return Column(
children: [
SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight * 0.7,
child: Center(
child: AnimatedRotation(
turns: _rotationAngle / 360,
duration: _rotationAnimationDuration,
curve: Curves.easeInOut,
child: Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..scaleByDouble(_flipHorizontal ? -1.0 : 1.0, _flipVertical ? -1.0 : 1.0, 1.0, 1.0),
child: Container(
padding: const EdgeInsets.all(10),
width: (_rotationAngle % 180 == 0) ? baseWidth : baseHeight,
height: (_rotationAngle % 180 == 0) ? baseHeight : baseWidth,
child: CropImage(controller: cropController, image: widget.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, top: 20, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
ImmichIconButton(
icon: Icons.rotate_left,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: _rotateLeft,
),
const SizedBox(width: 8),
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: _rotateRight,
),
],
),
Row(
children: [
ImmichIconButton(
icon: Icons.flip,
variant: ImmichVariant.ghost,
color: _flipHorizontal ? ImmichColor.primary : ImmichColor.secondary,
onPressed: _flipHorizontally,
),
const SizedBox(width: 8),
Transform.rotate(
angle: pi / 2,
child: ImmichIconButton(
icon: Icons.flip,
variant: ImmichVariant.ghost,
color: _flipVertical ? ImmichColor.primary : ImmichColor.secondary,
onPressed: _flipVertically,
),
),
],
),
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
spacing: 12,
children: aspectRatios.map((aspect) {
return _AspectRatioButton(
cropController: cropController,
currentAspectRatio: aspectRatio,
ratio: aspect.ratio,
label: aspect.label,
onPressed: () {
setState(() {
aspectRatio = aspect.ratio;
cropController.aspectRatio = aspect.ratio;
});
},
);
}).toList(),
),
),
const Spacer(),
],
),
),
),
),
],
);
},
),
),
),
);
}
}
class _AspectRatioButton extends StatelessWidget {
final CropController cropController;
final double? currentAspectRatio;
final double? ratio;
final String label;
final VoidCallback onPressed;
const _AspectRatioButton({
required this.cropController,
required this.currentAspectRatio,
required this.ratio,
required this.label,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.max,
children: [
IconButton(
iconSize: 36,
icon: Transform.rotate(
angle: (ratio ?? 1.0) < 1.0 ? pi / 2 : 0,
child: Icon(switch (label) {
'Free' => Icons.crop_free_rounded,
'1:1' => Icons.crop_square_rounded,
'16:9' => Icons.crop_16_9_rounded,
'3:2' => Icons.crop_3_2_rounded,
'7:5' => Icons.crop_7_5_rounded,
'9:16' => Icons.crop_16_9_rounded,
'2:3' => Icons.crop_3_2_rounded,
'5:7' => Icons.crop_7_5_rounded,
_ => Icons.crop_free_rounded,
}, color: currentAspectRatio == ratio ? context.primaryColor : context.themeData.iconTheme.color),
),
onPressed: onPressed,
),
Text(label, style: context.textTheme.displayMedium),
],
);
}
}

View File

@ -1,179 +0,0 @@
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:flutter_hooks/flutter_hooks.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/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
import 'package:immich_ui/immich_ui.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 DriftCropImagePage extends HookWidget {
final Image image;
final BaseAsset asset;
const DriftCropImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final cropController = useCropController();
final aspectRatio = useState<double?>(null);
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("crop".tr()),
leading: const ImmichCloseButton(),
actions: [
ImmichIconButton(
icon: Icons.done_rounded,
color: ImmichColor.primary,
variant: ImmichVariant.ghost,
onPressed: () async {
final croppedImage = await cropController.croppedImage();
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
},
),
],
),
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: () => cropController.rotateLeft(),
),
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
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) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(switch (label) {
'Free' => Icons.crop_free_rounded,
'1:1' => Icons.crop_square_rounded,
'16:9' => Icons.crop_16_9_rounded,
'3:2' => Icons.crop_3_2_rounded,
'7:5' => Icons.crop_7_5_rounded,
_ => Icons.crop_free_rounded,
}, color: aspectRatio.value == ratio ? context.primaryColor : context.themeData.iconTheme.color),
onPressed: () {
cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9);
aspectRatio.value = ratio;
cropController.aspectRatio = ratio;
},
),
Text(label, style: context.textTheme.displayMedium),
],
);
}
}

View File

@ -1,153 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
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;
/// 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 DriftEditImagePage extends ConsumerWidget {
final BaseAsset asset;
final Image image;
final bool isEdited;
const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited});
void _exitEditing(BuildContext context) {
// this assumes that the only way to get to this page is from the AssetViewerRoute
context.navigator.popUntil((route) => route.data?.name == AssetViewerRoute.name);
}
Future<void> _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async {
try {
final Uint8List imageData = await imageToUint8List(image);
LocalAsset? localAsset;
try {
localAsset = await ref
.read(fileMediaRepositoryProvider)
.saveLocalAsset(imageData, title: "${p.withoutExtension(asset.name)}_edited.jpg");
} on PlatformException catch (e) {
// OS might not return the saved image back, so we handle that gracefully
// This can happen if app does not have full library access
Logger("SaveEditedImage").warning("Failed to retrieve the saved image back from OS", e);
}
unawaited(ref.read(backgroundSyncProvider).syncLocal(full: true));
_exitEditing(context);
ImmichToast.show(durationInSecond: 3, context: context, msg: 'Image Saved!');
if (localAsset == null) {
return;
}
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset]);
} catch (e) {
ImmichToast.show(
durationInSecond: 6,
context: context,
msg: "error_saving_image".tr(namedArgs: {'error': e.toString()}),
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: Text("edit".tr()),
backgroundColor: context.scaffoldBackgroundColor,
leading: IconButton(
icon: Icon(Icons.close_rounded, color: context.primaryColor, size: 24),
onPressed: () => _exitEditing(context),
),
actions: <Widget>[
TextButton(
onPressed: isEdited ? () => _saveEditedImage(context, asset, image, ref) : null,
child: Text("save_to_gallery".tr(), style: TextStyle(color: isEdited ? context.primaryColor : Colors.grey)),
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: 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(
borderRadius: const BorderRadius.all(Radius.circular(7)),
child: Image(image: image.image, fit: BoxFit.contain),
),
),
),
),
bottomNavigationBar: Container(
height: 70,
margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10),
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(30)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.crop_rotate_rounded, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(DriftCropImageRoute(asset: asset, image: image));
},
),
Text("crop".tr(), style: context.textTheme.displayMedium),
],
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.filter, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(DriftFilterImageRoute(asset: asset, image: image));
},
),
Text("filter".tr(), style: context.textTheme.displayMedium),
],
),
],
),
),
);
}
}

View File

@ -1,159 +0,0 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/constants/filters.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/routing/router.dart';
/// A widget for filtering an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to add filters to an image and then navigate to the [EditImagePage] with the
/// final composition.'
@RoutePage()
class DriftFilterImagePage extends HookWidget {
final Image image;
final BaseAsset asset;
const DriftFilterImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final colorFilter = useState<ColorFilter>(filters[0]);
final selectedFilterIndex = useState<int>(0);
Future<ui.Image> createFilteredImage(ui.Image inputImage, ColorFilter filter) {
final completer = Completer<ui.Image>();
final size = Size(inputImage.width.toDouble(), inputImage.height.toDouble());
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final paint = Paint()..colorFilter = filter;
canvas.drawImage(inputImage, Offset.zero, paint);
recorder.endRecording().toImage(size.width.round(), size.height.round()).then((image) {
completer.complete(image);
});
return completer.future;
}
void applyFilter(ColorFilter filter, int index) {
colorFilter.value = filter;
selectedFilterIndex.value = index;
}
Future<Image> applyFilterAndConvert(ColorFilter filter) async {
final completer = Completer<ui.Image>();
image.image
.resolve(ImageConfiguration.empty)
.addListener(
ImageStreamListener((ImageInfo info, bool _) {
completer.complete(info.image);
}),
);
final uiImage = await completer.future;
final filteredUiImage = await createFilteredImage(uiImage, filter);
final byteData = await filteredUiImage.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
return Image.memory(pngBytes, fit: BoxFit.contain);
}
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("filter".tr()),
leading: CloseButton(color: context.primaryColor),
actions: [
IconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
onPressed: () async {
final filteredImage = await applyFilterAndConvert(colorFilter.value);
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: filteredImage, isEdited: true)));
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Column(
children: [
SizedBox(
height: context.height * 0.7,
child: Center(
child: ColorFiltered(colorFilter: colorFilter.value, child: image),
),
),
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: filters.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _FilterButton(
image: image,
label: filterNames[index],
filter: filters[index],
isSelected: selectedFilterIndex.value == index,
onTap: () => applyFilter(filters[index], index),
),
);
},
),
),
],
),
);
}
}
class _FilterButton extends StatelessWidget {
final Image image;
final String label;
final ColorFilter filter;
final bool isSelected;
final VoidCallback onTap;
const _FilterButton({
required this.image,
required this.label,
required this.filter,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
GestureDetector(
onTap: onTap,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(10)),
border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: ColorFiltered(
colorFilter: filter,
child: FittedBox(fit: BoxFit.cover, child: image),
),
),
),
),
const SizedBox(height: 10),
Text(label, style: context.themeData.textTheme.bodyMedium),
],
);
}
}

View File

@ -1,11 +1,21 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class EditImageActionButton extends ConsumerWidget {
const EditImageActionButton({super.key});
@ -14,13 +24,47 @@ class EditImageActionButton extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
onPress() {
if (currentAsset == null) {
Future<void> editImage(List<AssetEdit> edits) async {
if (currentAsset == null || currentAsset.remoteId == null) {
return;
}
final image = Image(image: getFullImageProvider(currentAsset));
context.pushRoute(DriftEditImageRoute(asset: currentAsset, image: image, isEdited: false));
try {
final completer = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) {
final eventData = data as Map<String, dynamic>;
return eventData["asset"]['id'] == currentAsset.remoteId;
}, const Duration(seconds: 10));
await ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits);
await completer;
ImmichToast.show(context: context, msg: 'asset_edit_success'.tr(), toastType: ToastType.success);
context.pop();
} catch (e) {
ImmichToast.show(context: context, msg: 'asset_edit_failed'.tr(), toastType: ToastType.error);
return;
}
}
Future<void> onPress() async {
if (currentAsset == null || currentAsset.remoteId == null) {
return;
}
final imageProvider = getFullImageProvider(currentAsset, edited: false);
final image = Image(image: imageProvider);
final edits = await ref.read(remoteAssetRepositoryProvider).getAssetEdits(currentAsset.remoteId!);
final exifInfo = await ref.read(remoteAssetRepositoryProvider).getExif(currentAsset.remoteId!);
if (exifInfo == null) {
return;
}
await context.pushRoute(
DriftEditImageRoute(asset: currentAsset, image: image, edits: edits, exifInfo: exifInfo, applyEdits: editImage),
);
}
return BaseActionButton(

View File

@ -3,12 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
@ -38,7 +38,7 @@ class ViewerBottomBar extends ConsumerWidget {
if (!isInLockedView) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.isEditable) const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (isOwner) ...[

View File

@ -135,7 +135,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
}
}
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) {
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) {
// Create new provider and cache it
final ImageProvider provider;
if (_shouldUseLocalAsset(asset)) {
@ -153,13 +153,13 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
} else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
}
provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type);
provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type, edited: edited);
}
return provider;
}
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution}) {
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
@ -167,7 +167,7 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash) : null;
return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited) : null;
}
bool _shouldUseLocalAsset(BaseAsset asset) =>

View File

@ -15,8 +15,8 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
RemoteImageProvider({required this.url});
RemoteImageProvider.thumbnail({required String assetId, required String thumbhash})
: url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash);
RemoteImageProvider.thumbnail({required String assetId, required String thumbhash, bool edited = true})
: url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash, edited: edited);
@override
Future<RemoteImageProvider> obtainKey(ImageConfiguration configuration) {
@ -58,8 +58,14 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final String assetId;
final String thumbhash;
final AssetType assetType;
final bool edited;
RemoteFullImageProvider({required this.assetId, required this.thumbhash, required this.assetType});
RemoteFullImageProvider({
required this.assetId,
required this.thumbhash,
required this.assetType,
this.edited = true,
});
@override
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
@ -70,7 +76,9 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
initialImage: getInitialImage(
RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash, edited: key.edited),
),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
@ -88,7 +96,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
}
final previewRequest = request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
uri: getThumbnailUrlForRemoteId(
key.assetId,
type: AssetMediaSize.preview,
thumbhash: key.thumbhash,
edited: key.edited,
),
);
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal);
yield* loadRequest(previewRequest, decode, evictOnError: !loadOriginal);
@ -102,7 +115,9 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return;
}
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
final originalRequest = request = RemoteImageRequest(
uri: getOriginalUrlForRemoteId(key.assetId, edited: key.edited),
);
yield* loadRequest(originalRequest, decode);
}
@ -110,12 +125,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteFullImageProvider) {
return assetId == other.assetId && thumbhash == other.thumbhash;
return assetId == other.assetId && thumbhash == other.thumbhash && edited == other.edited;
}
return false;
}
@override
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ edited.hashCode;
}

View File

@ -5,19 +5,20 @@ import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/timeline.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/timeline.service.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -490,6 +491,23 @@ class ActionNotifier extends Notifier<void> {
});
}
}
Future<ActionResult> applyEdits(ActionSource source, List<AssetEdit> edits) async {
final ids = _getOwnedRemoteIdsForSource(source);
if (ids.length != 1) {
_logger.warning('applyEdits called with multiple assets, expected single asset');
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for applying edits');
}
try {
await _service.applyEdits(ids.first, edits);
return const ActionResult(count: 1, success: true);
} catch (error, stack) {
_logger.severe('Failed to apply edits to assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
}
extension on Iterable<RemoteAsset> {

View File

@ -197,6 +197,27 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
state.socket?.on('on_upload_success', _handleOnUploadSuccess);
}
Future<void> waitForEvent(String event, bool Function(dynamic)? predicate, Duration timeout) {
final completer = Completer<void>();
void handler(dynamic data) {
if (predicate == null || predicate(data)) {
completer.complete();
state.socket?.off(event, handler);
}
}
state.socket?.on(event, handler);
return completer.future.timeout(
timeout,
onTimeout: () {
state.socket?.off(event, handler);
throw TimeoutException("Timeout waiting for event: $event");
},
);
}
void addPendingChange(PendingAction action, dynamic value) {
final now = DateTime.now();
state = state.copyWith(

View File

@ -1,12 +1,13 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart';
import 'package:openapi/api.dart' hide AssetEditAction;
final assetApiRepositoryProvider = Provider(
(ref) => AssetApiRepository(
@ -105,6 +106,25 @@ class AssetApiRepository extends ApiRepository {
Future<void> updateRating(String assetId, int rating) {
return _api.updateAsset(assetId, UpdateAssetDto(rating: rating));
}
Future<void> editAsset(String assetId, List<AssetEdit> edits) async {
final editDtos = edits
.map((edit) {
if (edit.action == AssetEditAction.other) {
return null;
}
return AssetEditActionListDtoEditsInner(action: edit.action.toDto()!, parameters: edit.parameters);
})
.whereType<AssetEditActionListDtoEditsInner>()
.toList();
await _api.editAsset(assetId, AssetEditActionListDto(edits: editDtos));
}
Future<void> removeEdits(String assetId) async {
await _api.removeAssetEdits(assetId);
}
}
extension on StackResponseDto {

View File

@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
@ -89,6 +91,7 @@ import 'package:immich_mobile/presentation/pages/drift_archive.page.dart';
import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/drift_asset_troubleshoot.page.dart';
import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_edit.page.dart';
import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart';
import 'package:immich_mobile/presentation/pages/drift_library.page.dart';
import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart';
@ -105,11 +108,8 @@ import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
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';
import 'package:immich_mobile/presentation/pages/profile/profile_picture_crop.page.dart';
import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/providers/api.provider.dart';
@ -333,8 +333,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftAlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftMapRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftEditImageRoute.page),
AutoRoute(page: DriftCropImageRoute.page),
AutoRoute(page: DriftFilterImageRoute.page),
AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),

View File

@ -1003,70 +1003,26 @@ class DriftCreateAlbumRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftCropImagePage]
class DriftCropImageRoute extends PageRouteInfo<DriftCropImageRouteArgs> {
DriftCropImageRoute({
Key? key,
required Image image,
required BaseAsset asset,
List<PageRouteInfo>? children,
}) : super(
DriftCropImageRoute.name,
args: DriftCropImageRouteArgs(key: key, image: image, asset: asset),
initialChildren: children,
);
static const String name = 'DriftCropImageRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftCropImageRouteArgs>();
return DriftCropImagePage(
key: args.key,
image: args.image,
asset: args.asset,
);
},
);
}
class DriftCropImageRouteArgs {
const DriftCropImageRouteArgs({
this.key,
required this.image,
required this.asset,
});
final Key? key;
final Image image;
final BaseAsset asset;
@override
String toString() {
return 'DriftCropImageRouteArgs{key: $key, image: $image, asset: $asset}';
}
}
/// generated route for
/// [DriftEditImagePage]
class DriftEditImageRoute extends PageRouteInfo<DriftEditImageRouteArgs> {
DriftEditImageRoute({
Key? key,
required BaseAsset asset,
required Image image,
required bool isEdited,
required BaseAsset asset,
required List<AssetEdit> edits,
required ExifInfo exifInfo,
required Future<void> Function(List<AssetEdit>) applyEdits,
List<PageRouteInfo>? children,
}) : super(
DriftEditImageRoute.name,
args: DriftEditImageRouteArgs(
key: key,
asset: asset,
image: image,
isEdited: isEdited,
asset: asset,
edits: edits,
exifInfo: exifInfo,
applyEdits: applyEdits,
),
initialChildren: children,
);
@ -1079,9 +1035,11 @@ class DriftEditImageRoute extends PageRouteInfo<DriftEditImageRouteArgs> {
final args = data.argsAs<DriftEditImageRouteArgs>();
return DriftEditImagePage(
key: args.key,
asset: args.asset,
image: args.image,
isEdited: args.isEdited,
asset: args.asset,
edits: args.edits,
exifInfo: args.exifInfo,
applyEdits: args.applyEdits,
);
},
);
@ -1090,22 +1048,28 @@ class DriftEditImageRoute extends PageRouteInfo<DriftEditImageRouteArgs> {
class DriftEditImageRouteArgs {
const DriftEditImageRouteArgs({
this.key,
required this.asset,
required this.image,
required this.isEdited,
required this.asset,
required this.edits,
required this.exifInfo,
required this.applyEdits,
});
final Key? key;
final BaseAsset asset;
final Image image;
final bool isEdited;
final BaseAsset asset;
final List<AssetEdit> edits;
final ExifInfo exifInfo;
final Future<void> Function(List<AssetEdit>) applyEdits;
@override
String toString() {
return 'DriftEditImageRouteArgs{key: $key, asset: $asset, image: $image, isEdited: $isEdited}';
return 'DriftEditImageRouteArgs{key: $key, image: $image, asset: $asset, edits: $edits, exifInfo: $exifInfo, applyEdits: $applyEdits}';
}
}
@ -1125,54 +1089,6 @@ class DriftFavoriteRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftFilterImagePage]
class DriftFilterImageRoute extends PageRouteInfo<DriftFilterImageRouteArgs> {
DriftFilterImageRoute({
Key? key,
required Image image,
required BaseAsset asset,
List<PageRouteInfo>? children,
}) : super(
DriftFilterImageRoute.name,
args: DriftFilterImageRouteArgs(key: key, image: image, asset: asset),
initialChildren: children,
);
static const String name = 'DriftFilterImageRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftFilterImageRouteArgs>();
return DriftFilterImagePage(
key: args.key,
image: args.image,
asset: args.asset,
);
},
);
}
class DriftFilterImageRouteArgs {
const DriftFilterImageRouteArgs({
this.key,
required this.image,
required this.asset,
});
final Key? key;
final Image image;
final BaseAsset asset;
@override
String toString() {
return 'DriftFilterImageRouteArgs{key: $key, image: $image, asset: $asset}';
}
}
/// generated route for
/// [DriftLibraryPage]
class DriftLibraryRoute extends PageRouteInfo<void> {

View File

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
@ -246,6 +247,16 @@ class ActionService {
return true;
}
Future<void> applyEdits(String remoteId, List<AssetEdit> edits) async {
if (edits.isEmpty) {
await _assetApiRepository.removeEdits(remoteId);
} else {
await _assetApiRepository.editAsset(remoteId, edits);
}
await _remoteAssetRepository.editAsset(remoteId, edits);
}
Future<int> _deleteLocalAssets(List<String> localIds) async {
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isEmpty) {

View File

@ -0,0 +1,70 @@
import 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/utils/matrix.utils.dart';
import 'package:openapi/api.dart' hide AssetEditAction;
Rect convertCropParametersToRect(CropParameters parameters, int originalWidth, int originalHeight) {
return Rect.fromLTWH(
parameters.x.toDouble() / originalWidth,
parameters.y.toDouble() / originalHeight,
parameters.width.toDouble() / originalWidth,
parameters.height.toDouble() / originalHeight,
);
}
CropParameters convertRectToCropParameters(Rect rect, int originalWidth, int originalHeight) {
final x = (rect.left * originalWidth).round();
final y = (rect.top * originalHeight).round();
final width = (rect.width * originalWidth).round();
final height = (rect.height * originalHeight).round();
return CropParameters(
x: max(x, 0).clamp(0, originalWidth),
y: max(y, 0).clamp(0, originalHeight),
width: max(width, 0).clamp(0, originalWidth - x),
height: max(height, 0).clamp(0, originalHeight - y),
);
}
AffineMatrix buildAffineFromEdits(List<AssetEdit> edits) {
return AffineMatrix.compose(
edits.map<AffineMatrix>((edit) {
switch (edit.action) {
case AssetEditAction.rotate:
final angleInDegrees = edit.parameters["angle"] as num;
final angleInRadians = angleInDegrees * pi / 180;
return AffineMatrix.rotate(angleInRadians);
case AssetEditAction.mirror:
final axis = edit.parameters["axis"] as String;
return axis == "horizontal" ? AffineMatrix.flipY() : AffineMatrix.flipX();
default:
return AffineMatrix.identity();
}
}).toList(),
);
}
bool isCloseToZero(double value, [double epsilon = 1e-15]) {
return value.abs() < epsilon;
}
typedef NormalizedTransform = ({double rotation, bool mirrorHorizontal, bool mirrorVertical});
NormalizedTransform normalizeTransformEdits(List<AssetEdit> edits) {
final matrix = buildAffineFromEdits(edits);
double a = matrix.a;
double b = matrix.b;
double c = matrix.c;
double d = matrix.d;
final rotation = ((isCloseToZero(a) ? asin(c) : acos(a)) * 180) / pi;
return (
rotation: rotation < 0 ? 360 + rotation : rotation,
mirrorHorizontal: false,
mirrorVertical: isCloseToZero(a) ? b == c : a == -d,
);
}

View File

@ -1,8 +1,14 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:crop_image/crop_image.dart';
import 'dart:ui'; // Import the dart:ui library for Rect
import 'package:crop_image/crop_image.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// A hook that provides a [CropController] instance.
CropController useCropController() {
return useMemoized(() => CropController(defaultCrop: const Rect.fromLTRB(0, 0, 1, 1)));
CropController useCropController({Rect? initialCrop, CropRotation? initialRotation}) {
return useMemoized(
() => CropController(
defaultCrop: initialCrop ?? const Rect.fromLTRB(0, 0, 1, 1),
rotation: initialRotation ?? CropRotation.up,
),
);
}

View File

@ -0,0 +1,50 @@
import 'dart:math';
class AffineMatrix {
final double a;
final double b;
final double c;
final double d;
final double e;
final double f;
const AffineMatrix(this.a, this.b, this.c, this.d, this.e, this.f);
@override
String toString() {
return 'AffineMatrix(a: $a, b: $b, c: $c, d: $d, e: $e, f: $f)';
}
factory AffineMatrix.identity() {
return const AffineMatrix(1, 0, 0, 1, 0, 0);
}
AffineMatrix multiply(AffineMatrix other) {
return AffineMatrix(
a * other.a + c * other.b,
b * other.a + d * other.b,
a * other.c + c * other.d,
b * other.c + d * other.d,
a * other.e + c * other.f + e,
b * other.e + d * other.f + f,
);
}
factory AffineMatrix.compose([List<AffineMatrix> transformations = const []]) {
return transformations.fold<AffineMatrix>(AffineMatrix.identity(), (acc, matrix) => acc.multiply(matrix));
}
factory AffineMatrix.rotate(double angle) {
final cosAngle = cos(angle);
final sinAngle = sin(angle);
return AffineMatrix(cosAngle, -sinAngle, sinAngle, cosAngle, 0, 0);
}
factory AffineMatrix.flipY() {
return const AffineMatrix(-1, 0, 0, 1, 0, 0);
}
factory AffineMatrix.flipX() {
return const AffineMatrix(1, 0, 0, -1, 0, 0);
}
}

View File

@ -0,0 +1,321 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/utils/editor.utils.dart';
List<AssetEdit> normalizedToEdits(NormalizedTransform transform) {
List<AssetEdit> edits = [];
if (transform.mirrorHorizontal) {
edits.add(const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}));
}
if (transform.mirrorVertical) {
edits.add(const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}));
}
if (transform.rotation != 0) {
edits.add(AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": transform.rotation}));
}
return edits;
}
bool compareEditAffines(List<AssetEdit> editsA, List<AssetEdit> editsB) {
final normA = buildAffineFromEdits(editsA);
final normB = buildAffineFromEdits(editsB);
return ((normA.a - normB.a).abs() < 0.0001 &&
(normA.b - normB.b).abs() < 0.0001 &&
(normA.c - normB.c).abs() < 0.0001 &&
(normA.d - normB.d).abs() < 0.0001);
}
void main() {
group('normalizeEdits', () {
test('should handle no edits', () {
final edits = <AssetEdit>[];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle a single 90° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle a single 180° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle a single 270° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle a single horizontal mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle a single vertical mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 90° rotation + horizontal mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 90° rotation + vertical mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 90° rotation + both mirrors', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 180° rotation + horizontal mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 180° rotation + vertical mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 180° rotation + both mirrors', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 270° rotation + horizontal mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 270° rotation + vertical mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 270° rotation + both mirrors', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle horizontal mirror + 90° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle horizontal mirror + 180° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle horizontal mirror + 270° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle vertical mirror + 90° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle vertical mirror + 180° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle vertical mirror + 270° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle both mirrors + 90° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle both mirrors + 180° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle both mirrors + 270° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
expect(compareEditAffines(normalizedEdits, edits), true);
});
});
}