Skip to content

Commit

Permalink
Layer group (#230)
Browse files Browse the repository at this point in the history
* Group slide action buttons

* Fix layer row clipping

* Build group line

* Build line

* Add layer group to save data

* Change save data format

* Setup sync layers test

* Pass tests

* Test appending to secondary

* Non null stroke

* Loaded bool

* Readability

* Refactor tests

* Better test output

* Pass test

* Pass test

* Longer wins

* Refactor before optimization

* Refactor

* Recursive refactor and todo

* Sync playmode

* Test if appended are empty

* YAGNI

* Non null width and height

* Just load empty image into mem as before

* Pass test

* Renume redundant null operators

* Fix broken tests

* Use loaded constructor

* File must always exist

* Line padding

* Remove bangs

* Layer groups list getter

* Flush line to the side

* Indent grouped

* Refactor

* Build group lines from state

* Implement group actions

* Fix line position

* Layer group actions buttons based on context

* Sync layers within a group on drawing page

* Merge layer groups

* Await for merge group

* Extract sharedPref, test for delete bug

* Refactor test

* Fix delete layer bug

* Delete logic for groups

* Pass tests

* Test adding to group when sandwich

* Pass test

* Test keeping layers in sync on add

* Pass test

* Show number only for selected

* Bottom padding

* Finish adding layer before adding another one

* Test handling groups on reorder

* Pass test

* Pass another test

* Pass test

* Pass another test for moving outside the group

* Refactor

* Pass last test

* Refactor

* Refactor

* Fix active reel index

* Extract layers getter

* Change icons

* Animated padding

* Make group line more obvious

* Extract group line

* Hide line on reordering

* Transparent material on reorder

* Trigger reordering end when layerer is dropped in the same place

* Rename to sync and detach

* Fade out group line on reorder

* Refactor

* Preserve auto scroll on reorder

* Fix group lines on rotation

* Update flutter

* Keep group lines aligned with its group at all times

* Fix path collision on adding frames to synced layers

* Optimize bracket painting

* Clip brackets outside bounds

* Allow timeline pinching over buttons

* Scene layer methods

* Image interface

* Use image interface in image sliver

* Painted glass

* Use getter

* Scene layer group

* Frame interface

* One interface for frame and composite frame

* Simplify composite image constructor

* Remove unnecessary ghost frames getter

* Rename to getFrames

* Replace with frame interface

* Timeline scene layer interface

* Implement interface

* Implement Scene layer group

* Partially integrate scene layer interface

* Timeline folder

* Timeline row interface

* Group row interfaces in one file

* Scene row interface

* Implement timeline scene row

* Scene row object

* Rename var

* Combine layers on timeline

* Fix group visibility toggle

* Fix duplicate

* Fix delete

* Update mini reel frame when paiting

* Base image

* Just pass image

* Dispose of all in history stack, since flushed snapshots are cloned

* Add smaller brush size

* Rename

* Show composite frame in delete dialog

* Remove unnecessary clone calls

* Document usage of clone for diskimage

* Dispose on delete

* Move draw with finger state

* Refactor

* Blend layers only when necessary

* Fix a bug where short strokes disappear

* Copy all properties when duplicating scene layer

* Remove override

* Release notes

* Bump version
  • Loading branch information
ruskakimov authored Oct 3, 2021
1 parent d24847e commit 2946966
Show file tree
Hide file tree
Showing 83 changed files with 2,255 additions and 645 deletions.
98 changes: 81 additions & 17 deletions lib/common/data/io/disk_image.dart
Original file line number Diff line number Diff line change
@@ -1,34 +1,85 @@
import 'dart:ui';
import 'dart:io';

import 'package:equatable/equatable.dart';
import 'package:mooltik/common/data/project/base_image.dart';
import 'package:mooltik/common/data/io/generate_image.dart';
import 'package:mooltik/common/data/io/make_duplicate_path.dart';
import 'package:mooltik/common/data/io/png.dart';

/// Manages a single image file.
class DiskImage with EquatableMixin {
/// Notifies listeners when image changes.
class DiskImage extends BaseImage {
DiskImage({
required this.file,
required this.width,
required this.height,
Image? snapshot,
}) : _snapshot = snapshot;
}) : assert(snapshot == null ||
snapshot.width == width && snapshot.height == height),
_snapshot = snapshot?.clone() {
file.createSync();
}

DiskImage.loaded({
required this.file,
required Image snapshot,
}) : width = snapshot.width,
height = snapshot.height,
_snapshot = snapshot.clone() {
file.createSync();
}

final File file;

final int width;
final int height;

/// Current memory snapshot owned by this image.
/// Will be disposed of when image updates.
/// Call `clone` if you want to keep the reference to this particular snapshot in time.
Image? get snapshot => _snapshot;
Image? _snapshot;

Size get size => Size(width!.toDouble(), height!.toDouble());

int? get width => _snapshot?.width;
bool get loaded => _snapshot != null;

int? get height => _snapshot?.height;
Future<bool> get isFileEmpty async =>
!file.existsSync() || (await file.length()) == 0;

Future<void> loadSnapshot() async {
if (await isFileEmpty) {
await _loadEmptySnapshot();
} else {
await _loadFromFile();
}
}

Future<void> _loadEmptySnapshot() async {
_snapshot?.dispose();
_snapshot = await generateEmptyImage(width, height);
notifyListeners();
}

Future<void> _loadFromFile() async {
_snapshot?.dispose();
_snapshot = await pngRead(file);
notifyListeners();
}

void changeSnapshot(Image? newSnapshot) {
_snapshot?.dispose();
_snapshot = newSnapshot?.clone();
notifyListeners();
}

Future<void> saveSnapshot() async {
if (_snapshot == null) throw Exception('Snapshot is missing.');
if (_snapshot == null) {
if (await isFileEmpty) {
await _loadEmptySnapshot();
} else {
throw Exception('Cannot save empty snapshot if file is not empty.');
}
}

await pngWrite(file, _snapshot!);
}

Expand All @@ -38,24 +89,37 @@ class DiskImage with EquatableMixin {

return DiskImage(
file: duplicateFile,
snapshot: snapshot?.clone(),
width: width,
height: height,
snapshot: snapshot,
);
}

factory DiskImage.fromJson(Map<String, dynamic> json) => DiskImage(
file: File(json[_filePathKey]),
);

Map<String, dynamic> toJson() => {
_filePathKey: file.path,
};
/// Creates an empty image with the same dimensions.
Future<DiskImage> cloneEmpty() async {
final freePath = makeFreeDuplicatePath(file.path);
final image = DiskImage(
file: File(freePath),
width: width,
height: height,
);

static const String _filePathKey = 'path';
await image.loadSnapshot();
return image;
}

@override
List<Object?> get props => [file.path];

@override
void dispose() {
_snapshot?.dispose();
super.dispose();
}

@override
void draw(Canvas canvas, Offset offset, Paint paint) {
final image = _snapshot;
if (image != null) canvas.drawImage(image, offset, paint);
}
}
3 changes: 3 additions & 0 deletions lib/common/data/io/generate_image.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ Future<Image> generateImage(
picture.dispose();
return image;
}

Future<Image> generateEmptyImage(int width, int height) =>
generateImage(null, width, height);
11 changes: 11 additions & 0 deletions lib/common/data/project/base_image.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';

abstract class BaseImage extends ChangeNotifier with EquatableMixin {
int get width;
int get height;

Size get size => Size(width.toDouble(), height.toDouble());

void draw(Canvas canvas, Offset offset, Paint paint);
}
38 changes: 11 additions & 27 deletions lib/common/data/project/composite_frame.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,51 +2,35 @@ import 'dart:ui' as ui;

import 'package:equatable/equatable.dart';
import 'package:mooltik/common/data/io/generate_image.dart';
import 'package:mooltik/common/data/extensions/duration_methods.dart';
import 'package:mooltik/common/data/project/composite_image.dart';
import 'package:mooltik/common/data/project/frame_interface.dart';
import 'package:mooltik/common/data/sequence/time_span.dart';
import 'package:mooltik/common/ui/composite_image_painter.dart';

/// Composite image with duration.
class CompositeFrame extends TimeSpan with EquatableMixin {
CompositeFrame(this.compositeImage, Duration duration) : super(duration);
class CompositeFrame extends TimeSpan
with EquatableMixin
implements FrameInterface {
CompositeFrame(this.image, Duration duration) : super(duration);

final CompositeImage compositeImage;
final CompositeImage image;

int get width => compositeImage.width;
int get width => image.width;

int get height => compositeImage.height;
int get height => image.height;

@override
TimeSpan copyWith({Duration? duration}) => CompositeFrame(
this.compositeImage,
this.image,
duration ?? this.duration,
);

Future<ui.Image> toImage() => generateImage(
CompositeImagePainter(compositeImage),
CompositeImagePainter(image),
width,
height,
);

@override
List<Object?> get props => [width, height, compositeImage, duration];

factory CompositeFrame.fromJson(Map<String, dynamic> json) => CompositeFrame(
CompositeImage.fromJson(json[_imageKey]),
(json[_durationKey] as String).parseDuration(),
);

Map<String, dynamic> toJson() => {
_imageKey: compositeImage.toJson(),
_durationKey: duration.toString(),
};

@override
void dispose() {
compositeImage.layers.forEach((layerImage) => layerImage.dispose());
}

static const String _imageKey = 'image';
static const String _durationKey = 'duration';
List<Object?> get props => [width, height, image, duration];
}
43 changes: 15 additions & 28 deletions lib/common/data/project/composite_image.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import 'dart:ui';

import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:mooltik/common/data/project/base_image.dart';
import 'package:mooltik/common/data/io/disk_image.dart';

/// Stack of images of the same size.
class CompositeImage with EquatableMixin {
CompositeImage({
required this.width,
required this.height,
required this.layers,
}) : assert(layers
.every((image) => image.height == height && image.width == width));
class CompositeImage extends BaseImage {
CompositeImage(this.layers)
: width = layers.first.width,
height = layers.first.height,
assert(layers.isNotEmpty &&
layers.every((image) =>
image.height == layers.first.height &&
image.width == layers.first.width));

CompositeImage.empty({
required this.width,
Expand All @@ -23,34 +25,19 @@ class CompositeImage with EquatableMixin {
/// Image layers from top to bottom.
final List<DiskImage> layers;

Size get size => Size(width.toDouble(), height.toDouble());

@override
List<Object?> get props => [width, height, layers];

factory CompositeImage.fromJson(Map<String, dynamic> json) => CompositeImage(
width: json[_widthKey],
height: json[_heightKey],
layers: (json[_layersKey] as List)
.map((json) => DiskImage.fromJson(json))
.toList(),
);

Map<String, dynamic> toJson() => {
_widthKey: width,
_heightKey: height,
_layersKey: layers.map((layer) => layer.toJson()).toList(),
};

static const String _widthKey = 'width';
static const String _heightKey = 'height';
static const String _layersKey = 'layers';
@override
void draw(Canvas canvas, Offset offset, Paint paint) {
canvas.drawCompositeImage(this, offset, paint);
}
}

extension CompositeImageDrawing on Canvas {
void drawCompositeImage(CompositeImage image, Offset offset, Paint paint) {
for (final layer in image.layers.reversed) {
drawImage(layer.snapshot!, offset, paint);
layer.draw(this, offset, paint);
}
}
}
7 changes: 7 additions & 0 deletions lib/common/data/project/frame_interface.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'package:mooltik/common/data/project/base_image.dart';
import 'package:mooltik/common/data/sequence/time_span.dart';

abstract class FrameInterface implements TimeSpan {
BaseImage get image;
Duration get duration;
}
24 changes: 24 additions & 0 deletions lib/common/data/project/layer_group/combine_frames.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import 'package:mooltik/common/data/project/composite_frame.dart';
import 'package:mooltik/common/data/project/composite_image.dart';
import 'package:mooltik/drawing/data/frame/frame.dart';

CompositeFrame combineFrames(Iterable<Frame> frames) {
assert(frames.isNotEmpty);
assert(frames.every((frame) => frame.duration == frames.first.duration));

final images = frames.map((frame) => frame.image).toList();
return CompositeFrame(CompositeImage(images), frames.first.duration);
}

Iterable<CompositeFrame> combineFrameSequences(
Iterable<Iterable<Frame>> frameSequences,
) sync* {
assert(frameSequences.isNotEmpty);

final iterators =
frameSequences.map((sequence) => sequence.iterator).toList();

while (iterators.every((iterator) => iterator.moveNext())) {
yield combineFrames(iterators.map((iterator) => iterator.current));
}
}
69 changes: 69 additions & 0 deletions lib/common/data/project/layer_group/frame_reel_group.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:mooltik/common/data/project/frame_interface.dart';
import 'package:mooltik/common/data/project/layer_group/combine_frames.dart';
import 'package:mooltik/common/data/sequence/sequence.dart';
import 'package:mooltik/drawing/data/frame/frame.dart';
import 'package:mooltik/drawing/data/frame_reel_model.dart';

/// Wrapper around `FrameReelModel` to keep grouped layers in sync.
class FrameReelGroup extends ChangeNotifier implements FrameReelModel {
FrameReelGroup({
required this.activeReel,
required this.group,
});

final FrameReelModel activeReel;
final List<FrameReelModel> group;

@override
Sequence<Frame> get frameSeq => activeReel.frameSeq;

@override
Frame get currentFrame => activeReel.currentFrame;

@override
FrameInterface get deleteDialogFrame =>
combineFrames(group.map((reel) => reel.currentFrame));

@override
int get currentIndex => activeReel.currentIndex;

@override
void setCurrent(int index) {
group.forEach((reel) => reel.setCurrent(index));
notifyListeners();
}

@override
Future<void> appendFrame() async {
await Future.wait(group.map((reel) => reel.appendFrame()));
notifyListeners();
}

@override
Future<void> addBeforeCurrent() async {
await Future.wait(group.map((reel) => reel.addBeforeCurrent()));
notifyListeners();
}

@override
Future<void> addAfterCurrent() async {
await Future.wait(group.map((reel) => reel.addAfterCurrent()));
notifyListeners();
}

@override
Future<void> duplicateCurrent() async {
await Future.wait(group.map((reel) => reel.duplicateCurrent()));
notifyListeners();
}

@override
bool get canDeleteCurrent => activeReel.canDeleteCurrent;

@override
void deleteCurrent() {
group.forEach((reel) => reel.deleteCurrent());
notifyListeners();
}
}
Loading

0 comments on commit 2946966

Please sign in to comment.