feat: mobile filtering

This commit is contained in:
bwees
2026-01-25 14:20:38 -06:00
parent f5db6dfa09
commit 871de53bca
2 changed files with 374 additions and 182 deletions

View File

@@ -8,6 +8,7 @@ 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/constants/filters.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';
@@ -48,14 +49,16 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
int _rotationAngle = 0;
bool _flipHorizontal = false;
bool _flipVertical = false;
double? aspectRatio;
ColorFilter? _colorFilter;
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;
String selectedSegment = 'transform';
void initEditor() {
final existingCrop = widget.edits.firstWhereOrNull((edit) => edit.action == AssetEditAction.crop);
@@ -198,6 +201,41 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
});
}
void _applyAspectRatio(double? ratio) {
setState(() {
cropController.aspectRatio = ratio;
_aspectRatio = ratio;
});
}
void _applyFilter(ColorFilter? filter) {
setState(() {
_colorFilter = filter;
});
}
void _resetEdits() {
setState(() {
cropController.aspectRatio = null;
cropController.crop = const Rect.fromLTRB(0, 0, 1, 1);
_rotationAnimationDuration = const Duration(milliseconds: 250);
_rotationAngle = 0;
_flipHorizontal = false;
_flipVertical = false;
_colorFilter = null;
_aspectRatio = null;
});
}
bool get hasEdits {
final isCropped = cropController.crop != const Rect.fromLTRB(0, 0, 1, 1);
final isRotated = (_rotationAngle % 360 + 360) % 360 != 0;
final isFlipped = _flipHorizontal || _flipVertical;
final isFiltered = _colorFilter != null;
return isCropped || isRotated || isFlipped || isFiltered;
}
@override
Widget build(BuildContext context) {
return Theme(
@@ -221,18 +259,16 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
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;
child: Column(
children: [
Expanded(
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.95;
return Column(
children: [
SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight * 0.7,
child: Center(
return Center(
child: AnimatedRotation(
turns: _rotationAngle / 360,
duration: _rotationAnimationDuration,
@@ -245,185 +281,103 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
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),
child: FutureBuilder(
future: resolveImage(widget.image.image),
builder: (context, data) {
if (!data.hasData) {
return const Center(child: CircularProgressIndicator());
}
return CropImage(
controller: cropController,
image: widget.image,
gridColor: Colors.white,
overlayPainter: MatrixAdjustmentPainter(image: data.data!, filter: _colorFilter),
);
},
),
),
),
),
),
);
},
),
),
AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
alignment: Alignment.bottomCenter,
clipBehavior: Clip.none,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: ref.watch(immichThemeProvider).dark.surface,
borderRadius: const BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20)),
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedCrossFade(
duration: const Duration(milliseconds: 250),
firstCurve: Curves.easeInOut,
secondCurve: Curves.easeInOut,
sizeCurve: Curves.easeInOut,
crossFadeState: selectedSegment == 'transform'
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: _TransformControls(
onRotateLeft: _rotateLeft,
onRotateRight: _rotateRight,
onFlipHorizontal: _flipHorizontally,
onFlipVertical: _flipVertically,
onAspectRatioSelected: _applyAspectRatio,
aspectRatio: _aspectRatio,
),
secondChild: _FilterControls(
filter: _colorFilter,
previewImage: widget.image,
onApplyFilter: _applyFilter,
),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
Padding(
padding: const EdgeInsets.only(bottom: 36, left: 24, right: 24),
child: Row(
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: <Widget>[
_AspectRatioButton(
cropController: cropController,
currentAspectRatio: aspectRatio,
ratio: null,
label: 'Free',
onPressed: () {
setState(() {
aspectRatio = null;
cropController.aspectRatio = null;
});
},
),
_AspectRatioButton(
cropController: cropController,
currentAspectRatio: aspectRatio,
ratio: 1.0,
label: '1:1',
onPressed: () {
setState(() {
aspectRatio = 1.0;
cropController.aspectRatio = 1.0;
});
},
),
_AspectRatioButton(
cropController: cropController,
currentAspectRatio: aspectRatio,
ratio: 16.0 / 9.0,
label: '16:9',
onPressed: () {
setState(() {
aspectRatio = 16.0 / 9.0;
cropController.aspectRatio = 16.0 / 9.0;
});
},
),
_AspectRatioButton(
cropController: cropController,
currentAspectRatio: aspectRatio,
ratio: 3.0 / 2.0,
label: '3:2',
onPressed: () {
setState(() {
aspectRatio = 3.0 / 2.0;
cropController.aspectRatio = 3.0 / 2.0;
});
},
),
_AspectRatioButton(
cropController: cropController,
currentAspectRatio: aspectRatio,
ratio: 7.0 / 5.0,
label: '7:5',
onPressed: () {
setState(() {
aspectRatio = 7.0 / 5.0;
cropController.aspectRatio = 7.0 / 5.0;
});
},
),
_AspectRatioButton(
cropController: cropController,
currentAspectRatio: aspectRatio,
ratio: 9.0 / 16.0,
label: '9:16',
onPressed: () {
setState(() {
aspectRatio = 9.0 / 16.0;
cropController.aspectRatio = 9.0 / 16.0;
});
},
),
_AspectRatioButton(
cropController: cropController,
currentAspectRatio: aspectRatio,
ratio: 2.0 / 3.0,
label: '2:3',
onPressed: () {
setState(() {
aspectRatio = 2.0 / 3.0;
cropController.aspectRatio = 2.0 / 3.0;
});
},
),
_AspectRatioButton(
cropController: cropController,
currentAspectRatio: aspectRatio,
ratio: 5.0 / 7.0,
label: '5:7',
onPressed: () {
setState(() {
aspectRatio = 5.0 / 7.0;
cropController.aspectRatio = 5.0 / 7.0;
});
},
),
],
),
SegmentedButton(
segments: [
const ButtonSegment<String>(
value: 'transform',
label: Text('Transform'),
icon: Icon(Icons.transform),
),
const ButtonSegment<String>(
value: 'filters',
label: Text('Filters'),
icon: Icon(Icons.color_lens),
),
],
selected: {selectedSegment},
onSelectionChanged: (value) => setState(() {
selectedSegment = value.first;
}),
showSelectedIcon: false,
),
const Spacer(),
ImmichTextButton(
labelText: "Reset",
onPressed: _resetEdits,
variant: ImmichVariant.filled,
expanded: false,
disabled: !hasEdits,
),
],
),
),
),
],
),
],
);
},
),
),
],
),
),
),
@@ -432,14 +386,12 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
}
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,
@@ -474,3 +426,194 @@ class _AspectRatioButton extends StatelessWidget {
);
}
}
class _AspectRatioSelector extends StatelessWidget {
final double? currentAspectRatio;
final void Function(double?) onAspectRatioSelected;
const _AspectRatioSelector({required this.currentAspectRatio, required this.onAspectRatioSelected});
@override
Widget build(BuildContext context) {
final aspectRatios = <String, double?>{
'Free': null,
'1:1': 1.0,
'16:9': 16 / 9,
'3:2': 3 / 2,
'7:5': 7 / 5,
'9:16': 9 / 16,
'2:3': 2 / 3,
'5:7': 5 / 7,
};
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: aspectRatios.entries.map((entry) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _AspectRatioButton(
currentAspectRatio: currentAspectRatio,
ratio: entry.value,
label: entry.key,
onPressed: () => onAspectRatioSelected(entry.value),
),
);
}).toList(),
),
);
}
}
class _TransformControls extends StatelessWidget {
final VoidCallback onRotateLeft;
final VoidCallback onRotateRight;
final VoidCallback onFlipHorizontal;
final VoidCallback onFlipVertical;
final void Function(double?) onAspectRatioSelected;
final double? aspectRatio;
const _TransformControls({
required this.onRotateLeft,
required this.onRotateRight,
required this.onFlipHorizontal,
required this.onFlipVertical,
required this.onAspectRatioSelected,
required this.aspectRatio,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
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: onRotateLeft,
),
const SizedBox(width: 8),
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: onRotateRight,
),
],
),
Row(
children: [
ImmichIconButton(
icon: Icons.flip,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: onFlipHorizontal,
),
const SizedBox(width: 8),
Transform.rotate(
angle: pi / 2,
child: ImmichIconButton(
icon: Icons.flip,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: onFlipVertical,
),
),
],
),
],
),
),
_AspectRatioSelector(currentAspectRatio: aspectRatio, onAspectRatioSelected: onAspectRatioSelected),
const SizedBox(height: 32),
],
);
}
}
class _FilterControls extends StatelessWidget {
final ColorFilter? filter;
final Image previewImage;
final void Function(ColorFilter?) onApplyFilter;
const _FilterControls({required this.filter, required this.previewImage, required this.onApplyFilter});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 24),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: filters.mapIndexed((i, filter) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _FilterButton(
image: previewImage,
label: filterNames[i],
filter: filter,
isSelected: filter == filters[i],
onTap: () => onApplyFilter(filter),
),
);
}).toList(),
),
),
),
);
}
}
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: Image(image: image.image, fit: BoxFit.cover),
),
),
),
),
const SizedBox(height: 10),
Text(label, style: context.themeData.textTheme.bodyMedium),
],
);
}
}

View File

@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
import 'package:flutter/material.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;
@@ -73,3 +75,50 @@ AffineMatrix buildAffineFromEdits(List<AssetEdit> edits) {
return (rotation, flipX, flipY);
}
class MatrixAdjustmentPainter extends CustomPainter {
final ui.Image image;
final ColorFilter? filter;
const MatrixAdjustmentPainter({required this.image, this.filter});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..colorFilter = filter;
final srcRect = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
final dstRect = Rect.fromLTWH(0, 0, size.width, size.height);
canvas.drawImageRect(image, srcRect, dstRect, paint);
}
@override
bool shouldRepaint(covariant MatrixAdjustmentPainter oldDelegate) {
return oldDelegate.image != image || oldDelegate.filter != filter;
}
}
/// Helper to resolve an ImageProvider to a ui.Image
Future<ui.Image> resolveImage(ImageProvider provider) {
final completer = Completer<ui.Image>();
final stream = provider.resolve(const ImageConfiguration());
late final ImageStreamListener listener;
listener = ImageStreamListener(
(ImageInfo info, bool sync) {
if (!completer.isCompleted) {
completer.complete(info.image);
}
stream.removeListener(listener);
},
onError: (error, stackTrace) {
if (!completer.isCompleted) {
completer.completeError(error, stackTrace);
}
stream.removeListener(listener);
},
);
stream.addListener(listener);
return completer.future;
}