diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index bb4f3efd267c6..ac5bd73604bc2 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -589,6 +589,7 @@ "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack", + "filter": "Filter", "downloading_media": "Downloading media", "download_finished": "Download finished", "download_filename": "file: {}", diff --git a/mobile/lib/constants/filters.dart b/mobile/lib/constants/filters.dart new file mode 100644 index 0000000000000..d9fa2920b7459 --- /dev/null +++ b/mobile/lib/constants/filters.dart @@ -0,0 +1,799 @@ +import 'package:flutter/material.dart'; + +List filters = [ + //Original + const ColorFilter.matrix([ + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Vintage + const ColorFilter.matrix([ + 0.8, + 0.1, + 0.1, + 0, + 20, + 0.1, + 0.8, + 0.1, + 0, + 20, + 0.1, + 0.1, + 0.8, + 0, + 20, + 0, + 0, + 0, + 1, + 0, + ]), + //Mood + const ColorFilter.matrix([ + 1.2, + 0.1, + 0.1, + 0, + 10, + 0.1, + 1, + 0.1, + 0, + 10, + 0.1, + 0.1, + 1, + 0, + 10, + 0, + 0, + 0, + 1, + 0, + ]), + //Crisp + const ColorFilter.matrix([ + 1.2, + 0, + 0, + 0, + 0, + 0, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Cool + const ColorFilter.matrix([ + 0.9, + 0, + 0.2, + 0, + 0, + 0, + 1, + 0.1, + 0, + 0, + 0.1, + 0, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Blush + const ColorFilter.matrix([ + 1.1, + 0.1, + 0.1, + 0, + 10, + 0.1, + 1, + 0.1, + 0, + 10, + 0.1, + 0.1, + 1, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + ]), + //Sunkissed + const ColorFilter.matrix([ + 1.3, + 0, + 0.1, + 0, + 15, + 0, + 1.1, + 0.1, + 0, + 10, + 0, + 0, + 0.9, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + ]), + //Fresh + const ColorFilter.matrix([ + 1.2, + 0, + 0, + 0, + 20, + 0, + 1.2, + 0, + 0, + 20, + 0, + 0, + 1.1, + 0, + 20, + 0, + 0, + 0, + 1, + 0, + ]), + //Classic + const ColorFilter.matrix([ + 1.1, + 0, + -0.1, + 0, + 10, + -0.1, + 1.1, + 0.1, + 0, + 5, + 0, + -0.1, + 1.1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Lomo-ish + const ColorFilter.matrix([ + 1.5, + 0, + 0.1, + 0, + 0, + 0, + 1.45, + 0, + 0, + 0, + 0.1, + 0, + 1.3, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Nashville + const ColorFilter.matrix([ + 1.2, + 0.15, + -0.15, + 0, + 15, + 0.1, + 1.1, + 0.1, + 0, + 10, + -0.05, + 0.2, + 1.25, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + ]), + //Valencia + const ColorFilter.matrix([ + 1.15, + 0.1, + 0.1, + 0, + 20, + 0.1, + 1.1, + 0, + 0, + 10, + 0.1, + 0.1, + 1.2, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + ]), + //Clarendon + const ColorFilter.matrix([ + 1.2, + 0, + 0, + 0, + 10, + 0, + 1.25, + 0, + 0, + 10, + 0, + 0, + 1.3, + 0, + 10, + 0, + 0, + 0, + 1, + 0, + ]), + //Moon + const ColorFilter.matrix([ + 0.33, + 0.33, + 0.33, + 0, + 0, + 0.33, + 0.33, + 0.33, + 0, + 0, + 0.33, + 0.33, + 0.33, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Willow + const ColorFilter.matrix([ + 0.5, + 0.5, + 0.5, + 0, + 20, + 0.5, + 0.5, + 0.5, + 0, + 20, + 0.5, + 0.5, + 0.5, + 0, + 20, + 0, + 0, + 0, + 1, + 0, + ]), + //Kodak + const ColorFilter.matrix([ + 1.3, + 0.1, + -0.1, + 0, + 10, + 0, + 1.25, + 0.1, + 0, + 10, + 0, + -0.1, + 1.1, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + ]), + //Frost + const ColorFilter.matrix([ + 0.8, + 0.2, + 0.1, + 0, + 0, + 0.2, + 1.1, + 0.1, + 0, + 0, + 0.1, + 0.1, + 1.2, + 0, + 10, + 0, + 0, + 0, + 1, + 0, + ]), + //Night Vision + const ColorFilter.matrix([ + 0.1, + 0.95, + 0.2, + 0, + 0, + 0.1, + 1.5, + 0.1, + 0, + 0, + 0.2, + 0.7, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Sunset + const ColorFilter.matrix([ + 1.5, + 0.2, + 0, + 0, + 0, + 0.1, + 0.9, + 0.1, + 0, + 0, + -0.1, + -0.2, + 1.3, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Noir + const ColorFilter.matrix([ + 1.3, + -0.3, + 0.1, + 0, + 0, + -0.1, + 1.2, + -0.1, + 0, + 0, + 0.1, + -0.2, + 1.3, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Dreamy + const ColorFilter.matrix([ + 1.1, + 0.1, + 0.1, + 0, + 0, + 0.1, + 1.1, + 0.1, + 0, + 0, + 0.1, + 0.1, + 1.1, + 0, + 15, + 0, + 0, + 0, + 1, + 0, + ]), + //Sepia + const ColorFilter.matrix([ + 0.393, + 0.769, + 0.189, + 0, + 0, + 0.349, + 0.686, + 0.168, + 0, + 0, + 0.272, + 0.534, + 0.131, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Radium + const ColorFilter.matrix([ + 1.438, + -0.062, + -0.062, + 0, + 0, + -0.122, + 1.378, + -0.122, + 0, + 0, + -0.016, + -0.016, + 1.483, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Aqua + const ColorFilter.matrix([ + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.7873, + 0.2848, + 0.9278, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Purple Haze + const ColorFilter.matrix([ + 1.3, + 0, + 1.2, + 0, + 0, + 0, + 1.1, + 0, + 0, + 0, + 0.2, + 0, + 1.3, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Lemonade + const ColorFilter.matrix([ + 1.2, + 0.1, + 0, + 0, + 0, + 0, + 1.1, + 0.2, + 0, + 0, + 0.1, + 0, + 0.7, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Caramel + const ColorFilter.matrix([ + 1.6, + 0.2, + 0, + 0, + 0, + 0.1, + 1.3, + 0.1, + 0, + 0, + 0, + 0.1, + 0.9, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Peachy + const ColorFilter.matrix([ + 1.3, + 0.5, + 0, + 0, + 0, + 0.2, + 1.1, + 0.3, + 0, + 0, + 0.1, + 0.1, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Neon + const ColorFilter.matrix([ + 1, + 0, + 1, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Cold Morning + const ColorFilter.matrix([ + 0.9, + 0.1, + 0.2, + 0, + 0, + 0, + 1, + 0.1, + 0, + 0, + 0.1, + 0, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Lush + const ColorFilter.matrix([ + 0.9, + 0.2, + 0, + 0, + 0, + 0, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1.1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Urban Neon + const ColorFilter.matrix([ + 1.1, + 0, + 0.3, + 0, + 0, + 0, + 0.9, + 0.3, + 0, + 0, + 0.3, + 0.1, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Monochrome + const ColorFilter.matrix([ + 0.6, + 0.2, + 0.2, + 0, + 0, + 0.2, + 0.6, + 0.2, + 0, + 0, + 0.2, + 0.2, + 0.7, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), +]; + +const List filterNames = [ + 'Original', + 'Vintage', + 'Mood', + 'Crisp', + 'Cool', + 'Blush', + 'Sunkissed', + 'Fresh', + 'Classic', + 'Lomo-ish', + 'Nashville', + 'Valencia', + 'Clarendon', + 'Moon', + 'Willow', + 'Kodak', + 'Frost', + 'Night Vision', + 'Sunset', + 'Noir', + 'Dreamy', + 'Sepia', + 'Radium', + 'Aqua', + 'Purple Haze', + 'Lemonade', + 'Caramel', + 'Peachy', + 'Neon', + 'Cold Morning', + 'Lush', + 'Urban Neon', + 'Monochrome', +]; diff --git a/mobile/lib/pages/editing/edit.page.dart b/mobile/lib/pages/editing/edit.page.dart index 5c0c185dbce0a..32d3aa6ba9021 100644 --- a/mobile/lib/pages/editing/edit.page.dart +++ b/mobile/lib/pages/editing/edit.page.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'dart:typed_data'; import 'dart:async'; import 'dart:ui'; @@ -9,7 +8,6 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; -import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:auto_route/auto_route.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -91,9 +89,6 @@ class EditImagePage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final Image imageWidget = - Image(image: ImmichImage.imageProvider(asset: asset)); - return Scaffold( appBar: AppBar( title: Text("edit_image_title".tr()), @@ -157,24 +152,48 @@ class EditImagePage extends ConsumerWidget { color: context.scaffoldBackgroundColor, borderRadius: BorderRadius.circular(30), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - IconButton( - icon: Icon( - Platform.isAndroid - ? Icons.crop_rotate_rounded - : Icons.crop_rotate_rounded, - color: Theme.of(context).iconTheme.color, - size: 25, - ), - onPressed: () { - context.pushRoute( - CropImageRoute(asset: asset, image: imageWidget), - ); - }, + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: Icon( + Icons.crop_rotate_rounded, + color: Theme.of(context).iconTheme.color, + size: 25, + ), + onPressed: () { + context.pushRoute( + CropImageRoute(asset: asset, image: image), + ); + }, + ), + Text("crop".tr(), style: context.textTheme.displayMedium), + ], + ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: Icon( + Icons.filter, + color: Theme.of(context).iconTheme.color, + size: 25, + ), + onPressed: () { + context.pushRoute( + FilterImageRoute( + asset: asset, + image: image, + ), + ); + }, + ), + Text("filter".tr(), style: context.textTheme.displayMedium), + ], ), - Text("crop".tr(), style: context.textTheme.displayMedium), ], ), ), diff --git a/mobile/lib/pages/editing/filter.page.dart b/mobile/lib/pages/editing/filter.page.dart new file mode 100644 index 0000000000000..da8ba74891595 --- /dev/null +++ b/mobile/lib/pages/editing/filter.page.dart @@ -0,0 +1,187 @@ +import 'dart:async'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/constants/filters.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:auto_route/auto_route.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 FilterImagePage extends HookWidget { + final Image image; + final Asset asset; + + const FilterImagePage({ + super.key, + required this.image, + required this.asset, + }); + + @override + Widget build(BuildContext context) { + final colorFilter = useState(filters[0]); + final selectedFilterIndex = useState(0); + + Future createFilteredImage( + ui.Image inputImage, + ColorFilter filter, + ) { + final completer = Completer(); + 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 applyFilterAndConvert(ColorFilter filter) async { + final completer = Completer(); + 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); + context.pushRoute( + EditImageRoute( + asset: asset, + image: filteredImage, + isEdited: true, + ), + ); + }, + ), + ], + ), + backgroundColor: context.scaffoldBackgroundColor, + body: Column( + children: [ + SizedBox( + height: MediaQuery.of(context).size.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: BorderRadius.circular(10), + border: isSelected + ? Border.all(color: context.primaryColor, width: 3) + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: ColorFiltered( + colorFilter: filter, + child: FittedBox( + fit: BoxFit.cover, + child: image, + ), + ), + ), + ), + ), + const SizedBox(height: 10), + Text(label, style: Theme.of(context).textTheme.bodyMedium), + ], + ); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 6869e7b7047e9..1f26e0d6de1ed 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -29,6 +29,7 @@ import 'package:immich_mobile/pages/common/splash_screen.page.dart'; import 'package:immich_mobile/pages/common/tab_controller.page.dart'; import 'package:immich_mobile/pages/editing/edit.page.dart'; import 'package:immich_mobile/pages/editing/crop.page.dart'; +import 'package:immich_mobile/pages/editing/filter.page.dart'; import 'package:immich_mobile/pages/library/archive.page.dart'; import 'package:immich_mobile/pages/library/favorite.page.dart'; import 'package:immich_mobile/pages/library/library.page.dart'; @@ -135,6 +136,7 @@ class AppRouter extends RootStackRouter { ), AutoRoute(page: EditImageRoute.page), AutoRoute(page: CropImageRoute.page), + AutoRoute(page: FilterImageRoute.page), AutoRoute(page: FavoritesRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AllVideosRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute( diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index df4c29fba1c70..4b27ab155fc31 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -755,6 +755,58 @@ class FavoritesRoute extends PageRouteInfo { ); } +/// generated route for +/// [FilterImagePage] +class FilterImageRoute extends PageRouteInfo { + FilterImageRoute({ + Key? key, + required Image image, + required Asset asset, + List? children, + }) : super( + FilterImageRoute.name, + args: FilterImageRouteArgs( + key: key, + image: image, + asset: asset, + ), + initialChildren: children, + ); + + static const String name = 'FilterImageRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return FilterImagePage( + key: args.key, + image: args.image, + asset: args.asset, + ); + }, + ); +} + +class FilterImageRouteArgs { + const FilterImageRouteArgs({ + this.key, + required this.image, + required this.asset, + }); + + final Key? key; + + final Image image; + + final Asset asset; + + @override + String toString() { + return 'FilterImageRouteArgs{key: $key, image: $image, asset: $asset}'; + } +} + /// generated route for /// [GalleryViewerPage] class GalleryViewerRoute extends PageRouteInfo {