Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/core/enums/image_format.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
enum ImageFormat {
png('png'),
jpg('jpg'),
ora('ora'),
catrobatImage('catrobat-image');

const ImageFormat(this.extension);
Expand Down
5 changes: 4 additions & 1 deletion lib/core/models/image_from_file.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import 'package:paintroid/core/models/catrobat_image.dart';
class ImageFromFile {
final Image? rasterImage;
final CatrobatImage? catrobatImage;
final List<Image>? oraImageLayers;

const ImageFromFile.catrobatImage(
CatrobatImage image, {
Image? backgroundImage,
}) : catrobatImage = image,
rasterImage = backgroundImage;
oraImageLayers = null,
rasterImage = backgroundImage;

const ImageFromFile.rasterImage(Image image)
: rasterImage = image,
oraImageLayers = null,
catrobatImage = null;
}
4 changes: 4 additions & 0 deletions lib/core/models/image_meta_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ class CatrobatImageMetaData extends ImageMetaData {
const CatrobatImageMetaData(String name)
: super(name, ImageFormat.catrobatImage);
}

class OraMetaData extends ImageMetaData {
const OraMetaData(String name) : super(name, ImageFormat.ora);
}
35 changes: 35 additions & 0 deletions lib/core/models/ora_image.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:image/image.dart' as img;
import 'package:archive/archive.dart';

class OraImage {
final int width;
final int height;
final List<img.Image> layers;
final String xmlMetadata;

OraImage({
required this.width,
required this.height,
required this.layers,
required this.xmlMetadata,
});

Uint8List toBytes() {
final archive = Archive();

for (int i = 0; i < layers.length; i++) {
final layer = layers[i];
final encoder = img.PngEncoder();
final layerData = encoder.encodeImage(layer);
archive.addFile(ArchiveFile('layer_$i.png', layerData.length, layerData));
}

final encodedXml = utf8.encode(xmlMetadata);
archive.addFile(ArchiveFile('stack.xml', encodedXml.length, encodedXml));

final zipEncoder = ZipEncoder();
return Uint8List.fromList(zipEncoder.encode(archive)!);
}
}
33 changes: 33 additions & 0 deletions lib/core/models/process_ora.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:archive/archive.dart';
import 'package:image/image.dart' as img;

class ProcessOra {
Future<List<ui.Image>> processOraFile(Archive archive) async {
List<ui.Image> layers = [];

for (var file in archive) {
if (file.isFile &&
(file.name.endsWith('.png') || file.name.endsWith('.jpg')) ||
file.name.endsWith('.ora')) {
img.Image? decodedImage = img.decodeImage(file.content as List<int>);

if (decodedImage != null) {
ui.Image layer = await convertImgImageToUiImage(decodedImage);
layers.add(layer);
}
}
}

return layers;
}

Future<ui.Image> convertImgImageToUiImage(img.Image image) async {
List<int> pngBytes = img.encodePng(image);

final codec = await ui.instantiateImageCodec(Uint8List.fromList(pngBytes));
final frame = await codec.getNextFrame();
return frame.image;
}
}
13 changes: 8 additions & 5 deletions lib/core/providers/object/file_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,16 @@ class FileService with LoggableMixin implements IFileService {
@override
Future<Result<File, Failure>> save(String filename, Uint8List data) async {
try {
final saveDirectory = await FilePicker.platform.getDirectoryPath();
if (saveDirectory == null) {
final savePath = await FilePicker.platform.saveFile(
dialogTitle: 'Save As',
fileName: filename,
bytes: data, // Required for Android/iOS
);
if (savePath == null) {
return const Result.err(SaveImageFailure.userCancelled);
}
final file =
await File('$saveDirectory/$filename').create(recursive: true);
return Result.ok(await file.writeAsBytes(data));
// File is already saved by file_picker
return Result.ok(File(savePath));
} catch (err, stacktrace) {
logger.severe('Could not save file', err, stacktrace);
return const Result.err(SaveImageFailure.unidentified);
Expand Down
61 changes: 61 additions & 0 deletions lib/core/providers/object/io_handler.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:image/image.dart' as img;

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
Expand All @@ -10,12 +12,14 @@ import 'package:paintroid/core/enums/image_format.dart';
import 'package:paintroid/core/enums/image_location.dart';
import 'package:paintroid/core/models/catrobat_image.dart';
import 'package:paintroid/core/models/image_meta_data.dart';
import 'package:paintroid/core/models/ora_image.dart';
import 'package:paintroid/core/providers/object/file_service.dart';
import 'package:paintroid/core/providers/object/image_service.dart';
import 'package:paintroid/core/providers/object/load_image_from_file_manager.dart';
import 'package:paintroid/core/providers/object/load_image_from_photo_library.dart';
import 'package:paintroid/core/providers/object/render_image_for_export.dart';
import 'package:paintroid/core/providers/object/save_as_catrobat_image.dart';
import 'package:paintroid/core/providers/object/save_as_ora_image.dart';
import 'package:paintroid/core/providers/object/save_as_raster_image.dart';
import 'package:paintroid/core/providers/state/app_bar_provider.dart';
import 'package:paintroid/core/providers/state/canvas_state_provider.dart';
Expand Down Expand Up @@ -178,10 +182,67 @@ class IOHandler {
} else if (imageData is CatrobatImageMetaData) {
final savedFile = await _saveAsCatrobatImage(imageData, false);
isImageSaved = (savedFile != null);
} else if (imageData is OraMetaData) {
isImageSaved = await _saveAsOraImage(imageData);
}
return isImageSaved;
}

Future<img.Image> convertUiImageToImgImage(ui.Image uiImage) async {
final byteData =
await uiImage.toByteData(format: ui.ImageByteFormat.rawRgba);
final buffer = byteData!.buffer.asUint8List();

return img.Image.fromBytes(
uiImage.width,
uiImage.height,
buffer,
format: img.Format.rgba,
);
}

String generateXmlMetadataForOra(List<img.Image> layers) {
var buffer = StringBuffer();
buffer.writeln('<image>');

for (int i = 0; i < layers.length; i++) {
buffer.writeln(
'<layer name="Layer $i" src="data/layer_$i.png" x="0" y="0" opacity="1.0"/>');
}

buffer.writeln('</image>');
return buffer.toString();
}

Future<bool> _saveAsOraImage(OraMetaData imageData) async {
final canvasState = ref.read(canvasStateProvider);
final oraImageService = ref.read(SaveAsOraImage.provider);

if (canvasState.cachedImage == null) {
return false;
}

final imgWidth = canvasState.size.width.toInt();
final imgHeight = canvasState.size.height.toInt();

img.Image layer = await convertUiImageToImgImage(canvasState.cachedImage!);

final oraImage = OraImage(
width: imgWidth,
height: imgHeight,
layers: [layer],
xmlMetadata: generateXmlMetadataForOra([layer]),
);

final fileName = '${imageData.name}.ora';
final result = await oraImageService.call(oraImage, fileName);

return result.match(
(file) => true,
(error) => false,
);
}

Future<bool> _saveAsRasterImage(ImageMetaData imageData) async {
final image = await ref
.read(RenderImageForExport.provider)
Expand Down
19 changes: 16 additions & 3 deletions lib/core/providers/object/load_image_from_file_manager.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui';
import 'dart:ui' as ui;
import 'package:archive/archive.dart';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:oxidized/oxidized.dart';

import 'package:paintroid/core/models/catrobat_image.dart';
import 'package:paintroid/core/models/image_from_file.dart';
import 'package:paintroid/core/models/loggable_mixin.dart';
import 'package:paintroid/core/models/process_ora.dart';
import 'package:paintroid/core/providers/object/file_service.dart';
import 'package:paintroid/core/providers/object/image_service.dart';
import 'package:paintroid/core/providers/object/permission_service.dart';
Expand Down Expand Up @@ -61,12 +63,23 @@ class LoadImageFromFileManager with LoggableMixin {
case 'catrobat-image':
Uint8List bytes = await file.readAsBytes();
CatrobatImage catrobatImage = CatrobatImage.fromBytes(bytes);
Image? backgroundImage =
ui.Image? backgroundImage =
await rebuildBackgroundImage(catrobatImage);
return Result.ok(ImageFromFile.catrobatImage(
catrobatImage,
backgroundImage: backgroundImage,
));
case 'ora':
Uint8List bytes = await file.readAsBytes();
Archive archive = ZipDecoder().decodeBytes(bytes);
ProcessOra processOra = ProcessOra();
List<ui.Image> layers = await processOra.processOraFile(archive);

if (layers.isNotEmpty) {
return Result.ok(ImageFromFile.rasterImage(layers.first));
} else {
return const Result.err(LoadImageFailure.invalidImage);
}
default:
return const Result.err(LoadImageFailure.invalidImage);
}
Expand All @@ -80,7 +93,7 @@ class LoadImageFromFileManager with LoggableMixin {
});
}

Future<Image?> rebuildBackgroundImage(CatrobatImage catrobatImage) async {
Future<ui.Image?> rebuildBackgroundImage(CatrobatImage catrobatImage) async {
if (catrobatImage.backgroundImage.isNotEmpty) {
final backgroundImageData = base64Decode(catrobatImage.backgroundImage);
final result =
Expand Down
30 changes: 30 additions & 0 deletions lib/core/providers/object/save_as_ora_image.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:oxidized/oxidized.dart';
import 'package:paintroid/core/models/ora_image.dart';
import 'package:paintroid/core/providers/object/file_service.dart';
import 'package:paintroid/core/providers/object/permission_service.dart';
import 'package:paintroid/core/utils/failure.dart';
import 'package:paintroid/core/utils/save_image_failure.dart';

class SaveAsOraImage {
final IFileService _fileService;
final IPermissionService _permissionService;

SaveAsOraImage(this._fileService, this._permissionService);

static final provider = Provider((ref) {
final fileService = ref.watch(IFileService.provider);
final permissionService = ref.watch(IPermissionService.provider);
return SaveAsOraImage(fileService, permissionService);
});

Future<Result<File, Failure>> call(OraImage image, String fileName) async {
if (!(await _permissionService.requestAccessToSharedFileStorage())) {
return const Result.err(SaveImageFailure.permissionDenied);
}

final bytes = image.toBytes();
return _fileService.save(fileName, bytes);
}
}
3 changes: 3 additions & 0 deletions lib/ui/shared/dialogs/save_image_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ class _SaveImageDialogState extends State<SaveImageDialog> {
case ImageFormat.catrobatImage:
data = CatrobatImageMetaData(nameFieldController.text);
break;
case ImageFormat.ora:
data = OraMetaData(nameFieldController.text);
break;
}
Navigator.of(context).pop(data);
}
Expand Down
4 changes: 4 additions & 0 deletions lib/ui/shared/image_format_info.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ extension on ImageFormat {
return const TextSpan(
text: 'Pocket Paint\'s native image format. '
'This format remembers commands and layers.');
case ImageFormat.ora:
return const TextSpan(
text:
'OpenRaster format. Supports layers and various attributes like opacity and visibility for each layer.');
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ packages:
source: hosted
version: "0.11.3"
archive:
dependency: transitive
dependency: "direct main"
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
Expand Down Expand Up @@ -900,10 +900,10 @@ packages:
dependency: transitive
description:
name: permission_handler_apple
sha256: f84a188e79a35c687c132a0a0556c254747a08561e99ab933f12f6ca71ef3c98
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.6"
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ dependencies:
file_picker: ^8.3.5
floor: ^1.2.0
sqflite: ^2.3.0
archive: ^3.4.10
colorpicker:
path: packages/colorpicker

Expand Down