mirror of
https://github.com/immich-app/immich.git
synced 2026-03-09 19:33:43 -04:00
feat: mobile editing
This commit is contained in:
parent
0edbca24e4
commit
edae7b0b61
@ -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…",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,6 +152,8 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
|
||||
fileSize: fileSize,
|
||||
dateTimeOriginal: dateTimeOriginal,
|
||||
rating: rating,
|
||||
width: width,
|
||||
height: height,
|
||||
timeZone: timeZone,
|
||||
make: make,
|
||||
model: model,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
382
mobile/lib/presentation/pages/drift_edit.page.dart
Normal file
382
mobile/lib/presentation/pages/drift_edit.page.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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) ...[
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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]),
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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) {
|
||||
|
||||
70
mobile/lib/utils/editor.utils.dart
Normal file
70
mobile/lib/utils/editor.utils.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
50
mobile/lib/utils/matrix.utils.dart
Normal file
50
mobile/lib/utils/matrix.utils.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
321
mobile/test/utils/editor_test.dart
Normal file
321
mobile/test/utils/editor_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user