diff --git a/analysis_options.yaml b/analysis_options.yaml index ee6678ea..26a82f14 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1,7 @@ include: package:flutter_lints/flutter.yaml analyzer: + errors: + missing_enum_constant_in_switch: error + exhaustive_cases: error exclude: - lib/**.pb*.dart \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 8b877ac6..a5d89552 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,38 @@ PODS: + - DKImagePickerController/Core (4.3.4): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.4) + - DKImagePickerController/PhotoGallery (4.3.4): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.4) + - DKPhotoGallery (0.0.17): + - DKPhotoGallery/Core (= 0.0.17) + - DKPhotoGallery/Model (= 0.0.17) + - DKPhotoGallery/Preview (= 0.0.17) + - DKPhotoGallery/Resource (= 0.0.17) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.17): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.17): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter - Flutter (1.0.0) - image_picker_ios (0.0.1): - Flutter @@ -6,14 +40,28 @@ PODS: - Flutter - path_provider_ios (0.0.1): - Flutter + - SDWebImage (5.13.1): + - SDWebImage/Core (= 5.13.1) + - SDWebImage/Core (5.13.1) + - SwiftyGif (5.4.3) DEPENDENCIES: + - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - SDWebImage + - SwiftyGif + EXTERNAL SOURCES: + file_picker: + :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter image_picker_ios: @@ -24,10 +72,15 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/path_provider_ios/ios" SPEC CHECKSUMS: + DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac + DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 + file_picker: 817ab1d8cd2da9d2da412a417162deee3500fc95 Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5 path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 + SDWebImage: fb26a455eeda4c7a55e4dcb6172dbb258af7a4ca + SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c diff --git a/lib/command/src/command_manager.dart b/lib/command/src/command_manager.dart index 1be050b3..bb50c037 100644 --- a/lib/command/src/command_manager.dart +++ b/lib/command/src/command_manager.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'command.dart'; import 'graphic_command.dart'; import 'implementation/manager/sync_command_manager.dart'; @@ -10,9 +11,13 @@ abstract class CommandManager { (ref) => SyncCommandManager(commands: []), ); + Iterable get commands; + void addGraphicCommand(GraphicCommand command); void executeLastCommand(Canvas canvas); void executeAllCommands(Canvas canvas); + + void resetHistory({Iterable? newCommands}); } diff --git a/lib/command/src/implementation/manager/sync_command_manager.dart b/lib/command/src/implementation/manager/sync_command_manager.dart index 5fefedbe..3e87aae2 100644 --- a/lib/command/src/implementation/manager/sync_command_manager.dart +++ b/lib/command/src/implementation/manager/sync_command_manager.dart @@ -5,11 +5,13 @@ import '../../command_manager.dart'; import '../../graphic_command.dart'; class SyncCommandManager implements CommandManager { - SyncCommandManager({required List commands}) - : _history = commands; + SyncCommandManager({required List commands}) : _history = commands; final List _history; + @override + Iterable get commands => List.unmodifiable(_history); + @override void addGraphicCommand(GraphicCommand command) { _history.add(command); @@ -32,4 +34,12 @@ class SyncCommandManager implements CommandManager { } } } + + @override + void resetHistory({Iterable? newCommands}) { + _history.clear(); + if (newCommands != null) { + _history.addAll(newCommands); + } + } } diff --git a/lib/core/loggable_mixin.dart b/lib/core/loggable_mixin.dart index 07466cc8..0d8855fe 100644 --- a/lib/core/loggable_mixin.dart +++ b/lib/core/loggable_mixin.dart @@ -1,5 +1,5 @@ import 'package:logging/logging.dart'; mixin LoggableMixin { - late final log = Logger(runtimeType.toString()); + late final logger = Logger(runtimeType.toString()); } \ No newline at end of file diff --git a/lib/io/io.dart b/lib/io/io.dart index 7a243f91..c11430cf 100644 --- a/lib/io/io.dart +++ b/lib/io/io.dart @@ -1,13 +1,14 @@ -export 'src/entity/image_format.dart'; +export 'src/entity/catrobat_image.dart'; +export 'src/entity/image_meta_data.dart'; export 'src/failure/load_image_failure.dart'; export 'src/failure/save_image_failure.dart'; -export 'src/serialization/proto/output/command/graphic/draw_path_command.pb.dart'; -export 'src/serialization/proto/output/graphic/paint.pb.dart'; -export 'src/serialization/proto/output/graphic/path.pb.dart'; -export 'src/serialization/proto_serializer.dart'; -export 'src/serialization/serializer.dart'; +export 'src/serialization/serializer/catrobat_image_serializer.dart'; +export 'src/service/file_service.dart'; export 'src/service/image_service.dart'; export 'src/service/photo_library_service.dart'; +export 'src/ui/discard_changes_dialog.dart'; +export 'src/ui/load_image_dialog.dart'; export 'src/ui/save_image_dialog.dart'; -export 'src/usecase/load_image.dart'; -export 'src/usecase/save_image.dart'; +export 'src/usecase/load_image_from_photo_library.dart'; +export 'src/usecase/save_as_catrobat_image.dart'; +export 'src/usecase/save_as_raster_image.dart'; diff --git a/lib/io/serialization.dart b/lib/io/serialization.dart new file mode 100644 index 00000000..7009b8be --- /dev/null +++ b/lib/io/serialization.dart @@ -0,0 +1,10 @@ +export 'src/serialization/proto/output/catrobat_image.pb.dart'; +export 'src/serialization/proto/output/command/graphic/draw_path_command.pb.dart'; +export 'src/serialization/proto/output/google/protobuf/any.pb.dart'; +export 'src/serialization/proto/output/graphic/paint.pb.dart'; +export 'src/serialization/proto/output/graphic/path.pb.dart'; +export 'src/serialization/proto_serializer_with_versioning.dart'; +export 'src/serialization/serializer/command/graphic/draw_path_command_serializer.dart'; +export 'src/serialization/serializer/graphic/paint_serializer.dart'; +export 'src/serialization/serializer/graphic/path_serializer.dart'; +export 'src/serialization/version_serializer.dart'; diff --git a/lib/io/src/entity/catrobat_image.dart b/lib/io/src/entity/catrobat_image.dart new file mode 100644 index 00000000..fab5a8d8 --- /dev/null +++ b/lib/io/src/entity/catrobat_image.dart @@ -0,0 +1,18 @@ +import 'dart:typed_data'; + +import 'package:paintroid/command/command.dart' show Command; + +class CatrobatImage { + static const magicValue = "CATROBAT"; + static const latestVersion = 1; + + final int version; + final Iterable commands; + final Uint8List? loadedImage; + + const CatrobatImage( + this.commands, + this.loadedImage, { + this.version = latestVersion, + }); +} diff --git a/lib/io/src/entity/image_format.dart b/lib/io/src/entity/image_format.dart index c95b7f25..2d4aefee 100644 --- a/lib/io/src/entity/image_format.dart +++ b/lib/io/src/entity/image_format.dart @@ -1,6 +1,9 @@ +part of 'image_meta_data.dart'; + enum ImageFormat { png("png"), - jpg("jpg"); + jpg("jpg"), + catrobatImage("catrobat-image"); const ImageFormat(this.extension); diff --git a/lib/io/src/entity/image_location.dart b/lib/io/src/entity/image_location.dart new file mode 100644 index 00000000..ef43e1bf --- /dev/null +++ b/lib/io/src/entity/image_location.dart @@ -0,0 +1 @@ +enum ImageLocation { photos, files } diff --git a/lib/io/src/entity/image_meta_data.dart b/lib/io/src/entity/image_meta_data.dart new file mode 100644 index 00000000..700ce02e --- /dev/null +++ b/lib/io/src/entity/image_meta_data.dart @@ -0,0 +1,30 @@ +part 'image_format.dart'; + +abstract class ImageMetaData { + final String name; + final ImageFormat format; + + const ImageMetaData(this.name, this.format); + + @override + String toString() => "$name.${format.extension}"; +} + +class JpgMetaData extends ImageMetaData { + /// Value between 1-100 (both inclusive) + final int quality; + + const JpgMetaData(String name, this.quality) : super(name, ImageFormat.jpg); + + @override + String toString() => "$name.${format.extension} - $quality%"; +} + +class PngMetaData extends ImageMetaData { + const PngMetaData(String name) : super(name, ImageFormat.png); +} + +class CatrobatImageMetaData extends ImageMetaData { + const CatrobatImageMetaData(String name) + : super(name, ImageFormat.catrobatImage); +} diff --git a/lib/io/src/failure/load_image_failure.dart b/lib/io/src/failure/load_image_failure.dart index 12756440..bd0a473b 100644 --- a/lib/io/src/failure/load_image_failure.dart +++ b/lib/io/src/failure/load_image_failure.dart @@ -7,5 +7,6 @@ class LoadImageFailure extends Failure { LoadImageFailure._("Permission to view photos is denied in settings"); static const userCancelled = LoadImageFailure._("User did not choose an image"); + static const invalidImage = LoadImageFailure._("Invalid image"); static const unidentified = LoadImageFailure._("Could not load image"); } diff --git a/lib/io/src/failure/save_image_failure.dart b/lib/io/src/failure/save_image_failure.dart index 6b573230..16fd8f53 100644 --- a/lib/io/src/failure/save_image_failure.dart +++ b/lib/io/src/failure/save_image_failure.dart @@ -5,5 +5,7 @@ class SaveImageFailure extends Failure { static const permissionDenied = SaveImageFailure._("Permission to save photos is denied in settings"); + static const userCancelled = + SaveImageFailure._("User did not choose a save location"); static const unidentified = SaveImageFailure._("Could not save image"); } diff --git a/lib/io/src/serialization/proto/generate_protos.sh b/lib/io/src/serialization/proto/generate_protos.sh index 718930c3..75b99a1b 100644 --- a/lib/io/src/serialization/proto/generate_protos.sh +++ b/lib/io/src/serialization/proto/generate_protos.sh @@ -1 +1 @@ -protoc --dart_out=output --proto_path=schema "$(find schema -iname "*.proto")" \ No newline at end of file +protoc --dart_out=output --proto_path=schema $(find schema -iname "*.proto") google/protobuf/any.proto \ No newline at end of file diff --git a/lib/io/src/serialization/proto/output/catrobat_image.pb.dart b/lib/io/src/serialization/proto/output/catrobat_image.pb.dart new file mode 100644 index 00000000..697597b2 --- /dev/null +++ b/lib/io/src/serialization/proto/output/catrobat_image.pb.dart @@ -0,0 +1,90 @@ +/// +import 'dart:core' as $core; + +import 'package:protobuf/protobuf.dart' as $pb; + +import 'google/protobuf/any.pb.dart' as $2; + +class SerializableCatrobatImage extends $pb.GeneratedMessage { + static final $pb.BuilderInfo _i = $pb.BuilderInfo(const $core.bool.fromEnvironment('protobuf.omit_message_names') ? '' : 'SerializableCatrobatImage', createEmptyInstance: create) + ..aOS(1, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'magicValue', protoName: 'magicValue') + ..a<$core.int>(2, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'version', $pb.PbFieldType.O3) + ..pc<$2.Any>(3, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'commands', $pb.PbFieldType.PM, subBuilder: $2.Any.create) + ..a<$core.List<$core.int>>(4, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'loadedImage', $pb.PbFieldType.OY, protoName: 'loadedImage') + ..hasRequiredFields = false + ; + + SerializableCatrobatImage._() : super(); + factory SerializableCatrobatImage({ + $core.String? magicValue, + $core.int? version, + $core.Iterable<$2.Any>? commands, + $core.List<$core.int>? loadedImage, + }) { + final _result = create(); + if (magicValue != null) { + _result.magicValue = magicValue; + } + if (version != null) { + _result.version = version; + } + if (commands != null) { + _result.commands.addAll(commands); + } + if (loadedImage != null) { + _result.loadedImage = loadedImage; + } + return _result; + } + factory SerializableCatrobatImage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SerializableCatrobatImage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + SerializableCatrobatImage clone() => SerializableCatrobatImage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SerializableCatrobatImage copyWith(void Function(SerializableCatrobatImage) updates) => super.copyWith((message) => updates(message as SerializableCatrobatImage)) as SerializableCatrobatImage; // ignore: deprecated_member_use + $pb.BuilderInfo get info_ => _i; + @$core.pragma('dart2js:noInline') + static SerializableCatrobatImage create() => SerializableCatrobatImage._(); + SerializableCatrobatImage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SerializableCatrobatImage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SerializableCatrobatImage? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get magicValue => $_getSZ(0); + @$pb.TagNumber(1) + set magicValue($core.String v) { $_setString(0, v); } + @$pb.TagNumber(1) + $core.bool hasMagicValue() => $_has(0); + @$pb.TagNumber(1) + void clearMagicValue() => clearField(1); + + @$pb.TagNumber(2) + $core.int get version => $_getIZ(1); + @$pb.TagNumber(2) + set version($core.int v) { $_setSignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasVersion() => $_has(1); + @$pb.TagNumber(2) + void clearVersion() => clearField(2); + + @$pb.TagNumber(3) + $core.List<$2.Any> get commands => $_getList(2); + + @$pb.TagNumber(4) + $core.List<$core.int> get loadedImage => $_getN(3); + @$pb.TagNumber(4) + set loadedImage($core.List<$core.int> v) { $_setBytes(3, v); } + @$pb.TagNumber(4) + $core.bool hasLoadedImage() => $_has(3); + @$pb.TagNumber(4) + void clearLoadedImage() => clearField(4); +} + diff --git a/lib/io/src/serialization/proto/output/catrobat_image.pbenum.dart b/lib/io/src/serialization/proto/output/catrobat_image.pbenum.dart new file mode 100644 index 00000000..caaf031b --- /dev/null +++ b/lib/io/src/serialization/proto/output/catrobat_image.pbenum.dart @@ -0,0 +1,7 @@ +/// +// Generated code. Do not modify. +// source: catrobat_image.proto +// +// @dart = 2.12 +// ignore_for_file: annotate_overrides,camel_case_types,constant_identifier_names,directives_ordering,library_prefixes,non_constant_identifier_names,prefer_final_fields,return_of_invalid_type,unnecessary_const,unnecessary_import,unnecessary_this,unused_import,unused_shown_name + diff --git a/lib/io/src/serialization/proto/output/catrobat_image.pbjson.dart b/lib/io/src/serialization/proto/output/catrobat_image.pbjson.dart new file mode 100644 index 00000000..e193acf4 --- /dev/null +++ b/lib/io/src/serialization/proto/output/catrobat_image.pbjson.dart @@ -0,0 +1,20 @@ +/// +import 'dart:convert' as $convert; +import 'dart:core' as $core; +import 'dart:typed_data' as $typed_data; +@$core.Deprecated('Use serializableCatrobatImageDescriptor instead') +const SerializableCatrobatImage$json = const { + '1': 'SerializableCatrobatImage', + '2': const [ + const {'1': 'magicValue', '3': 1, '4': 1, '5': 9, '10': 'magicValue'}, + const {'1': 'version', '3': 2, '4': 1, '5': 5, '10': 'version'}, + const {'1': 'commands', '3': 3, '4': 3, '5': 11, '6': '.google.protobuf.Any', '10': 'commands'}, + const {'1': 'loadedImage', '3': 4, '4': 1, '5': 12, '9': 0, '10': 'loadedImage', '17': true}, + ], + '8': const [ + const {'1': '_loadedImage'}, + ], +}; + +/// Descriptor for `SerializableCatrobatImage`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List serializableCatrobatImageDescriptor = $convert.base64Decode('ChlTZXJpYWxpemFibGVDYXRyb2JhdEltYWdlEh4KCm1hZ2ljVmFsdWUYASABKAlSCm1hZ2ljVmFsdWUSGAoHdmVyc2lvbhgCIAEoBVIHdmVyc2lvbhIwCghjb21tYW5kcxgDIAMoCzIULmdvb2dsZS5wcm90b2J1Zi5BbnlSCGNvbW1hbmRzEiUKC2xvYWRlZEltYWdlGAQgASgMSABSC2xvYWRlZEltYWdliAEBQg4KDF9sb2FkZWRJbWFnZQ=='); diff --git a/lib/io/src/serialization/proto/output/catrobat_image.pbserver.dart b/lib/io/src/serialization/proto/output/catrobat_image.pbserver.dart new file mode 100644 index 00000000..a3b0b156 --- /dev/null +++ b/lib/io/src/serialization/proto/output/catrobat_image.pbserver.dart @@ -0,0 +1,3 @@ +/// +export 'catrobat_image.pb.dart'; + diff --git a/lib/io/src/serialization/proto/output/google/protobuf/any.pb.dart b/lib/io/src/serialization/proto/output/google/protobuf/any.pb.dart new file mode 100644 index 00000000..43060c92 --- /dev/null +++ b/lib/io/src/serialization/proto/output/google/protobuf/any.pb.dart @@ -0,0 +1,78 @@ +/// +import 'dart:core' as $core; + +import 'package:protobuf/protobuf.dart' as $pb; +import 'package:protobuf/src/protobuf/mixins/well_known.dart' as $mixin; + +class Any extends $pb.GeneratedMessage with $mixin.AnyMixin { + static final $pb.BuilderInfo _i = $pb.BuilderInfo(const $core.bool.fromEnvironment('protobuf.omit_message_names') ? '' : 'Any', package: const $pb.PackageName(const $core.bool.fromEnvironment('protobuf.omit_message_names') ? '' : 'google.protobuf'), createEmptyInstance: create, toProto3Json: $mixin.AnyMixin.toProto3JsonHelper, fromProto3Json: $mixin.AnyMixin.fromProto3JsonHelper) + ..aOS(1, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'typeUrl') + ..a<$core.List<$core.int>>(2, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'value', $pb.PbFieldType.OY) + ..hasRequiredFields = false + ; + + Any._() : super(); + factory Any({ + $core.String? typeUrl, + $core.List<$core.int>? value, + }) { + final _result = create(); + if (typeUrl != null) { + _result.typeUrl = typeUrl; + } + if (value != null) { + _result.value = value; + } + return _result; + } + factory Any.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Any.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Any clone() => Any()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Any copyWith(void Function(Any) updates) => super.copyWith((message) => updates(message as Any)) as Any; // ignore: deprecated_member_use + $pb.BuilderInfo get info_ => _i; + @$core.pragma('dart2js:noInline') + static Any create() => Any._(); + Any createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Any getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Any? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get typeUrl => $_getSZ(0); + @$pb.TagNumber(1) + set typeUrl($core.String v) { $_setString(0, v); } + @$pb.TagNumber(1) + $core.bool hasTypeUrl() => $_has(0); + @$pb.TagNumber(1) + void clearTypeUrl() => clearField(1); + + @$pb.TagNumber(2) + $core.List<$core.int> get value => $_getN(1); + @$pb.TagNumber(2) + set value($core.List<$core.int> v) { $_setBytes(1, v); } + @$pb.TagNumber(2) + $core.bool hasValue() => $_has(1); + @$pb.TagNumber(2) + void clearValue() => clearField(2); + /// Creates a new [Any] encoding [message]. + /// + /// The [typeUrl] will be [typeUrlPrefix]/`fullName` where `fullName` is + /// the fully qualified name of the type of [message]. + static Any pack($pb.GeneratedMessage message, + {$core.String typeUrlPrefix = 'type.googleapis.com'}) { + final result = create(); + $mixin.AnyMixin.packIntoAny(result, message, + typeUrlPrefix: typeUrlPrefix); + return result; + } +} + diff --git a/lib/io/src/serialization/proto/output/google/protobuf/any.pbenum.dart b/lib/io/src/serialization/proto/output/google/protobuf/any.pbenum.dart new file mode 100644 index 00000000..2f49d627 --- /dev/null +++ b/lib/io/src/serialization/proto/output/google/protobuf/any.pbenum.dart @@ -0,0 +1,7 @@ +/// +// Generated code. Do not modify. +// source: google/protobuf/any.proto +// +// @dart = 2.12 +// ignore_for_file: annotate_overrides,camel_case_types,constant_identifier_names,directives_ordering,library_prefixes,non_constant_identifier_names,prefer_final_fields,return_of_invalid_type,unnecessary_const,unnecessary_import,unnecessary_this,unused_import,unused_shown_name + diff --git a/lib/io/src/serialization/proto/output/google/protobuf/any.pbjson.dart b/lib/io/src/serialization/proto/output/google/protobuf/any.pbjson.dart new file mode 100644 index 00000000..deec96ee --- /dev/null +++ b/lib/io/src/serialization/proto/output/google/protobuf/any.pbjson.dart @@ -0,0 +1,15 @@ +/// +import 'dart:convert' as $convert; +import 'dart:core' as $core; +import 'dart:typed_data' as $typed_data; +@$core.Deprecated('Use anyDescriptor instead') +const Any$json = const { + '1': 'Any', + '2': const [ + const {'1': 'type_url', '3': 1, '4': 1, '5': 9, '10': 'typeUrl'}, + const {'1': 'value', '3': 2, '4': 1, '5': 12, '10': 'value'}, + ], +}; + +/// Descriptor for `Any`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List anyDescriptor = $convert.base64Decode('CgNBbnkSGQoIdHlwZV91cmwYASABKAlSB3R5cGVVcmwSFAoFdmFsdWUYAiABKAxSBXZhbHVl'); diff --git a/lib/io/src/serialization/proto/output/google/protobuf/any.pbserver.dart b/lib/io/src/serialization/proto/output/google/protobuf/any.pbserver.dart new file mode 100644 index 00000000..ba997003 --- /dev/null +++ b/lib/io/src/serialization/proto/output/google/protobuf/any.pbserver.dart @@ -0,0 +1,3 @@ +/// +export 'any.pb.dart'; + diff --git a/lib/io/src/serialization/proto/output/graphic/paint.pb.dart b/lib/io/src/serialization/proto/output/graphic/paint.pb.dart index 166173ae..52ce34cc 100644 --- a/lib/io/src/serialization/proto/output/graphic/paint.pb.dart +++ b/lib/io/src/serialization/proto/output/graphic/paint.pb.dart @@ -9,9 +9,10 @@ export 'paint.pbenum.dart'; class SerializablePaint extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(const $core.bool.fromEnvironment('protobuf.omit_message_names') ? '' : 'SerializablePaint', createEmptyInstance: create) - ..a<$core.int>(1, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'color', $pb.PbFieldType.O3) + ..a<$core.int>(1, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'color', $pb.PbFieldType.OU3) ..a<$core.double>(2, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'strokeWidth', $pb.PbFieldType.OF, protoName: 'strokeWidth') ..e(3, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'cap', $pb.PbFieldType.OE, defaultOrMaker: SerializablePaint_StrokeCap.ROUND, valueOf: SerializablePaint_StrokeCap.valueOf, enumValues: SerializablePaint_StrokeCap.values) + ..e(4, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'style', $pb.PbFieldType.OE, defaultOrMaker: SerializablePaint_PaintingStyle.FILL, valueOf: SerializablePaint_PaintingStyle.valueOf, enumValues: SerializablePaint_PaintingStyle.values) ..hasRequiredFields = false ; @@ -20,6 +21,7 @@ class SerializablePaint extends $pb.GeneratedMessage { $core.int? color, $core.double? strokeWidth, SerializablePaint_StrokeCap? cap, + SerializablePaint_PaintingStyle? style, }) { final _result = create(); if (color != null) { @@ -31,6 +33,9 @@ class SerializablePaint extends $pb.GeneratedMessage { if (cap != null) { _result.cap = cap; } + if (style != null) { + _result.style = style; + } return _result; } factory SerializablePaint.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); @@ -57,7 +62,7 @@ class SerializablePaint extends $pb.GeneratedMessage { @$pb.TagNumber(1) $core.int get color => $_getIZ(0); @$pb.TagNumber(1) - set color($core.int v) { $_setSignedInt32(0, v); } + set color($core.int v) { $_setUnsignedInt32(0, v); } @$pb.TagNumber(1) $core.bool hasColor() => $_has(0); @$pb.TagNumber(1) @@ -80,5 +85,14 @@ class SerializablePaint extends $pb.GeneratedMessage { $core.bool hasCap() => $_has(2); @$pb.TagNumber(3) void clearCap() => clearField(3); + + @$pb.TagNumber(4) + SerializablePaint_PaintingStyle get style => $_getN(3); + @$pb.TagNumber(4) + set style(SerializablePaint_PaintingStyle v) { setField(4, v); } + @$pb.TagNumber(4) + $core.bool hasStyle() => $_has(3); + @$pb.TagNumber(4) + void clearStyle() => clearField(4); } diff --git a/lib/io/src/serialization/proto/output/graphic/paint.pbenum.dart b/lib/io/src/serialization/proto/output/graphic/paint.pbenum.dart index d81bc108..35471d37 100644 --- a/lib/io/src/serialization/proto/output/graphic/paint.pbenum.dart +++ b/lib/io/src/serialization/proto/output/graphic/paint.pbenum.dart @@ -20,3 +20,18 @@ class SerializablePaint_StrokeCap extends $pb.ProtobufEnum { const SerializablePaint_StrokeCap._($core.int v, $core.String n) : super(v, n); } +class SerializablePaint_PaintingStyle extends $pb.ProtobufEnum { + static const SerializablePaint_PaintingStyle FILL = SerializablePaint_PaintingStyle._(0, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'FILL'); + static const SerializablePaint_PaintingStyle STROKE = SerializablePaint_PaintingStyle._(1, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'STROKE'); + + static const $core.List values = [ + FILL, + STROKE, + ]; + + static final $core.Map<$core.int, SerializablePaint_PaintingStyle> _byValue = $pb.ProtobufEnum.initByValue(values); + static SerializablePaint_PaintingStyle? valueOf($core.int value) => _byValue[value]; + + const SerializablePaint_PaintingStyle._($core.int v, $core.String n) : super(v, n); +} + diff --git a/lib/io/src/serialization/proto/output/graphic/paint.pbjson.dart b/lib/io/src/serialization/proto/output/graphic/paint.pbjson.dart index 98591769..fd681235 100644 --- a/lib/io/src/serialization/proto/output/graphic/paint.pbjson.dart +++ b/lib/io/src/serialization/proto/output/graphic/paint.pbjson.dart @@ -6,11 +6,12 @@ import 'dart:typed_data' as $typed_data; const SerializablePaint$json = const { '1': 'SerializablePaint', '2': const [ - const {'1': 'color', '3': 1, '4': 1, '5': 5, '10': 'color'}, + const {'1': 'color', '3': 1, '4': 1, '5': 13, '10': 'color'}, const {'1': 'strokeWidth', '3': 2, '4': 1, '5': 2, '10': 'strokeWidth'}, const {'1': 'cap', '3': 3, '4': 1, '5': 14, '6': '.SerializablePaint.StrokeCap', '10': 'cap'}, + const {'1': 'style', '3': 4, '4': 1, '5': 14, '6': '.SerializablePaint.PaintingStyle', '10': 'style'}, ], - '4': const [SerializablePaint_StrokeCap$json], + '4': const [SerializablePaint_StrokeCap$json, SerializablePaint_PaintingStyle$json], }; @$core.Deprecated('Use serializablePaintDescriptor instead') @@ -23,5 +24,14 @@ const SerializablePaint_StrokeCap$json = const { ], }; +@$core.Deprecated('Use serializablePaintDescriptor instead') +const SerializablePaint_PaintingStyle$json = const { + '1': 'PaintingStyle', + '2': const [ + const {'1': 'FILL', '2': 0}, + const {'1': 'STROKE', '2': 1}, + ], +}; + /// Descriptor for `SerializablePaint`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List serializablePaintDescriptor = $convert.base64Decode('ChFTZXJpYWxpemFibGVQYWludBIUCgVjb2xvchgBIAEoBVIFY29sb3ISIAoLc3Ryb2tlV2lkdGgYAiABKAJSC3N0cm9rZVdpZHRoEi4KA2NhcBgDIAEoDjIcLlNlcmlhbGl6YWJsZVBhaW50LlN0cm9rZUNhcFIDY2FwIiwKCVN0cm9rZUNhcBIJCgVST1VORBAAEggKBEJVVFQQARIKCgZTUVVBUkUQAg=='); +final $typed_data.Uint8List serializablePaintDescriptor = $convert.base64Decode('ChFTZXJpYWxpemFibGVQYWludBIUCgVjb2xvchgBIAEoDVIFY29sb3ISIAoLc3Ryb2tlV2lkdGgYAiABKAJSC3N0cm9rZVdpZHRoEi4KA2NhcBgDIAEoDjIcLlNlcmlhbGl6YWJsZVBhaW50LlN0cm9rZUNhcFIDY2FwEjYKBXN0eWxlGAQgASgOMiAuU2VyaWFsaXphYmxlUGFpbnQuUGFpbnRpbmdTdHlsZVIFc3R5bGUiLAoJU3Ryb2tlQ2FwEgkKBVJPVU5EEAASCAoEQlVUVBABEgoKBlNRVUFSRRACIiUKDVBhaW50aW5nU3R5bGUSCAoERklMTBAAEgoKBlNUUk9LRRAB'); diff --git a/lib/io/src/serialization/proto/protos.dart b/lib/io/src/serialization/proto/protos.dart new file mode 100644 index 00000000..a180b981 --- /dev/null +++ b/lib/io/src/serialization/proto/protos.dart @@ -0,0 +1,4 @@ +export 'output/command/graphic/draw_path_command.pb.dart'; +export 'output/google/protobuf/any.pb.dart'; +export 'output/graphic/paint.pb.dart'; +export 'output/graphic/path.pb.dart'; diff --git a/lib/io/src/serialization/proto/schema/catrobat_image.proto b/lib/io/src/serialization/proto/schema/catrobat_image.proto new file mode 100644 index 00000000..c0f970a3 --- /dev/null +++ b/lib/io/src/serialization/proto/schema/catrobat_image.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +import "google/protobuf/any.proto"; + +message SerializableCatrobatImage { + string magicValue = 1; + int32 version = 2; + repeated google.protobuf.Any commands = 3; + optional bytes loadedImage = 4; +} \ No newline at end of file diff --git a/lib/io/src/serialization/proto/schema/graphic/paint.proto b/lib/io/src/serialization/proto/schema/graphic/paint.proto index 41d83b7c..f551268d 100644 --- a/lib/io/src/serialization/proto/schema/graphic/paint.proto +++ b/lib/io/src/serialization/proto/schema/graphic/paint.proto @@ -1,7 +1,7 @@ syntax = 'proto3'; message SerializablePaint { - int32 color = 1; + uint32 color = 1; float strokeWidth = 2; enum StrokeCap { ROUND = 0; @@ -9,4 +9,9 @@ message SerializablePaint { SQUARE = 2; } StrokeCap cap = 3; + enum PaintingStyle { + FILL = 0; + STROKE = 1; + } + PaintingStyle style = 4; } \ No newline at end of file diff --git a/lib/io/src/serialization/proto/schema/graphic/path.proto b/lib/io/src/serialization/proto/schema/graphic/path.proto index fa51f9ee..8fa45480 100644 --- a/lib/io/src/serialization/proto/schema/graphic/path.proto +++ b/lib/io/src/serialization/proto/schema/graphic/path.proto @@ -1,5 +1,4 @@ syntax = "proto3"; -//package org.catrobat.paintroid.graphic; message SerializablePath { message Action { diff --git a/lib/io/src/serialization/proto_serializer.dart b/lib/io/src/serialization/proto_serializer.dart deleted file mode 100644 index b68244fd..00000000 --- a/lib/io/src/serialization/proto_serializer.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'serializer.dart'; - -abstract class ProtoSerializer implements Serializer { - T deserializeFromProto(S serializable); - - S convertToProtoSerializable(T object); -} diff --git a/lib/io/src/serialization/proto_serializer_with_versioning.dart b/lib/io/src/serialization/proto_serializer_with_versioning.dart new file mode 100644 index 00000000..29453232 --- /dev/null +++ b/lib/io/src/serialization/proto_serializer_with_versioning.dart @@ -0,0 +1,24 @@ +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:protobuf/protobuf.dart' show GeneratedMessage; + +import 'version_serializer.dart'; + +abstract class ProtoSerializerWithVersioning + extends VersionSerializer { + const ProtoSerializerWithVersioning(super.version); + + static const urlPrefix = "org.catrobat.paintroid"; + + @protected + SERIALIZABLE Function(Uint8List binary) get fromBytesToSerializable; + + @nonVirtual + T fromBytes(Uint8List binary) => deserialize(fromBytesToSerializable(binary)); + + @nonVirtual + Uint8List toBytes(T object) => + serializeWithLatestVersion(object).writeToBuffer(); +} diff --git a/lib/io/src/serialization/serializer.dart b/lib/io/src/serialization/serializer.dart deleted file mode 100644 index b52c8ee8..00000000 --- a/lib/io/src/serialization/serializer.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'dart:typed_data'; - -abstract class Serializer { - Uint8List serialize(T object); - - T deserialize(Uint8List binary); -} diff --git a/lib/io/src/serialization/serializer/catrobat_image_serializer.dart b/lib/io/src/serialization/serializer/catrobat_image_serializer.dart new file mode 100644 index 00000000..46ad818f --- /dev/null +++ b/lib/io/src/serialization/serializer/catrobat_image_serializer.dart @@ -0,0 +1,58 @@ +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart' show Provider; +import 'package:paintroid/command/command.dart' show Command, DrawPathCommand; +import 'package:paintroid/io/io.dart' show CatrobatImage; +import 'package:paintroid/io/serialization.dart'; + +class CatrobatImageSerializer extends ProtoSerializerWithVersioning< + CatrobatImage, SerializableCatrobatImage> { + final DrawPathCommandSerializer _drawPathCommandSerializer; + + const CatrobatImageSerializer(super.version, this._drawPathCommandSerializer); + + static final provider = Provider.family( + (ref, int ver) => CatrobatImageSerializer( + ver, + ref.watch(DrawPathCommandSerializer.provider(ver)), + ), + ); + + @override + SerializableCatrobatImage serializeWithLatestVersion(CatrobatImage object) { + return SerializableCatrobatImage( + magicValue: CatrobatImage.magicValue, + version: CatrobatImage.latestVersion, + loadedImage: object.loadedImage, + commands: object.commands.map((command) { + if (command is DrawPathCommand) { + return Any.pack( + _drawPathCommandSerializer.serializeWithLatestVersion(command), + typeUrlPrefix: ProtoSerializerWithVersioning.urlPrefix, + ); + } else { + throw "Invalid command type"; + } + }), + ); + } + + @override + CatrobatImage deserializeWithLatestVersion(SerializableCatrobatImage data) { + final commands = []; + for (final cmd in data.commands) { + if (cmd.canUnpackInto(SerializableDrawPathCommand.getDefault())) { + final unpacked = cmd.unpackInto(SerializableDrawPathCommand()); + commands.add(_drawPathCommandSerializer.deserialize(unpacked)); + } else { + throw "Invalid command type"; + } + } + final loadedImage = + data.loadedImage.isEmpty ? null : Uint8List.fromList(data.loadedImage); + return CatrobatImage(commands, loadedImage, version: data.version); + } + + @override + final fromBytesToSerializable = SerializableCatrobatImage.fromBuffer; +} diff --git a/lib/io/src/serialization/serializer/command/graphic/draw_path_command_serializer.dart b/lib/io/src/serialization/serializer/command/graphic/draw_path_command_serializer.dart index 872ccaed..81a62904 100644 --- a/lib/io/src/serialization/serializer/command/graphic/draw_path_command_serializer.dart +++ b/lib/io/src/serialization/serializer/command/graphic/draw_path_command_serializer.dart @@ -1,47 +1,45 @@ -import 'dart:typed_data'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:paintroid/command/command.dart' + show CommandFactory, DrawPathCommand; +import 'package:paintroid/io/serialization.dart'; -import 'package:paintroid/command/command.dart'; - -import '../../../proto/output/command/graphic/draw_path_command.pb.dart'; -import '../../../proto_serializer.dart'; -import '../../graphic/paint_serializer.dart'; -import '../../graphic/path_serializer.dart'; - -class DrawPathCommandSerializer - implements ProtoSerializer { - final PathSerializer pathSerializer; - final PaintSerializer paintSerializer; - final CommandFactory commandFactory; +class DrawPathCommandSerializer extends ProtoSerializerWithVersioning< + DrawPathCommand, SerializableDrawPathCommand> { + final PathSerializer _pathSerializer; + final PaintSerializer _paintSerializer; + final CommandFactory _commandFactory; const DrawPathCommandSerializer( - this.pathSerializer, - this.paintSerializer, - this.commandFactory, + super.version, + this._pathSerializer, + this._paintSerializer, + this._commandFactory, ); - @override - DrawPathCommand deserialize(Uint8List binary) { - final serializable = SerializableDrawPathCommand.fromBuffer(binary); - return deserializeFromProto(serializable); - } + static final provider = Provider.family( + (ref, int ver) => DrawPathCommandSerializer( + ver, + ref.watch(PathSerializer.provider(ver)), + ref.watch(PaintSerializer.provider(ver)), + ref.watch(CommandFactory.provider)), + ); @override - DrawPathCommand deserializeFromProto( - SerializableDrawPathCommand serializable) { - final path = pathSerializer.deserializeFromProto(serializable.path); - final paint = paintSerializer.deserializeFromProto(serializable.paint); - return commandFactory.createDrawPathCommand(path, paint); - } + final fromBytesToSerializable = SerializableDrawPathCommand.fromBuffer; @override - Uint8List serialize(DrawPathCommand object) => - convertToProtoSerializable(object).writeToBuffer(); + DrawPathCommand deserializeWithLatestVersion( + SerializableDrawPathCommand data) { + final path = _pathSerializer.deserialize(data.path); + final paint = _paintSerializer.deserialize(data.paint); + return _commandFactory.createDrawPathCommand(path, paint); + } @override - SerializableDrawPathCommand convertToProtoSerializable( + SerializableDrawPathCommand serializeWithLatestVersion( DrawPathCommand object) { - final sPaint = paintSerializer.convertToProtoSerializable(object.paint); - final sPath = pathSerializer.convertToProtoSerializable(object.path); + final sPaint = _paintSerializer.serializeWithLatestVersion(object.paint); + final sPath = _pathSerializer.serializeWithLatestVersion(object.path); return SerializableDrawPathCommand(paint: sPaint, path: sPath); } } diff --git a/lib/io/src/serialization/serializer/graphic/paint_serializer.dart b/lib/io/src/serialization/serializer/graphic/paint_serializer.dart index 623fc10f..0f243a0d 100644 --- a/lib/io/src/serialization/serializer/graphic/paint_serializer.dart +++ b/lib/io/src/serialization/serializer/graphic/paint_serializer.dart @@ -1,28 +1,28 @@ -import 'dart:typed_data'; import 'dart:ui'; +import 'package:flutter_riverpod/flutter_riverpod.dart' show Provider; import 'package:paintroid/core/graphic_factory.dart'; +import 'package:paintroid/io/serialization.dart'; -import '../../proto/output/graphic/paint.pb.dart'; -import '../../proto_serializer.dart'; +class PaintSerializer + extends ProtoSerializerWithVersioning { + final GraphicFactory _graphicFactory; -class PaintSerializer implements ProtoSerializer { - final GraphicFactory graphicFactory; + const PaintSerializer(super.version, this._graphicFactory); - const PaintSerializer(this.graphicFactory); + static final provider = Provider.family( + (ref, int ver) => PaintSerializer(ver, ref.watch(GraphicFactory.provider)), + ); @override - Paint deserialize(Uint8List binary) { - final serializablePaint = SerializablePaint.fromBuffer(binary); - return deserializeFromProto(serializablePaint); - } + final fromBytesToSerializable = SerializablePaint.fromBuffer; @override - Paint deserializeFromProto(SerializablePaint serializable) { - final paint = graphicFactory.createPaint() - ..color = Color(serializable.color) - ..strokeWidth = serializable.strokeWidth; - switch (serializable.cap) { + Paint deserializeWithLatestVersion(SerializablePaint data) { + final paint = _graphicFactory.createPaint() + ..color = Color(data.color) + ..strokeWidth = data.strokeWidth; + switch (data.cap) { case SerializablePaint_StrokeCap.BUTT: paint.strokeCap = StrokeCap.butt; break; @@ -33,15 +33,19 @@ class PaintSerializer implements ProtoSerializer { paint.strokeCap = StrokeCap.square; break; } + switch (data.style) { + case SerializablePaint_PaintingStyle.FILL: + paint.style = PaintingStyle.fill; + break; + case SerializablePaint_PaintingStyle.STROKE: + paint.style = PaintingStyle.stroke; + break; + } return paint; } @override - Uint8List serialize(Paint object) => - convertToProtoSerializable(object).writeToBuffer(); - - @override - SerializablePaint convertToProtoSerializable(Paint object) { + SerializablePaint serializeWithLatestVersion(Paint object) { final serializable = SerializablePaint() ..color = object.color.value ..strokeWidth = object.strokeWidth; @@ -56,6 +60,14 @@ class PaintSerializer implements ProtoSerializer { serializable.cap = SerializablePaint_StrokeCap.SQUARE; break; } + switch(object.style) { + case PaintingStyle.fill: + serializable.style = SerializablePaint_PaintingStyle.FILL; + break; + case PaintingStyle.stroke: + serializable.style = SerializablePaint_PaintingStyle.STROKE; + break; + } return serializable; } } diff --git a/lib/io/src/serialization/serializer/graphic/path_serializer.dart b/lib/io/src/serialization/serializer/graphic/path_serializer.dart index 1af44afc..fbfa3b4f 100644 --- a/lib/io/src/serialization/serializer/graphic/path_serializer.dart +++ b/lib/io/src/serialization/serializer/graphic/path_serializer.dart @@ -1,28 +1,25 @@ -import 'dart:typed_data'; import 'dart:ui'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:paintroid/core/graphic_factory.dart'; +import 'package:paintroid/core/loggable_mixin.dart'; import 'package:paintroid/core/path_with_action_history.dart'; +import 'package:paintroid/io/serialization.dart'; -import '../../proto/output/graphic/path.pb.dart'; -import '../../proto_serializer.dart'; +class PathSerializer extends ProtoSerializerWithVersioning< + PathWithActionHistory, SerializablePath> with LoggableMixin { + final GraphicFactory _graphicFactory; -class PathSerializer - implements ProtoSerializer { - final GraphicFactory graphicFactory; + PathSerializer(super.version, this._graphicFactory); - const PathSerializer(this.graphicFactory); + static final provider = Provider.family( + (ref, int ver) => PathSerializer(ver, ref.watch(GraphicFactory.provider)), + ); @override - PathWithActionHistory deserialize(Uint8List binary) { - final serializablePath = SerializablePath.fromBuffer(binary); - return deserializeFromProto(serializablePath); - } - - @override - PathWithActionHistory deserializeFromProto(SerializablePath serializable) { - final path = graphicFactory.createPathWithActionHistory(); - switch (serializable.fillType) { + PathWithActionHistory deserializeWithLatestVersion(SerializablePath data) { + final path = _graphicFactory.createPathWithActionHistory(); + switch (data.fillType) { case SerializablePath_FillType.EVEN_ODD: path.fillType = PathFillType.evenOdd; break; @@ -30,24 +27,26 @@ class PathSerializer path.fillType = PathFillType.nonZero; break; } - for (final action in serializable.actions) { + for (var i = 0; i < data.actions.length; i++) { + final action = data.actions[i]; if (action.hasMoveTo()) { path.moveTo(action.moveTo.x, action.moveTo.y); } else if (action.hasLineTo()) { path.lineTo(action.lineTo.x, action.lineTo.y); } else if (action.hasClose()) { path.close(); + } else { + logger.severe("No Path Action was set at index $i."); } } return path; } @override - Uint8List serialize(PathWithActionHistory object) => - convertToProtoSerializable(object).writeToBuffer(); + final fromBytesToSerializable = SerializablePath.fromBuffer; @override - SerializablePath convertToProtoSerializable(PathWithActionHistory object) { + SerializablePath serializeWithLatestVersion(PathWithActionHistory object) { final serializablePath = SerializablePath(); switch (object.fillType) { case PathFillType.nonZero: @@ -68,6 +67,8 @@ class PathSerializer } else if (action is CloseAction) { final close = SerializablePath_Action_Close(); serializableAction = SerializablePath_Action(close: close); + } else { + logger.severe("Path Action serialization was not handled for $action"); } serializablePath.actions.add(serializableAction); } diff --git a/lib/io/src/serialization/version_serializer.dart b/lib/io/src/serialization/version_serializer.dart new file mode 100644 index 00000000..3dadea92 --- /dev/null +++ b/lib/io/src/serialization/version_serializer.dart @@ -0,0 +1,27 @@ +import 'package:flutter/foundation.dart'; + +abstract class VersionSerializer { + final int version; + + static const v1 = 1; + + const VersionSerializer(this.version); + + TO serializeWithLatestVersion(FROM object); + + @nonVirtual + FROM deserialize(TO data) { + switch (version) { + case v1: + return deserializeV1(data); + default: + throw "Invalid version"; + } + } + + @protected + FROM deserializeV1(TO data) => deserializeWithLatestVersion(data); + + @protected + FROM deserializeWithLatestVersion(TO data); +} diff --git a/lib/io/src/service/file_service.dart b/lib/io/src/service/file_service.dart new file mode 100644 index 00000000..f44758bb --- /dev/null +++ b/lib/io/src/service/file_service.dart @@ -0,0 +1,55 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:paintroid/core/failure.dart'; +import 'package:paintroid/core/loggable_mixin.dart'; +import 'package:paintroid/io/io.dart'; + +abstract class IFileService { + TaskEither save(String filename, Uint8List data); + + TaskEither pick(); + + static final provider = Provider((ref) => FileService()); +} + +class FileService with LoggableMixin implements IFileService { + @override + TaskEither pick() => TaskEither(() async { + try { + final result = + await FilePicker.platform.pickFiles(allowCompression: false); + if (result == null) { + return const Left(LoadImageFailure.userCancelled); + } + if (result.files.single.path == null) { + throw "file path is null"; + } else { + return Right(File(result.files.single.path!)); + } + } catch (err, stacktrace) { + logger.severe("Could not load file", err, stacktrace); + return const Left(LoadImageFailure.unidentified); + } + }); + + @override + TaskEither save(String filename, Uint8List data) => + TaskEither(() async { + try { + final saveDirectory = await FilePicker.platform.getDirectoryPath(); + if (saveDirectory == null) { + return const Left(SaveImageFailure.userCancelled); + } + final file = + await File("$saveDirectory/$filename").create(recursive: true); + return Right(await file.writeAsBytes(data)); + } catch (err, stacktrace) { + logger.severe("Could not save file", err, stacktrace); + return const Left(SaveImageFailure.unidentified); + } + }); +} diff --git a/lib/io/src/service/image_service.dart b/lib/io/src/service/image_service.dart index f79cb5bc..55ed2eef 100644 --- a/lib/io/src/service/image_service.dart +++ b/lib/io/src/service/image_service.dart @@ -14,7 +14,9 @@ import '../failure/save_image_failure.dart'; abstract class IImageService { TaskEither import(Uint8List fileData); - /// Quality: 1-100 + TaskEither export(ui.Image image); + + /// Value between 1-100 (both inclusive) TaskEither exportAsJpg(ui.Image image, int quality); TaskEither exportAsPng(ui.Image image); @@ -23,16 +25,26 @@ abstract class IImageService { } class ImageService with LoggableMixin implements IImageService { - ImageService(); - @override TaskEither import(Uint8List fileData) => TaskEither(() async { try { return Right(await decodeImageFromList(fileData)); } catch (e, stacktrace) { - log.severe("Couldn't decode image from fileData", e, stacktrace); - return const Left(LoadImageFailure.unidentified); + logger.severe("Couldn't decode image from fileData", e, stacktrace); + return const Left(LoadImageFailure.invalidImage); + } + }); + + @override + TaskEither export(ui.Image image) => TaskEither(() async { + try { + final byteData = await image.toByteData(); + if (byteData == null) throw "Unable to convert canvas Image to bytes"; + return Right(byteData.buffer.asUint8List()); + } catch (err, stacktrace) { + logger.severe("Could not export to Jpg", err, stacktrace); + return const Left(SaveImageFailure.unidentified); } }); @@ -46,7 +58,7 @@ class ImageService with LoggableMixin implements IImageService { final img = Image.fromBytes(image.width, image.height, rawBytes); return Right(Uint8List.fromList(encodeJpg(img, quality: quality))); } catch (err, stacktrace) { - log.severe("Could not export to Jpg", err, stacktrace); + logger.severe("Could not export to Jpg", err, stacktrace); return const Left(SaveImageFailure.unidentified); } }); @@ -61,7 +73,7 @@ class ImageService with LoggableMixin implements IImageService { final img = Image.fromBytes(image.width, image.height, rawBytes); return Right(Uint8List.fromList(encodePng(img))); } catch (err, stacktrace) { - log.severe("Could not export to Png", err, stacktrace); + logger.severe("Could not export to Png", err, stacktrace); return const Left(SaveImageFailure.unidentified); } }); diff --git a/lib/io/src/service/photo_library_service.dart b/lib/io/src/service/photo_library_service.dart index 152d9525..f2992edd 100644 --- a/lib/io/src/service/photo_library_service.dart +++ b/lib/io/src/service/photo_library_service.dart @@ -39,15 +39,15 @@ class PhotoLibraryService with LoggableMixin implements IPhotoLibraryService { return const Right(unit); } on PlatformException catch (err, stacktrace) { if (err.code == "PERMISSION_DENIED") { - log.warning("User explicitly denied permission to save images", err, + logger.warning("User explicitly denied permission to save images", err, stacktrace); return const Left(SaveImageFailure.permissionDenied); } else { - log.severe("Could not save photo to library", err, stacktrace); + logger.severe("Could not save photo to library", err, stacktrace); return const Left(SaveImageFailure.unidentified); } } catch (err, stacktrace) { - log.severe("Could not save photo to library", err, stacktrace); + logger.severe("Could not save photo to library", err, stacktrace); return const Left(SaveImageFailure.unidentified); } }); @@ -62,15 +62,15 @@ class PhotoLibraryService with LoggableMixin implements IPhotoLibraryService { } on PlatformException catch (err, stacktrace) { // This error code is from ImagePicker if (err.code == "photo_access_denied") { - log.warning("User explicitly denied permission to load images", err, + logger.warning("User explicitly denied permission to load images", err, stacktrace); return const Left(LoadImageFailure.permissionDenied); } else { - log.severe("Could not load photo from library", err, stacktrace); + logger.severe("Could not load photo from library", err, stacktrace); return const Left(LoadImageFailure.unidentified); } } catch (err, stacktrace) { - log.severe("Could not load photo from library", err, stacktrace); + logger.severe("Could not load photo from library", err, stacktrace); return const Left(LoadImageFailure.unidentified); } }); diff --git a/lib/io/src/ui/discard_changes_dialog.dart b/lib/io/src/ui/discard_changes_dialog.dart new file mode 100644 index 00000000..212deae7 --- /dev/null +++ b/lib/io/src/ui/discard_changes_dialog.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +Future showDiscardChangesDialog(BuildContext context) => + showGeneralDialog( + context: context, + pageBuilder: (_, __, ___) => const DiscardChangesDialog(), + barrierDismissible: true, + barrierLabel: "Dismiss discard changes dialog box"); + +class DiscardChangesDialog extends StatefulWidget { + const DiscardChangesDialog({Key? key}) : super(key: key); + + @override + State createState() => _DiscardChangesDialogState(); +} + +class _DiscardChangesDialogState extends State { + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text("Discard changes"), + actions: [_discardButton, _saveButton], + content: const Text( + "You have not saved your last changes. They will be lost!"), + ); + } + + TextButton get _discardButton { + return TextButton( + style: TextButton.styleFrom(primary: Colors.red), + onPressed: () => Navigator.of(context).pop(true), + child: const Text("Discard"), + ); + } + + ElevatedButton get _saveButton { + return ElevatedButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text("Save", style: TextStyle(color: Colors.white)), + ); + } +} diff --git a/lib/io/src/ui/image_format_info.dart b/lib/io/src/ui/image_format_info.dart index da3f120f..3eaf07ce 100644 --- a/lib/io/src/ui/image_format_info.dart +++ b/lib/io/src/ui/image_format_info.dart @@ -1,42 +1,44 @@ part of 'save_image_dialog.dart'; +extension on ImageFormat { + TextSpan get info { + switch (this) { + case ImageFormat.png: + return const TextSpan( + text: "Lossless compression. Transparency is preserved"); + case ImageFormat.jpg: + return const TextSpan( + text: 'Takes up ', + children: [ + TextSpan( + text: 'minimal storage space.\nNo transparency ', + style: TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan(text: 'is remembered.'), + ], + ); + case ImageFormat.catrobatImage: + return const TextSpan( + text: "Pocket Paint's native image format. " + "This format remembers commands and layers."); + } + } +} + class ImageFormatInfo extends StatelessWidget { final ImageFormat format; const ImageFormatInfo(this.format, {Key? key}) : super(key: key); - final _pngInfo = - const TextSpan(text: "Lossless compression. Transparency is preserved"); - - final _jpgInfo = const TextSpan( - text: 'Takes up ', - children: [ - TextSpan( - text: 'minimal storage space.\nNo transparency ', - style: TextStyle(fontWeight: FontWeight.bold), - ), - TextSpan(text: 'is remembered.'), - ], - ); - @override Widget build(BuildContext context) { - late TextSpan infoText; - switch (format) { - case ImageFormat.png: - infoText = _pngInfo; - break; - case ImageFormat.jpg: - infoText = _jpgInfo; - break; - } return Row( children: [ const Icon(Icons.info_outline), const VerticalDivider(width: 8), Flexible( child: Text.rich( - infoText, + format.info, style: const TextStyle(fontSize: 11), ), ) diff --git a/lib/io/src/ui/load_image_dialog.dart b/lib/io/src/ui/load_image_dialog.dart new file mode 100644 index 00000000..0a43e115 --- /dev/null +++ b/lib/io/src/ui/load_image_dialog.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:paintroid/io/src/entity/image_location.dart'; + +Future showLoadImageDialog(BuildContext context) => + showGeneralDialog( + context: context, + pageBuilder: (_, __, ___) => const LoadImageDialog(), + barrierDismissible: true, + barrierLabel: "Dismiss load image dialog box"); + +class LoadImageDialog extends StatefulWidget { + const LoadImageDialog({Key? key}) : super(key: key); + + @override + State createState() => _LoadImageDialogState(); +} + +class _LoadImageDialogState extends State { + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text("Load image"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text("Where do you want to load the image from?"), + const SizedBox(height: 30), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [_photosButton, _filesButton], + ), + ], + ), + ); + } + + ElevatedButton get _photosButton { + return ElevatedButton( + onPressed: () => Navigator.of(context).pop(ImageLocation.photos), + child: const Text("Photos", style: TextStyle(color: Colors.white)), + ); + } + + ElevatedButton get _filesButton { + return ElevatedButton( + onPressed: () => Navigator.of(context).pop(ImageLocation.files), + child: const Text("Files", style: TextStyle(color: Colors.white)), + ); + } +} diff --git a/lib/io/src/ui/save_image_dialog.dart b/lib/io/src/ui/save_image_dialog.dart index 5546c94f..29d21d2e 100644 --- a/lib/io/src/ui/save_image_dialog.dart +++ b/lib/io/src/ui/save_image_dialog.dart @@ -24,18 +24,25 @@ class _SaveImageDialogState extends State { var imageQualityValue = 100; void _dismissDialogWithData() { - final data = ImageMetaData( - nameFieldController.text, - selectedFormat, - imageQualityValue, - ); + late ImageMetaData data; + switch (selectedFormat) { + case ImageFormat.png: + data = PngMetaData(nameFieldController.text); + break; + case ImageFormat.jpg: + data = JpgMetaData(nameFieldController.text, imageQualityValue); + break; + case ImageFormat.catrobatImage: + data = CatrobatImageMetaData(nameFieldController.text); + break; + } Navigator.of(context).pop(data); } @override Widget build(BuildContext context) { return AlertDialog( - title: const Text("Save Image"), + title: const Text("Save image"), actions: [_cancelButton, _saveButton], contentTextStyle: Theme.of(context).textTheme.bodyLarge, content: Form( diff --git a/lib/io/src/usecase/load_image_from_file_manager.dart b/lib/io/src/usecase/load_image_from_file_manager.dart new file mode 100644 index 00000000..4031f5fc --- /dev/null +++ b/lib/io/src/usecase/load_image_from_file_manager.dart @@ -0,0 +1,71 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:paintroid/command/command.dart'; +import 'package:paintroid/core/failure.dart'; +import 'package:paintroid/core/loggable_mixin.dart'; +import 'package:paintroid/io/io.dart'; + +extension on File { + String? get extension { + final list = path.split("."); + if (list.isEmpty) return null; + return list.last; + } +} + +class LoadImageFromFileManager with LoggableMixin { + final IFileService fileService; + final IImageService imageService; + final CommandManager commandManager; + final CatrobatImageSerializer catrobatImageSerializer; + + LoadImageFromFileManager(this.fileService, this.imageService, + this.commandManager, this.catrobatImageSerializer); + + static final provider = Provider((ref) { + final imageService = ref.watch(IImageService.provider); + final fileService = ref.watch(IFileService.provider); + final commandManager = ref.watch(CommandManager.provider); + const ver = CatrobatImage.latestVersion; + final serializer = ref.watch(CatrobatImageSerializer.provider(ver)); + return LoadImageFromFileManager( + fileService, imageService, commandManager, serializer); + }); + + TaskEither prepareTask() => + fileService.pick().flatMap((file) { + try { + switch (file.extension) { + case "jpg": + case "jpeg": + case "png": + return imageService.import(file.readAsBytesSync()).flatMap((img) { + commandManager.resetHistory(); + return TaskEither.right(img); + }); + case "catrobat-image": + final image = + catrobatImageSerializer.fromBytes(file.readAsBytesSync()); + final task = + image.loadedImage != null && image.loadedImage!.isNotEmpty + ? imageService.import(image.loadedImage!) + : TaskEither.right(null); + return task.flatMap((img) { + commandManager.resetHistory(newCommands: image.commands); + return TaskEither.right(img); + }); + default: + return TaskEither.left(LoadImageFailure.invalidImage); + } + } on FileSystemException catch (err, stacktrace) { + logger.severe("Failed to read file", err, stacktrace); + return TaskEither.left(LoadImageFailure.invalidImage); + } catch (err, stacktrace) { + logger.severe("Could not load image", err, stacktrace); + return TaskEither.left(LoadImageFailure.unidentified); + } + }); +} diff --git a/lib/io/src/usecase/load_image.dart b/lib/io/src/usecase/load_image_from_photo_library.dart similarity index 78% rename from lib/io/src/usecase/load_image.dart rename to lib/io/src/usecase/load_image_from_photo_library.dart index 08393ba6..1db61f8b 100644 --- a/lib/io/src/usecase/load_image.dart +++ b/lib/io/src/usecase/load_image_from_photo_library.dart @@ -7,16 +7,16 @@ import 'package:paintroid/core/failure.dart'; import '../service/image_service.dart'; import '../service/photo_library_service.dart'; -class LoadImage { +class LoadImageFromPhotoLibrary { final IImageService imageService; final IPhotoLibraryService photoLibraryService; - const LoadImage(this.imageService, this.photoLibraryService); + const LoadImageFromPhotoLibrary(this.imageService, this.photoLibraryService); static final provider = Provider((ref) { final imageService = ref.watch(IImageService.provider); final photoLibraryService = ref.watch(IPhotoLibraryService.provider); - return LoadImage(imageService, photoLibraryService); + return LoadImageFromPhotoLibrary(imageService, photoLibraryService); }); TaskEither prepareTask() => photoLibraryService diff --git a/lib/io/src/usecase/save_as_catrobat_image.dart b/lib/io/src/usecase/save_as_catrobat_image.dart new file mode 100644 index 00000000..a298051c --- /dev/null +++ b/lib/io/src/usecase/save_as_catrobat_image.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:paintroid/core/failure.dart'; +import 'package:paintroid/core/loggable_mixin.dart'; +import 'package:paintroid/io/io.dart' + show + CatrobatImage, + CatrobatImageMetaData, + CatrobatImageSerializer, + IFileService, + SaveImageFailure; + +class SaveAsCatrobatImage with LoggableMixin { + final IFileService _fileService; + final CatrobatImageSerializer _catrobatImageSerializer; + + SaveAsCatrobatImage(this._fileService, this._catrobatImageSerializer); + + static final provider = Provider((ref) { + final fileService = ref.watch(IFileService.provider); + const ver = CatrobatImage.latestVersion; + final serializer = ref.watch(CatrobatImageSerializer.provider(ver)); + return SaveAsCatrobatImage(fileService, serializer); + }); + + TaskEither prepareTask( + CatrobatImageMetaData data, CatrobatImage image) { + final nameWithExt = "${data.name}.${data.format.extension}"; + try { + final bytes = _catrobatImageSerializer.toBytes(image); + return _fileService.save(nameWithExt, bytes); + } catch (err, stacktrace) { + logger.severe("Failed to serialize CatrobatImage object", err, stacktrace); + return TaskEither.left(SaveImageFailure.unidentified); + } + } +} diff --git a/lib/io/src/usecase/save_as_raster_image.dart b/lib/io/src/usecase/save_as_raster_image.dart new file mode 100644 index 00000000..751a06e6 --- /dev/null +++ b/lib/io/src/usecase/save_as_raster_image.dart @@ -0,0 +1,31 @@ +import 'dart:ui'; + +import 'package:flutter_riverpod/flutter_riverpod.dart' show Provider; +import 'package:fpdart/fpdart.dart' show TaskEither, Unit; +import 'package:paintroid/core/failure.dart'; +import 'package:paintroid/io/io.dart'; + +class SaveAsRasterImage { + final IImageService imageService; + final IPhotoLibraryService photoLibraryService; + + const SaveAsRasterImage(this.imageService, this.photoLibraryService); + + static final provider = Provider((ref) { + final imageService = ref.watch(IImageService.provider); + final photoLibraryService = ref.watch(IPhotoLibraryService.provider); + return SaveAsRasterImage(imageService, photoLibraryService); + }); + + TaskEither prepareTaskForJpg(JpgMetaData data, Image image) { + final nameWithExt = "${data.name}.${data.format.extension}"; + return imageService.exportAsJpg(image, data.quality).flatMap( + (imageBytes) => photoLibraryService.save(nameWithExt, imageBytes)); + } + + TaskEither prepareTaskForPng(PngMetaData data, Image image) { + final nameWithExt = "${data.name}.${data.format.extension}"; + return imageService.exportAsPng(image).flatMap( + (imageBytes) => photoLibraryService.save(nameWithExt, imageBytes)); + } +} diff --git a/lib/io/src/usecase/save_image.dart b/lib/io/src/usecase/save_image.dart deleted file mode 100644 index 58bd59d4..00000000 --- a/lib/io/src/usecase/save_image.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:fpdart/fpdart.dart'; -import 'package:paintroid/core/failure.dart'; - -import '../entity/image_format.dart'; -import '../service/image_service.dart'; -import '../service/photo_library_service.dart'; - -class SaveImage { - final IImageService imageService; - final IPhotoLibraryService photoLibraryService; - - const SaveImage(this.imageService, this.photoLibraryService); - - static final provider = Provider((ref) { - final imageService = ref.watch(IImageService.provider); - final photoLibraryService = ref.watch(IPhotoLibraryService.provider); - return SaveImage(imageService, photoLibraryService); - }); - - TaskEither prepareTask({ - required ImageMetaData metaData, - required Image image, - }) { - final nameWithExt = "${metaData.name}.${metaData.format.extension}"; - switch (metaData.format) { - case ImageFormat.png: - return imageService.exportAsPng(image).flatMap( - (imageBytes) => photoLibraryService.save(nameWithExt, imageBytes)); - case ImageFormat.jpg: - return imageService.exportAsJpg(image, metaData.quality).flatMap( - (imageBytes) => photoLibraryService.save(nameWithExt, imageBytes)); - } - } -} - -@immutable -class ImageMetaData { - final String name; - final ImageFormat format; - - /// From 1-100 - final int quality; - - const ImageMetaData(this.name, this.format, this.quality); - - @override - String toString() => "$name.${format.extension}"; -} diff --git a/lib/main.dart b/lib/main.dart index d674921a..435a0736 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,7 +5,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_styled_toast/flutter_styled_toast.dart'; import 'package:logging/logging.dart'; import 'package:paintroid/ui/color_schemes.dart'; -import 'package:paintroid/workspace/workspace.dart'; import 'ui/pocket_paint.dart'; @@ -20,15 +19,7 @@ void main() async { error: record.error, stackTrace: record.stackTrace); }); - final container = ProviderContainer(); - runApp( - UncontrolledProviderScope( - container: container, - child: const PocketPaintApp(), - ), - ); - final size = await container.read(DrawCanvas.sizeProvider.future); - container.read(CanvasState.provider.notifier).updateCanvasSize(size); + runApp(const ProviderScope(child: PocketPaintApp())); } class PocketPaintApp extends StatelessWidget { diff --git a/lib/tool/src/tool_state.dart b/lib/tool/src/tool_state.dart index 37106f2e..5d042b8e 100644 --- a/lib/tool/src/tool_state.dart +++ b/lib/tool/src/tool_state.dart @@ -1,6 +1,5 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:paintroid/workspace/workspace.dart'; import 'brush_tool.dart'; import 'tool.dart'; @@ -17,7 +16,6 @@ class ToolState { currentTool: ref.watch(BrushTool.provider), isDown: false, ), - ref.watch(CanvasState.provider.notifier), ), ); diff --git a/lib/tool/src/tool_state_notifier.dart b/lib/tool/src/tool_state_notifier.dart index 0429bec3..b2e3e399 100644 --- a/lib/tool/src/tool_state_notifier.dart +++ b/lib/tool/src/tool_state_notifier.dart @@ -1,14 +1,11 @@ import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:paintroid/workspace/workspace.dart'; import 'tool_state.dart'; class ToolStateNotifier extends StateNotifier { - ToolStateNotifier(super.state, this._canvasStateNotifier); - - final CanvasStateNotifier _canvasStateNotifier; + ToolStateNotifier(super.state); void didTapDown(Offset position) { state.currentTool.onDown(position); @@ -21,7 +18,6 @@ class ToolStateNotifier extends StateNotifier { void didTapUp({Offset? position}) { state.currentTool.onUp(position); - _canvasStateNotifier.updateLastCompiledImage(); state = state.copyWith(isDown: false); } } diff --git a/lib/ui/bottom_control_navigation_bar.dart b/lib/ui/bottom_control_navigation_bar.dart index 603cb633..363d515f 100644 --- a/lib/ui/bottom_control_navigation_bar.dart +++ b/lib/ui/bottom_control_navigation_bar.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; class BottomControlNavigationBar extends StatelessWidget { + static const height = 64.0; + const BottomControlNavigationBar({Key? key}) : super(key: key); @override @@ -14,7 +16,7 @@ class BottomControlNavigationBar extends StatelessWidget { ), ), child: NavigationBar( - height: 64, + height: height, destinations: [ const NavigationDestination( label: "Tools", diff --git a/lib/ui/io_handler.dart b/lib/ui/io_handler.dart new file mode 100644 index 00000000..c7b77b6e --- /dev/null +++ b/lib/ui/io_handler.dart @@ -0,0 +1,121 @@ +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_styled_toast/flutter_styled_toast.dart'; +import 'package:fpdart/fpdart.dart' show TaskEither, Unit; +import 'package:paintroid/command/command.dart' show CommandManager; +import 'package:paintroid/core/failure.dart'; +import 'package:paintroid/io/io.dart'; +import 'package:paintroid/io/src/entity/image_location.dart'; +import 'package:paintroid/io/src/usecase/load_image_from_file_manager.dart'; +import 'package:paintroid/workspace/src/state/canvas_dirty_state.dart'; +import 'package:paintroid/workspace/workspace.dart'; + +class IOHandler { + final Ref ref; + + const IOHandler(this.ref); + + static final provider = Provider((ref) => IOHandler(ref)); + + void loadImage(ImageLocation location) async { + switch (location) { + case ImageLocation.photos: + await _loadFromPhotos(); + break; + case ImageLocation.files: + await _loadFromFiles(); + break; + } + } + + Future _loadFromPhotos() async { + final loadImage = ref.read(LoadImageFromPhotoLibrary.provider); + final result = await loadImage.prepareTask().run(); + result.fold( + (failure) { + if (failure != LoadImageFailure.userCancelled) { + showToast(failure.message); + } + }, + (img) async { + ref.read(CanvasState.provider.notifier).clearLastRenderedImage(); + ref.read(CommandManager.provider).resetHistory(); + ref.read(WorkspaceState.provider.notifier).loadImage(img); + + }, + ); + } + + Future _loadFromFiles() async { + final loadImage = ref.read(LoadImageFromFileManager.provider); + final result = await loadImage.prepareTask().run(); + result.fold( + (failure) { + if (failure != LoadImageFailure.userCancelled) { + showToast(failure.message); + } + }, + (img) async { + ref.read(CanvasState.provider.notifier).clearLastRenderedImage(); + final workspaceNotifier = ref.read(WorkspaceState.provider.notifier); + img == null + ? workspaceNotifier.clearLoadedImage() + : workspaceNotifier.loadImage(img); + ref + .read(CanvasState.provider.notifier) + .renderAndReplaceImageWithAllCommands(); + ref.read(CanvasDirtyState.provider.notifier).repaint(); + }, + ); + } + + void saveImage(ImageMetaData imageData) async { + if (imageData is JpgMetaData || imageData is PngMetaData) { + await _saveAsRasterImage(imageData); + } else if (imageData is CatrobatImageMetaData) { + await _saveAsCatrobatImage(imageData); + } + } + + Future _saveAsRasterImage(ImageMetaData imageData) async { + final image = await ref.read(RenderImageForExport.provider).call(); + final saveAsRasterImage = ref.read(SaveAsRasterImage.provider); + late final TaskEither task; + if (imageData is JpgMetaData) { + task = saveAsRasterImage.prepareTaskForJpg(imageData, image); + } else if (imageData is PngMetaData) { + task = saveAsRasterImage.prepareTaskForPng(imageData, image); + } + (await task.run()).fold( + (failure) => showToast(failure.message), + (_) => showToast("Saved to Photos"), + ); + } + + Future _saveAsCatrobatImage(CatrobatImageMetaData imageData) async { + final commands = ref.read(CommandManager.provider).commands; + final loadedImage = ref.read(WorkspaceState.provider).loadedImage; + final imageService = ref.read(IImageService.provider); + Uint8List? bytes; + if (loadedImage != null) { + final result = await imageService.export(loadedImage).run(); + bytes = result.fold( + (failure) { + showToast(failure.message); + return null; + }, + (imageBytes) => imageBytes, + ); + if (bytes == null) return; + } + final catrobatImage = CatrobatImage(commands, bytes); + final saveAsCatrobatImage = ref.read(SaveAsCatrobatImage.provider); + final result = + await saveAsCatrobatImage.prepareTask(imageData, catrobatImage).run(); + result.fold( + (failure) => showToast(failure.message), + (file) => showToast("Saved successfully"), + ); + } +} diff --git a/lib/ui/overflow_menu.dart b/lib/ui/overflow_menu.dart index fa510100..a400b977 100644 --- a/lib/ui/overflow_menu.dart +++ b/lib/ui/overflow_menu.dart @@ -1,7 +1,10 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_styled_toast/flutter_styled_toast.dart'; import 'package:paintroid/io/io.dart'; +import 'package:paintroid/io/src/entity/image_location.dart'; +import 'package:paintroid/ui/io_handler.dart'; import 'package:paintroid/workspace/workspace.dart'; enum OverflowMenuOption { @@ -24,6 +27,7 @@ class OverflowMenu extends ConsumerStatefulWidget { class _OverflowMenuState extends ConsumerState { @override Widget build(BuildContext context) { + final ioHandler = ref.watch(IOHandler.provider); return PopupMenuButton( color: Theme.of(context).colorScheme.background, icon: const Icon(Icons.more_vert), @@ -31,16 +35,24 @@ class _OverflowMenuState extends ConsumerState { side: const BorderSide(), borderRadius: BorderRadius.circular(20), ), - onSelected: (option) { + onSelected: (option) async { switch (option) { case OverflowMenuOption.fullscreen: _enterFullscreen(); break; case OverflowMenuOption.saveImage: - _saveImage(); + final imageData = await showSaveImageDialog(context); + if (imageData == null) return; // User cancelled + ioHandler.saveImage(imageData); break; case OverflowMenuOption.loadImage: - _loadImage(); + if (Platform.isIOS) { + final location = await showLoadImageDialog(context); + if (location == null) return; + ioHandler.loadImage(location); + } else { + ioHandler.loadImage(ImageLocation.files); + } break; } }, @@ -52,43 +64,6 @@ class _OverflowMenuState extends ConsumerState { ); } - void _loadImage() async { - final loadImage = ref.read(LoadImage.provider); - final image = await loadImage.prepareTask().run(); - image.fold( - (failure) { - if (failure != LoadImageFailure.userCancelled) { - showToast(failure.message); - } - }, - (img) async { - ref.read(WorkspaceState.provider.notifier).loadImage(img); - final size = await ref.read(DrawCanvas.sizeProvider.future); - ref.read(CanvasState.provider.notifier).updateCanvasSize(size); - }, - ); - } - - void _saveImage() async { - final imageData = await showSaveImageDialog(context); - if (imageData == null) return; - final saveImage = ref.read(SaveImage.provider); - final scaleImage = ref.read(ScaleImage.provider); - final canvasSize = await ref.read(DrawCanvas.sizeProvider.future); - final workspaceState = ref.read(WorkspaceState.provider); - final exportSize = workspaceState.exportSize; - final loadedImage = workspaceState.loadedImage; - final scaledImage = - await scaleImage.call(canvasSize, exportSize, loadedImage); - final either = await saveImage - .prepareTask(metaData: imageData, image: scaledImage) - .run(); - either.fold( - (failure) => showToast(failure.message), - (_) => showToast("Saved to Photos"), - ); - } - void _enterFullscreen() => ref.read(WorkspaceState.provider.notifier).toggleFullscreen(true); } diff --git a/lib/ui/pocket_paint.dart b/lib/ui/pocket_paint.dart index f9e8793f..b5f86648 100644 --- a/lib/ui/pocket_paint.dart +++ b/lib/ui/pocket_paint.dart @@ -25,13 +25,14 @@ class PocketPaint extends ConsumerWidget { WorkspaceState.provider.select((state) => state.isFullscreen), (_, isFullscreen) => _toggleStatusBar(isFullscreen), ); + final canvasWidth = + ref.watch(CanvasState.provider.select((value) => value.size.width)); + final scale = canvasWidth / (MediaQuery.of(context).size.width + 24); return WillPopScope( onWillPop: () async { final willPop = !isFullscreen; if (isFullscreen) { - ref - .read(WorkspaceState.provider.notifier) - .toggleFullscreen(false); + ref.read(WorkspaceState.provider.notifier).toggleFullscreen(false); } return willPop; }, @@ -41,7 +42,12 @@ class PocketPaint extends ConsumerWidget { body: SafeArea( child: Stack( children: [ - const Center(child: Workspace()), + Center( + child: Transform.scale( + scale: scale, + child: const DrawingCanvas(), + ), + ), if (isFullscreen) const Positioned( top: 2, diff --git a/lib/ui/top_app_bar.dart b/lib/ui/top_app_bar.dart index 429e7797..1948d55f 100644 --- a/lib/ui/top_app_bar.dart +++ b/lib/ui/top_app_bar.dart @@ -8,6 +8,6 @@ class TopAppBar extends AppBar { key: key, title: Text(title), centerTitle: false, - actions: const [OverflowMenu()], + actions: [const OverflowMenu()], ); } diff --git a/lib/workspace/src/state/canvas_dirty_state.dart b/lib/workspace/src/state/canvas_dirty_state.dart new file mode 100644 index 00000000..4d19c4aa --- /dev/null +++ b/lib/workspace/src/state/canvas_dirty_state.dart @@ -0,0 +1,10 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class CanvasDirtyState extends ChangeNotifier { + static final provider = ChangeNotifierProvider( + (ref) => CanvasDirtyState(), + ); + + void repaint() => notifyListeners(); +} diff --git a/lib/workspace/src/state/canvas_state.dart b/lib/workspace/src/state/canvas_state.dart index 54b87b39..8aa3e895 100644 --- a/lib/workspace/src/state/canvas_state.dart +++ b/lib/workspace/src/state/canvas_state.dart @@ -2,31 +2,33 @@ part of 'canvas_state_notifier.dart'; @immutable class CanvasState { - final Image? lastCompiledImage; + final Image? lastRenderedImage; final Size size; static const initial = CanvasState(size: Size.zero); static final provider = StateNotifierProvider( - (ref) => CanvasStateNotifier( - initial, - ref.watch(CommandManager.provider), - ref.watch(GraphicFactory.provider), - ), + (ref) { + return CanvasStateNotifier( + initial, + ref.watch(CommandManager.provider), + ref.watch(GraphicFactory.provider), + ); + }, ); const CanvasState({ - this.lastCompiledImage, + this.lastRenderedImage, required this.size, }); CanvasState copyWith({ - Image? lastCompiledImage, + Image? lastRenderedImage, Size? size, }) { return CanvasState( - lastCompiledImage: lastCompiledImage ?? this.lastCompiledImage, + lastRenderedImage: lastRenderedImage ?? this.lastRenderedImage, size: size ?? this.size, ); } diff --git a/lib/workspace/src/state/canvas_state_notifier.dart b/lib/workspace/src/state/canvas_state_notifier.dart index eb04b55d..d2389f8e 100644 --- a/lib/workspace/src/state/canvas_state_notifier.dart +++ b/lib/workspace/src/state/canvas_state_notifier.dart @@ -1,7 +1,9 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart' + show StateNotifier, StateNotifierProvider; import 'package:paintroid/command/command.dart'; import 'package:paintroid/core/graphic_factory.dart'; @@ -15,17 +17,32 @@ class CanvasStateNotifier extends StateNotifier { void updateCanvasSize(Size newSize) => state = state.copyWith(size: newSize); - void updateLastCompiledImage() async { + void renderImageWithLastCommand() async { final recorder = _graphicFactory.createPictureRecorder(); final canvas = _graphicFactory.createCanvasWithRecorder(recorder); - final paint = _graphicFactory.createPaint(); - if (state.lastCompiledImage != null) { - canvas.drawImage(state.lastCompiledImage!, Offset.zero, paint); + final bounds = Rect.fromLTWH(0, 0, state.size.width, state.size.height); + canvas.clipRect(bounds); + if (state.lastRenderedImage != null) { + paintImage(canvas: canvas, rect: bounds, image: state.lastRenderedImage!); } _commandManager.executeLastCommand(canvas); final picture = recorder.endRecording(); final image = await picture.toImage( state.size.width.toInt(), state.size.height.toInt()); - state = state.copyWith(lastCompiledImage: image); + state = state.copyWith(lastRenderedImage: image); + } + + void clearLastRenderedImage() => + state = state.copyWith(lastRenderedImage: null); + + void renderAndReplaceImageWithAllCommands() async { + final recorder = _graphicFactory.createPictureRecorder(); + final canvas = _graphicFactory.createCanvasWithRecorder(recorder); + canvas.clipRect(Rect.fromLTWH(0, 0, state.size.width, state.size.height)); + _commandManager.executeAllCommands(canvas); + final picture = recorder.endRecording(); + final image = await picture.toImage( + state.size.width.toInt(), state.size.height.toInt()); + state = state.copyWith(lastRenderedImage: image); } } diff --git a/lib/workspace/src/state/workspace_state.dart b/lib/workspace/src/state/workspace_state.dart index 5f960686..4fc6ff23 100644 --- a/lib/workspace/src/state/workspace_state.dart +++ b/lib/workspace/src/state/workspace_state.dart @@ -4,12 +4,11 @@ part of 'workspace_state_notifier.dart'; class WorkspaceState { final bool isFullscreen; final Size exportSize; - final double aspectRatio; final Image? loadedImage; - static final initial = WorkspaceState( + static const initial = WorkspaceState( isFullscreen: false, - exportSize: const Size(1080, 1920), + exportSize: Size(1080, 1920), ); static final provider = @@ -17,11 +16,11 @@ class WorkspaceState { (ref) => WorkspaceStateNotifier(initial), ); - WorkspaceState({ + const WorkspaceState({ required this.isFullscreen, this.loadedImage, required this.exportSize, - }) : aspectRatio = exportSize.width / exportSize.height; + }); WorkspaceState copyWith({ bool? isFullscreen, diff --git a/lib/workspace/src/state/workspace_state_notifier.dart b/lib/workspace/src/state/workspace_state_notifier.dart index 86494bec..a2680626 100644 --- a/lib/workspace/src/state/workspace_state_notifier.dart +++ b/lib/workspace/src/state/workspace_state_notifier.dart @@ -15,4 +15,9 @@ class WorkspaceStateNotifier extends StateNotifier { loadedImage: image, exportSize: Size(image.width.toDouble(), image.height.toDouble()), ); + + void clearLoadedImage() => state = state.copyWith( + loadedImage: null, + exportSize: WorkspaceState.initial.exportSize, + ); } diff --git a/lib/workspace/src/ui/canvas_painter.dart b/lib/workspace/src/ui/canvas_painter.dart new file mode 100644 index 00000000..780d9cef --- /dev/null +++ b/lib/workspace/src/ui/canvas_painter.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:paintroid/command/command.dart'; + +import '../state/canvas_dirty_state.dart'; +import '../state/canvas_state_notifier.dart'; +import '../state/workspace_state_notifier.dart'; +import 'command_painter.dart'; +import 'transparency_grid_pattern.dart'; + +class CanvasPainter extends ConsumerWidget { + const CanvasPainter({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final loadedImage = ref.watch( + WorkspaceState.provider.select((value) => value.loadedImage), + ); + return Stack( + fit: StackFit.expand, + children: [ + RepaintBoundary( + child: TransparencyGridPattern( + numberOfSquaresAlongWidth: 100, + child: loadedImage != null + ? RawImage(image: loadedImage, fit: BoxFit.fill) + : null, + ), + ), + Consumer( + builder: (context, ref, child) { + ref.watch(CanvasDirtyState.provider); + final canvasImage = ref.watch( + CanvasState.provider.select((value) => value.lastRenderedImage), + ); + return CustomPaint( + foregroundPainter: + CommandPainter(ref.watch(CommandManager.provider)), + child: canvasImage != null + ? RawImage(image: canvasImage, fit: BoxFit.fill) + : null, + ); + }, + ), + ], + ); + } +} diff --git a/lib/workspace/src/ui/draw_canvas.dart b/lib/workspace/src/ui/draw_canvas.dart deleted file mode 100644 index 37c3fa58..00000000 --- a/lib/workspace/src/ui/draw_canvas.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:paintroid/command/command.dart'; - -import '../state/canvas_state_notifier.dart'; -import '../state/workspace_state_notifier.dart'; -import 'command_painter.dart'; -import 'transparency_grid_pattern.dart'; - -class DrawCanvas extends ConsumerWidget { - const DrawCanvas({super.key}); - - static final _canvasKey = GlobalKey(debugLabel: "DrawCanvas"); - - static final sizeProvider = FutureProvider((ref) { - final completer = Completer(); - SchedulerBinding.instance.addPostFrameCallback((timeStamp) { - final ctx = _canvasKey.currentContext!; - final renderBox = ctx.findRenderObject() as RenderBox; - completer.complete(renderBox.size); - }); - return completer.future; - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final loadedImage = ref.watch( - WorkspaceState.provider.select((value) => value.loadedImage), - ); - final canvasImage = ref.watch( - CanvasState.provider.select((value) => value.lastCompiledImage), - ); - return Stack( - fit: StackFit.expand, - children: [ - RepaintBoundary( - child: TransparencyGridPattern( - numberOfSquaresAlongWidth: 100, - child: loadedImage != null - ? RawImage(image: loadedImage, fit: BoxFit.fill) - : null, - ), - ), - CustomPaint( - key: _canvasKey, - foregroundPainter: CommandPainter(ref.watch(CommandManager.provider)), - child: canvasImage != null - ? RawImage(image: canvasImage, fit: BoxFit.fill) - : null, - ), - ], - ); - } -} diff --git a/lib/workspace/src/ui/drawing_canvas.dart b/lib/workspace/src/ui/drawing_canvas.dart new file mode 100644 index 00000000..d2245842 --- /dev/null +++ b/lib/workspace/src/ui/drawing_canvas.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:paintroid/tool/tool.dart'; +import 'package:paintroid/workspace/src/state/canvas_state_notifier.dart'; +import 'package:paintroid/workspace/src/state/workspace_state_notifier.dart'; + +import '../state/canvas_dirty_state.dart'; +import 'canvas_painter.dart'; + +class DrawingCanvas extends ConsumerStatefulWidget { + const DrawingCanvas({Key? key}) : super(key: key); + + @override + ConsumerState createState() => _DrawingCanvasState(); +} + +class _DrawingCanvasState extends ConsumerState + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final renderBox = context.findRenderObject() as RenderBox; + ref.read(CanvasState.provider.notifier).updateCanvasSize(renderBox.size); + }); + } + + @override + Widget build(BuildContext context) { + final toolStateNotifier = ref.watch(ToolState.provider.notifier); + final canvasStateNotifier = ref.watch(CanvasState.provider.notifier); + final canvasDirtyNotifier = + ref.watch(CanvasDirtyState.provider.notifier); + ref.listen(WorkspaceState.provider, (previous, next) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final renderBox = context.findRenderObject() as RenderBox; + ref.read(CanvasState.provider.notifier).updateCanvasSize(renderBox.size); + }); + }); + return AspectRatio( + aspectRatio: ref.watch( + WorkspaceState.provider.select((value) => value.exportSize.aspectRatio), + ), + child: DecoratedBox( + decoration: const BoxDecoration( + border: Border.fromBorderSide(BorderSide(width: 0.5)), + ), + position: DecorationPosition.foreground, + child: GestureDetector( + onPanDown: (details) { + final box = context.findRenderObject() as RenderBox; + toolStateNotifier + .didTapDown(box.globalToLocal(details.globalPosition)); + }, + onPanUpdate: (details) { + final box = context.findRenderObject() as RenderBox; + toolStateNotifier + .didDrag(box.globalToLocal(details.globalPosition)); + canvasDirtyNotifier.repaint(); + }, + onPanEnd: (_) { + toolStateNotifier.didTapUp(); + canvasStateNotifier.renderImageWithLastCommand(); + }, + child: const CanvasPainter(), + ), + ), + ); + } +} diff --git a/lib/workspace/src/ui/workspace.dart b/lib/workspace/src/ui/workspace.dart deleted file mode 100644 index e6a71b1f..00000000 --- a/lib/workspace/src/ui/workspace.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:paintroid/tool/tool.dart'; - -import '../state/workspace_state_notifier.dart'; -import 'draw_canvas.dart'; - -class Workspace extends ConsumerStatefulWidget { - const Workspace({Key? key}) : super(key: key); - - @override - ConsumerState createState() => _WorkspaceState(); -} - -class _WorkspaceState extends ConsumerState { - @override - Widget build(BuildContext context) { - final toolStateNotifier = ref.watch(ToolState.provider.notifier); - return Container( - margin: const EdgeInsets.all(24), - decoration: const BoxDecoration( - border: Border.fromBorderSide(BorderSide(width: 0.5)), - ), - child: AspectRatio( - aspectRatio: ref.watch( - WorkspaceState.provider.select((state) => state.aspectRatio), - ), - child: GestureDetector( - onPanDown: (details) { - setState(() => toolStateNotifier.didTapDown(details.localPosition)); - }, - onPanUpdate: (details) { - setState(() => toolStateNotifier.didDrag(details.localPosition)); - }, - onPanEnd: (_) { - setState(() => toolStateNotifier.didTapUp()); - }, - // Cannot be const because it needs to be rebuilt on every setState call - // ignore: prefer_const_constructors - child: DrawCanvas(), - ), - ), - ); - } -} diff --git a/lib/workspace/src/usecase/render_image_for_export.dart b/lib/workspace/src/usecase/render_image_for_export.dart new file mode 100644 index 00000000..f875d244 --- /dev/null +++ b/lib/workspace/src/usecase/render_image_for_export.dart @@ -0,0 +1,48 @@ +import 'dart:ui'; + +import 'package:flutter/painting.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:paintroid/command/command.dart'; +import 'package:paintroid/core/graphic_factory.dart'; +import 'package:paintroid/workspace/workspace.dart'; + +class RenderImageForExport { + final Ref _ref; + final GraphicFactory _graphicFactory; + final CommandManager _commandManager; + + static final provider = Provider( + (ref) => RenderImageForExport( + ref, + ref.watch(GraphicFactory.provider), + ref.watch(CommandManager.provider), + ), + ); + + const RenderImageForExport( + this._ref, this._graphicFactory, this._commandManager); + + Future call() async { + final recorder = _graphicFactory.createPictureRecorder(); + final canvas = _graphicFactory.createCanvasWithRecorder(recorder); + final canvasSize = _ref.read(CanvasState.provider).size; + final workspaceState = _ref.read(WorkspaceState.provider); + final exportSize = workspaceState.exportSize; + final loadedImage = workspaceState.loadedImage; + final scaledRect = Rect.fromLTWH(0, 0, exportSize.width, exportSize.height); + if (loadedImage != null) { + paintImage( + canvas: canvas, + rect: scaledRect, + image: loadedImage, + fit: BoxFit.fill, + ); + } + canvas.scale(exportSize.width / canvasSize.width); + canvas.clipRect(scaledRect); + _commandManager.executeAllCommands(canvas); + final picture = recorder.endRecording(); + return await picture.toImage( + exportSize.width.toInt(), exportSize.height.toInt()); + } +} diff --git a/lib/workspace/src/usecase/scale_image.dart b/lib/workspace/src/usecase/scale_image.dart deleted file mode 100644 index 3761114b..00000000 --- a/lib/workspace/src/usecase/scale_image.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/painting.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:paintroid/command/command.dart'; -import 'package:paintroid/core/graphic_factory.dart'; - -class ScaleImage { - final GraphicFactory graphicFactory; - final CommandManager commandManager; - - static final provider = Provider( - (ref) => ScaleImage( - ref.watch(GraphicFactory.provider), - ref.watch(CommandManager.provider), - ), - ); - - const ScaleImage(this.graphicFactory, this.commandManager); - - Future call( - Size originalSize, Size scaledSize, Image? backgroundImage) async { - final recorder = graphicFactory.createPictureRecorder(); - final canvas = graphicFactory.createCanvasWithRecorder(recorder); - final scaledRect = Rect.fromLTWH(0, 0, scaledSize.width, scaledSize.height); - if (backgroundImage != null) { - paintImage( - canvas: canvas, - rect: scaledRect, - image: backgroundImage, - fit: BoxFit.fill, - ); - } - canvas.scale(scaledSize.width / originalSize.width); - canvas.clipRect(scaledRect); - commandManager.executeAllCommands(canvas); - final picture = recorder.endRecording(); - return await picture.toImage( - scaledSize.width.toInt(), scaledSize.height.toInt()); - } -} diff --git a/lib/workspace/workspace.dart b/lib/workspace/workspace.dart index 55172052..8307941b 100644 --- a/lib/workspace/workspace.dart +++ b/lib/workspace/workspace.dart @@ -1,5 +1,5 @@ export 'src/state/canvas_state_notifier.dart'; export 'src/state/workspace_state_notifier.dart'; -export 'src/ui/draw_canvas.dart'; -export 'src/ui/workspace.dart'; -export 'src/usecase/scale_image.dart'; \ No newline at end of file +export 'src/ui/canvas_painter.dart'; +export 'src/ui/drawing_canvas.dart'; +export 'src/usecase/render_image_for_export.dart'; \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 37753444..e79c5dfd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -189,7 +189,7 @@ packages: name: ffi url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "1.2.1" file: dependency: transitive description: @@ -197,6 +197,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.1.2" + file_picker: + dependency: "direct main" + description: + name: file_picker + url: "https://pub.dartlang.org" + source: hosted + version: "4.6.1" fixnum: dependency: transitive description: @@ -525,7 +532,7 @@ packages: name: path_provider_windows url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.0.7" petitparser: dependency: transitive description: @@ -733,7 +740,7 @@ packages: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "2.7.0" + version: "2.6.1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 48718086..82ce90bd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: image_picker: ^0.8.5+3 fpdart: ^0.1.0 logging: ^1.0.2 + file_picker: ^4.6.1 flutter_styled_toast: ^2.1.3 dev_dependencies: diff --git a/test/unit/io/service/file_service_test.dart b/test/unit/io/service/photo_library_service_test.dart similarity index 99% rename from test/unit/io/service/file_service_test.dart rename to test/unit/io/service/photo_library_service_test.dart index 20286636..b0252278 100644 --- a/test/unit/io/service/file_service_test.dart +++ b/test/unit/io/service/photo_library_service_test.dart @@ -9,7 +9,7 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:paintroid/io/io.dart'; -import 'file_service_test.mocks.dart'; +import 'photo_library_service_test.mocks.dart'; @GenerateMocks([ImagePicker, MethodChannel, XFile]) void main() { diff --git a/test/unit/io/usecase/load_image_test.dart b/test/unit/io/usecase/load_image_test.dart index 9eeaaca7..f50acac0 100644 --- a/test/unit/io/usecase/load_image_test.dart +++ b/test/unit/io/usecase/load_image_test.dart @@ -21,14 +21,14 @@ void main() { late Uint8List fakeBytes; late MockIImageService mockImageService; late MockIPhotoLibraryService mockPhotoLibraryService; - late LoadImage sut; + late LoadImageFromPhotoLibrary sut; setUp(() { fakeImage = FakeImage(); fakeBytes = Uint8List(12); mockImageService = MockIImageService(); mockPhotoLibraryService = MockIPhotoLibraryService(); - sut = LoadImage(mockImageService, mockPhotoLibraryService); + sut = LoadImageFromPhotoLibrary(mockImageService, mockPhotoLibraryService); }); test('Should provide LoadImage with correct dependencies', () { @@ -36,7 +36,7 @@ void main() { IImageService.provider.overrideWithValue(mockImageService), IPhotoLibraryService.provider.overrideWithValue(mockPhotoLibraryService), ]); - final loadImage = container.read(LoadImage.provider); + final loadImage = container.read(LoadImageFromPhotoLibrary.provider); expect(loadImage.imageService, mockImageService); expect(loadImage.photoLibraryService, mockPhotoLibraryService); verifyZeroInteractions(mockImageService); diff --git a/test/unit/io/usecase/save_image_test.dart b/test/unit/io/usecase/save_image_test.dart index bee67cac..4b8cc185 100644 --- a/test/unit/io/usecase/save_image_test.dart +++ b/test/unit/io/usecase/save_image_test.dart @@ -15,7 +15,7 @@ class FakeImage extends Fake implements Image {} class FakeFailure extends Fake implements Failure {} -@GenerateMocks([IImageService, IPhotoLibraryService]) +@GenerateMocks([IImageService, IPhotoLibraryService, IFileService]) void main() { late String testName; late int testQuality; @@ -23,7 +23,7 @@ void main() { late Uint8List fakeBytes; late MockIImageService mockImageService; late MockIPhotoLibraryService mockPhotoLibraryService; - late SaveImage sut; + late SaveAsRasterImage sut; setUp(() { testName = "testImageName"; @@ -32,12 +32,7 @@ void main() { fakeBytes = Uint8List(12); mockImageService = MockIImageService(); mockPhotoLibraryService = MockIPhotoLibraryService(); - sut = SaveImage(mockImageService, mockPhotoLibraryService); - }); - - test('Verify string representation of ImageMetaData', () async { - const testMetaData = ImageMetaData("image", ImageFormat.png, 23); - expect(testMetaData.toString(), "image.png"); + sut = SaveAsRasterImage(mockImageService, mockPhotoLibraryService); }); test('Should provide SaveImage with correct dependencies', () { @@ -45,7 +40,7 @@ void main() { IImageService.provider.overrideWithValue(mockImageService), IPhotoLibraryService.provider.overrideWithValue(mockPhotoLibraryService), ]); - final saveImage = container.read(SaveImage.provider); + final saveImage = container.read(SaveAsRasterImage.provider); expect(saveImage.imageService, mockImageService); expect(saveImage.photoLibraryService, mockPhotoLibraryService); verifyZeroInteractions(mockImageService); @@ -61,11 +56,9 @@ void main() { .thenReturn(TaskEither.right(fakeBytes)); when(mockPhotoLibraryService.save(any, any)) .thenReturn(TaskEither.right(unit)); - final testMetaData = - ImageMetaData(testName, ImageFormat.png, testQuality); - final result = await sut - .prepareTask(metaData: testMetaData, image: fakeImage) - .run(); + final testMetaData = PngMetaData(testName); + final result = + await sut.prepareTaskForPng(testMetaData, fakeImage).run(); expect(result, const Right(unit)); verify(mockImageService.exportAsPng(fakeImage)); verify(mockPhotoLibraryService.save(expectedFilename, fakeBytes)); @@ -79,11 +72,9 @@ void main() { .thenReturn(TaskEither.right(fakeBytes)); when(mockPhotoLibraryService.save(any, any)) .thenReturn(TaskEither.right(unit)); - final testMetaData = - ImageMetaData(testName, ImageFormat.jpg, testQuality); - final result = await sut - .prepareTask(metaData: testMetaData, image: fakeImage) - .run(); + final testMetaData = JpgMetaData(testName, testQuality); + final result = + await sut.prepareTaskForJpg(testMetaData, fakeImage).run(); expect(result, const Right(unit)); verify(mockImageService.exportAsJpg(fakeImage, testQuality)); verify(mockPhotoLibraryService.save(expectedFilename, fakeBytes)); @@ -108,11 +99,9 @@ void main() { .thenReturn(TaskEither.right(fakeBytes)); when(mockPhotoLibraryService.save(any, any)) .thenReturn(TaskEither.left(fakeFailure)); - final testMetaData = - ImageMetaData(testName, ImageFormat.jpg, testQuality); - final result = await sut - .prepareTask(metaData: testMetaData, image: fakeImage) - .run(); + final testMetaData = JpgMetaData(testName, testQuality); + final result = + await sut.prepareTaskForJpg(testMetaData, fakeImage).run(); expect(result, Left(fakeFailure)); verify(mockImageService.exportAsJpg(any, any)); verify(mockPhotoLibraryService.save(any, any)); @@ -125,11 +114,9 @@ void main() { .thenReturn(TaskEither.right(fakeBytes)); when(mockPhotoLibraryService.save(any, any)) .thenReturn(TaskEither.left(fakeFailure)); - final testMetaData = - ImageMetaData(testName, ImageFormat.png, testQuality); - final result = await sut - .prepareTask(metaData: testMetaData, image: fakeImage) - .run(); + final testMetaData = PngMetaData(testName); + final result = + await sut.prepareTaskForPng(testMetaData, fakeImage).run(); expect(result, Left(fakeFailure)); verify(mockImageService.exportAsPng(any)); verify(mockPhotoLibraryService.save(any, any)); @@ -142,11 +129,9 @@ void main() { test('When format is jpg', () async { when(mockImageService.exportAsJpg(any, any)) .thenReturn(TaskEither.left(fakeFailure)); - final testMetaData = - ImageMetaData(testName, ImageFormat.jpg, testQuality); - final result = await sut - .prepareTask(metaData: testMetaData, image: fakeImage) - .run(); + final testMetaData = JpgMetaData(testName, testQuality); + final result = + await sut.prepareTaskForJpg(testMetaData, fakeImage).run(); expect(result, Left(fakeFailure)); verify(mockImageService.exportAsJpg(any, any)); verifyNoMoreInteractions(mockImageService); @@ -156,11 +141,9 @@ void main() { test('When format is png', () async { when(mockImageService.exportAsPng(any)) .thenReturn(TaskEither.left(fakeFailure)); - final testMetaData = - ImageMetaData(testName, ImageFormat.png, testQuality); - final result = await sut - .prepareTask(metaData: testMetaData, image: fakeImage) - .run(); + final testMetaData = PngMetaData(testName); + final result = + await sut.prepareTaskForPng(testMetaData, fakeImage).run(); expect(result, Left(fakeFailure)); verify(mockImageService.exportAsPng(any)); verifyNoMoreInteractions(mockImageService);