duplicateAt(int index) async {
33 | final duplicate = await clipAt(index).duplicate();
34 | _sceneSequence.insert(index + 1, duplicate);
35 | }
36 |
37 | @override
38 | void changeDurationAt(int index, Duration newDuration) {
39 | _sceneSequence.changeSpanDurationAt(index, newDuration);
40 | }
41 |
42 | @override
43 | Duration startTimeOf(int index) => _sceneSequence.startTimeOf(index);
44 |
45 | @override
46 | Duration endTimeOf(int index) => _sceneSequence.endTimeOf(index);
47 | }
48 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Animation studio in your pocket.
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | [](https://codemagic.io/apps/60363e65c9d4d7cf9b10cfc0/default-workflow/latest_build)
15 |
16 |
17 |
18 |
19 | ## Features:
20 |
21 |
22 |
23 | ## Useful commands:
24 |
25 | #### Extract app data
26 |
27 | ```
28 | adb shell
29 | run-as com.kakimov.mooltik
30 | mkdir -p /sdcard/Documents/mooltik_data
31 | cp -r app_flutter/ /sdcard/Documents/mooltik_data/
32 | ```
33 |
--------------------------------------------------------------------------------
/lib/editing/data/timeline/timeline_row_interfaces.dart:
--------------------------------------------------------------------------------
1 | import 'package:mooltik/common/data/project/frame_interface.dart';
2 | import 'package:mooltik/common/data/project/scene.dart';
3 | import 'package:mooltik/common/data/project/scene_layer.dart';
4 | import 'package:mooltik/common/data/sequence/time_span.dart';
5 |
6 | abstract class TimelineRowInterface {
7 | int get clipCount;
8 | Iterable get clips;
9 |
10 | TimeSpan clipAt(int index);
11 | void deleteAt(int index);
12 | Future duplicateAt(int index);
13 | void changeDurationAt(int index, Duration newDuration);
14 | Duration startTimeOf(int index);
15 | Duration endTimeOf(int index);
16 | }
17 |
18 | abstract class TimelineSceneRowInterface extends TimelineRowInterface {
19 | @override
20 | Iterable get clips;
21 |
22 | @override
23 | Scene clipAt(int index);
24 |
25 | void insertSceneAfter(int index, Scene newScene);
26 | }
27 |
28 | abstract class TimelineSceneLayerInterface extends TimelineRowInterface {
29 | @override
30 | Iterable get clips;
31 |
32 | /// Apply the [playMode] to get a frame sequence that lasts [totalDuration].
33 | Iterable getPlayFrames(Duration totalDuration);
34 |
35 | PlayMode get playMode;
36 | void changePlayMode();
37 |
38 | bool get visible;
39 | void toggleVisibility();
40 |
41 | void changeAllFramesDuration(Duration newFrameDuration);
42 |
43 | @override
44 | FrameInterface clipAt(int index);
45 | }
46 |
--------------------------------------------------------------------------------
/lib/common/data/io/mp4/ffmpeg_helpers.dart:
--------------------------------------------------------------------------------
1 | import 'package:mooltik/common/data/io/mp4/slide.dart';
2 | import 'package:mooltik/common/data/project/fps_config.dart';
3 |
4 | String ffmpegSlideshowConcatDemuxer(List slides) {
5 | String concatDemuxer = '';
6 | for (final slide in slides) {
7 | concatDemuxer += '''
8 | file '${slide.pngImage.path}'
9 | duration ${ffmpegDurationLabel(slide.duration)}
10 | ''';
11 | }
12 |
13 | // Due to a quirk, the last image has to be specified twice - the 2nd time without any duration directive.
14 | // Source: https://trac.ffmpeg.org/wiki/Slideshow
15 | concatDemuxer += '''
16 | file '${slides.last.pngImage.path}'
17 | ''';
18 |
19 | return concatDemuxer;
20 | }
21 |
22 | String ffmpegDurationLabel(Duration duration) => '${duration.inMilliseconds}ms';
23 |
24 | String ffmpegCommand({
25 | required String concatDemuxerPath,
26 | String? soundClipPath,
27 | Duration? soundClipOffset,
28 | required String outputPath,
29 | required Duration videoDuration,
30 | }) {
31 | final globalOptions = '-y';
32 | final videoInput = '-f concat -safe 0 -i $concatDemuxerPath';
33 | final audioInput = soundClipPath != null
34 | ? '-itsoffset ${ffmpegDurationLabel(soundClipOffset!)} -i $soundClipPath'
35 | : '';
36 | final output =
37 | '-c:v libx264 -preset slow -crf 18 -vf fps=$fps -pix_fmt yuv420p -t ${ffmpegDurationLabel(videoDuration)} $outputPath';
38 | return '$globalOptions $videoInput $audioInput $output';
39 | }
40 |
--------------------------------------------------------------------------------
/lib/common/ui/orientation_listener.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 |
3 | class OrientationListener extends StatefulWidget {
4 | OrientationListener({
5 | Key? key,
6 | required this.onOrientationChanged,
7 | required this.child,
8 | }) : super(key: key);
9 |
10 | final ValueChanged onOrientationChanged;
11 | final Widget child;
12 |
13 | @override
14 | _OrientationListenerState createState() => _OrientationListenerState();
15 | }
16 |
17 | class _OrientationListenerState extends State
18 | with WidgetsBindingObserver {
19 | late Orientation _orientation;
20 |
21 | Orientation get currentOrientation {
22 | final size = WidgetsBinding.instance!.window.physicalSize;
23 | return size.width > size.height
24 | ? Orientation.landscape
25 | : Orientation.portrait;
26 | }
27 |
28 | @override
29 | void initState() {
30 | super.initState();
31 | WidgetsBinding.instance!.addObserver(this);
32 | _orientation = currentOrientation;
33 | }
34 |
35 | @override
36 | void dispose() {
37 | WidgetsBinding.instance!.removeObserver(this);
38 | super.dispose();
39 | }
40 |
41 | @override
42 | void didChangeMetrics() {
43 | if (currentOrientation != _orientation) {
44 | _orientation = currentOrientation;
45 | widget.onOrientationChanged(_orientation);
46 | }
47 | }
48 |
49 | @override
50 | Widget build(BuildContext context) {
51 | return widget.child;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/lib/drawing/data/toolbox/tools/bucket.dart:
--------------------------------------------------------------------------------
1 | import 'dart:ui' as ui;
2 |
3 | import 'package:flutter/material.dart';
4 | import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
5 | import 'package:mooltik/common/data/flood_fill.dart';
6 | import 'package:mooltik/drawing/data/frame/stroke.dart';
7 | import 'package:shared_preferences/shared_preferences.dart';
8 | import 'tool.dart';
9 |
10 | class Bucket extends ToolWithColor {
11 | Bucket(SharedPreferences sharedPreferences) : super(sharedPreferences);
12 |
13 | @override
14 | IconData get icon => MdiIcons.formatColorFill;
15 |
16 | @override
17 | String get name => 'bucket';
18 |
19 | Offset _lastPoint = Offset.zero;
20 |
21 | @override
22 | Stroke? onStrokeStart(Offset canvasPoint) {
23 | _lastPoint = canvasPoint;
24 | }
25 |
26 | @override
27 | void onStrokeUpdate(Offset canvasPoint) {
28 | _lastPoint = canvasPoint;
29 | }
30 |
31 | @override
32 | Stroke? onStrokeEnd() {}
33 |
34 | @override
35 | Stroke? onStrokeCancel() {}
36 |
37 | @override
38 | PaintOn? makePaintOn(ui.Rect canvasArea) {
39 | if (!canvasArea.contains(_lastPoint)) return null;
40 |
41 | final frozenColor = color;
42 | final frozenX = _lastPoint.dx.toInt();
43 | final frozenY = _lastPoint.dy.toInt();
44 |
45 | return (ui.Image canvasImage) {
46 | return floodFill(
47 | canvasImage,
48 | frozenX,
49 | frozenY,
50 | frozenColor,
51 | );
52 | };
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/lib/editing/ui/timeline/view/overlay/sliver_action_buttons/speed_button.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:mooltik/editing/data/timeline/timeline_view_model.dart';
3 | import 'package:mooltik/editing/ui/timeline/view/overlay/sliver_action_buttons/sliver_action_button.dart';
4 | import 'package:mooltik/editing/ui/timeline/view/overlay/sliver_action_buttons/speed_dialog.dart';
5 | import 'package:provider/provider.dart';
6 |
7 | class SpeedButton extends StatelessWidget {
8 | const SpeedButton({
9 | Key? key,
10 | required this.rowIndex,
11 | required this.colIndex,
12 | }) : super(key: key);
13 |
14 | final int rowIndex;
15 | final int colIndex;
16 |
17 | @override
18 | Widget build(BuildContext context) {
19 | return SliverActionButton(
20 | iconData: Icons.speed,
21 | onPressed: () => _openSpeedDialog(context),
22 | rowIndex: rowIndex,
23 | colIndex: colIndex,
24 | );
25 | }
26 |
27 | void _openSpeedDialog(BuildContext context) {
28 | final timelineView = context.read();
29 |
30 | Navigator.of(context).push(
31 | MaterialPageRoute(
32 | fullscreenDialog: true,
33 | builder: (context) => SpeedDialog(
34 | frames: timelineView.layerFrames(rowIndex),
35 | playMode: timelineView.layerPlayMode(rowIndex),
36 | onSubmit: (frameDuration) =>
37 | timelineView.setLayerSpeed(rowIndex, frameDuration),
38 | ),
39 | ),
40 | );
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/test/project_save_data_test.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 | import 'dart:io';
3 |
4 | import 'package:flutter_test/flutter_test.dart';
5 | import 'package:mooltik/common/data/project/project_save_data.dart';
6 |
7 | void main() {
8 | group('ProjectSaveData should', () {
9 | test('decode and encode back A', () {
10 | final rawSaveData = File('./test/project_data/a_v1_18.json')
11 | .readAsStringSync()
12 | .replaceAll(RegExp(r'\s'), '');
13 |
14 | final data = ProjectSaveData.fromJson(jsonDecode(rawSaveData), '', '');
15 | expect(jsonEncode(data), rawSaveData);
16 | });
17 |
18 | test('decode and encode back B', () {
19 | final rawSaveData = File('./test/project_data/b_v1_18.json')
20 | .readAsStringSync()
21 | .replaceAll(RegExp(r'\s'), '');
22 |
23 | final data = ProjectSaveData.fromJson(jsonDecode(rawSaveData), '', '');
24 | expect(jsonEncode(data), rawSaveData);
25 | });
26 |
27 | test('decode and encode back B with width and height as double', () {
28 | final rawSaveData = File('./test/project_data/b_v1_13.json')
29 | .readAsStringSync()
30 | .replaceAll(RegExp(r'\s'), '');
31 |
32 | final expectedData = File('./test/project_data/b_v1_18.json')
33 | .readAsStringSync()
34 | .replaceAll(RegExp(r'\s'), '');
35 |
36 | final data = ProjectSaveData.fromJson(jsonDecode(rawSaveData), '', '');
37 | expect(jsonEncode(data), expectedData);
38 | });
39 | });
40 | }
41 |
--------------------------------------------------------------------------------
/lib/drawing/data/toolbox/tools/lasso.dart:
--------------------------------------------------------------------------------
1 | import 'dart:ui' as ui;
2 |
3 | import 'package:flutter/material.dart';
4 | import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
5 | import 'package:mooltik/drawing/data/frame/selection_stroke.dart';
6 | import 'package:mooltik/drawing/data/frame/stroke.dart';
7 | import 'package:shared_preferences/shared_preferences.dart';
8 | import 'tool.dart';
9 |
10 | class Lasso extends ToolWithColor {
11 | Lasso(SharedPreferences sharedPreferences) : super(sharedPreferences);
12 |
13 | @override
14 | IconData get icon => MdiIcons.lasso;
15 |
16 | @override
17 | String get name => 'lasso';
18 |
19 | /// Lasso selected area.
20 | SelectionStroke? get selectionStroke => _selectionStroke;
21 | SelectionStroke? _selectionStroke;
22 |
23 | void removeSelection() {
24 | _selectionStroke = null;
25 | }
26 |
27 | @override
28 | Stroke? onStrokeStart(Offset canvasPoint) {
29 | _selectionStroke = SelectionStroke(canvasPoint);
30 | }
31 |
32 | @override
33 | void onStrokeUpdate(Offset canvasPoint) {
34 | _selectionStroke?.extend(canvasPoint);
35 | }
36 |
37 | @override
38 | Stroke? onStrokeEnd() {
39 | _selectionStroke?.finish();
40 | }
41 |
42 | @override
43 | Stroke? onStrokeCancel() {
44 | removeSelection();
45 | }
46 |
47 | @override
48 | PaintOn? makePaintOn(ui.Rect canvasArea) {
49 | _selectionStroke?.clipToFrame(canvasArea);
50 | if (_selectionStroke!.isTooSmall) removeSelection();
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/lib/editing/ui/timeline/view/overlay/animated_scene_preview.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:mooltik/common/data/project/scene.dart';
3 | import 'package:mooltik/common/ui/composite_image_painter.dart';
4 |
5 | class AnimatedScenePreview extends StatefulWidget {
6 | AnimatedScenePreview({
7 | Key? key,
8 | required this.scene,
9 | }) : super(key: key);
10 |
11 | final Scene scene;
12 |
13 | @override
14 | _AnimatedScenePreviewState createState() => _AnimatedScenePreviewState();
15 | }
16 |
17 | class _AnimatedScenePreviewState extends State
18 | with SingleTickerProviderStateMixin {
19 | late final AnimationController animation;
20 |
21 | @override
22 | void initState() {
23 | super.initState();
24 | animation = AnimationController(vsync: this)
25 | ..repeat(period: widget.scene.duration);
26 | }
27 |
28 | @override
29 | void dispose() {
30 | animation.dispose();
31 | super.dispose();
32 | }
33 |
34 | @override
35 | Widget build(BuildContext context) {
36 | return AnimatedBuilder(
37 | animation: animation,
38 | builder: (context, child) {
39 | final playhead = widget.scene.duration * animation.value;
40 | final image = widget.scene.imageAt(playhead);
41 |
42 | return FittedBox(
43 | fit: BoxFit.contain,
44 | child: CustomPaint(
45 | size: image.size,
46 | painter: CompositeImagePainter(image),
47 | ),
48 | );
49 | },
50 | );
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/lib/home/ui/add_project_button.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:provider/provider.dart';
3 | import 'package:font_awesome_flutter/font_awesome_flutter.dart';
4 |
5 | import '../data/gallery_model.dart';
6 |
7 | class AddProjectButton extends StatelessWidget {
8 | const AddProjectButton({
9 | Key? key,
10 | }) : super(key: key);
11 |
12 | @override
13 | Widget build(BuildContext context) {
14 | return Material(
15 | color: Colors.white12,
16 | borderRadius: BorderRadius.circular(8),
17 | child: InkWell(
18 | onTap: () => _addProject(context),
19 | borderRadius: BorderRadius.circular(8),
20 | child: DecoratedBox(
21 | decoration: BoxDecoration(
22 | border: Border.all(
23 | color: Colors.white24,
24 | width: 4,
25 | ),
26 | borderRadius: BorderRadius.circular(8),
27 | ),
28 | child: Center(
29 | child: Column(
30 | mainAxisAlignment: MainAxisAlignment.center,
31 | children: [
32 | Icon(FontAwesomeIcons.plus, size: 20),
33 | SizedBox(height: 12),
34 | Text('Add Project'),
35 | ],
36 | ),
37 | ),
38 | ),
39 | ),
40 | );
41 | }
42 |
43 | void _addProject(BuildContext context) async {
44 | final manager = context.read();
45 | final project = await manager.addProject();
46 | manager.openProject(project, context);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/lib/drawing/data/onion_model.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:mooltik/common/data/sequence/sequence.dart';
3 | import 'package:mooltik/drawing/data/frame/frame.dart';
4 | import 'package:shared_preferences/shared_preferences.dart';
5 |
6 | const _enabledKey = 'onion_enabled';
7 |
8 | class OnionModel extends ChangeNotifier {
9 | OnionModel({
10 | required Sequence frames,
11 | required int selectedIndex,
12 | required SharedPreferences sharedPreferences,
13 | }) : assert(selectedIndex < frames.length),
14 | _frames = frames,
15 | _selectedIndex = selectedIndex,
16 | _preferences = sharedPreferences,
17 | _enabled = sharedPreferences.getBool(_enabledKey) ?? true;
18 |
19 | SharedPreferences _preferences;
20 | Sequence _frames;
21 |
22 | int _selectedIndex;
23 |
24 | void updateFrames(Sequence frames) {
25 | if (frames != _frames) {
26 | _frames = frames;
27 | }
28 | }
29 |
30 | void updateSelectedIndex(int selectedIndex) {
31 | _selectedIndex = selectedIndex;
32 | }
33 |
34 | bool get enabled => _enabled;
35 | bool _enabled;
36 |
37 | Future toggle() async {
38 | _enabled = !_enabled;
39 | notifyListeners();
40 |
41 | await _preferences.setBool(_enabledKey, _enabled);
42 | }
43 |
44 | Frame? get frameBefore =>
45 | _enabled && _selectedIndex > 0 ? _frames[_selectedIndex - 1] : null;
46 |
47 | Frame? get frameAfter => _enabled && _selectedIndex < _frames.length - 1
48 | ? _frames[_selectedIndex + 1]
49 | : null;
50 | }
51 |
--------------------------------------------------------------------------------
/lib/common/data/project/sound_clip.dart:
--------------------------------------------------------------------------------
1 | import 'dart:io';
2 |
3 | import 'package:mooltik/common/data/extensions/duration_methods.dart';
4 | import 'package:mooltik/common/data/sequence/time_span.dart';
5 | import 'package:path/path.dart' as p;
6 |
7 | class SoundClip extends TimeSpan {
8 | SoundClip({
9 | required this.file,
10 | required Duration startTime,
11 | required Duration duration,
12 | }) : _startTime = startTime,
13 | super(duration);
14 |
15 | String get path => file.path;
16 | final File file;
17 |
18 | Duration get startTime => _startTime;
19 | Duration _startTime;
20 |
21 | Duration get endTime => _startTime + duration;
22 |
23 | factory SoundClip.fromJson(Map json, String soundDirPath) =>
24 | SoundClip(
25 | file: File(p.join(soundDirPath, json[_fileNameKey])),
26 | startTime: (json[_startTimeKey] as String).parseDuration(),
27 | duration: (json[_durationKey] as String).parseDuration(),
28 | );
29 |
30 | Map toJson() => {
31 | _fileNameKey: p.basename(file.path),
32 | _startTimeKey: _startTime.toString(),
33 | _durationKey: duration.toString(),
34 | };
35 |
36 | @override
37 | SoundClip copyWith({Duration? duration}) => SoundClip(
38 | file: file,
39 | startTime: startTime,
40 | duration: duration ?? this.duration,
41 | );
42 |
43 | @override
44 | void dispose() {}
45 | }
46 |
47 | const String _fileNameKey = 'file_name';
48 | const String _startTimeKey = 'start_time';
49 | const String _durationKey = 'duration';
50 |
--------------------------------------------------------------------------------
/lib/editing/data/importer_model.dart:
--------------------------------------------------------------------------------
1 | import 'dart:io';
2 |
3 | import 'package:file_picker/file_picker.dart';
4 | import 'package:flutter/material.dart';
5 | import 'package:mooltik/common/data/project/project.dart';
6 |
7 | class ImporterModel extends ChangeNotifier {
8 | /// Whether import is in progress.
9 | bool get importing => _importing;
10 | bool _importing = false;
11 |
12 | /// Imports audio to project.
13 | /// Wrap in try-catch to handle bad input.
14 | Future importAudioTo(Project project) async {
15 | if (_importing) return;
16 |
17 | final soundFile = await _pickSoundFile();
18 |
19 | // User closed picker.
20 | if (soundFile == null) return;
21 |
22 | _importing = true;
23 | notifyListeners();
24 |
25 | try {
26 | await project.loadSoundClipFromFile(soundFile);
27 | } catch (e) {
28 | rethrow;
29 | } finally {
30 | _importing = false;
31 | notifyListeners();
32 | }
33 | }
34 |
35 | Future _pickSoundFile() async {
36 | final iosAllowedExtensions = ['mp3', 'aac', 'flac', 'm4a', 'wav', 'ogg'];
37 |
38 | final result = await FilePicker.platform.pickFiles(
39 | // Audio type opens Apple Music on iOS, which doesn't allow you import downloaded sounds.
40 | // Custom type opens Files app instead.
41 | type: Platform.isIOS ? FileType.custom : FileType.audio,
42 | allowedExtensions: Platform.isIOS ? iosAllowedExtensions : null,
43 | );
44 |
45 | if (result == null || result.files.isEmpty) return null;
46 |
47 | return File(result.files.first.path!);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/lib/common/ui/get_permission.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:permission_handler/permission_handler.dart';
3 |
4 | final _permissionStrings = {
5 | Permission.storage: 'Storage',
6 | };
7 |
8 | /// Handles permission flow and executes your code that required the permission.
9 | Future getPermission({
10 | required BuildContext context,
11 | required Permission permission,
12 | required VoidCallback onGranted,
13 | }) async {
14 | assert(
15 | _permissionStrings.containsKey(permission),
16 | 'Permission string must be defined.',
17 | );
18 |
19 | final storageStatus = await permission.request();
20 |
21 | if (storageStatus.isGranted) {
22 | onGranted();
23 | } else if (storageStatus.isPermanentlyDenied) {
24 | _openAllowAccessDialog(
25 | context: context,
26 | name: _permissionStrings[permission]!,
27 | );
28 | }
29 | }
30 |
31 | Future _openAllowAccessDialog({
32 | required BuildContext context,
33 | required String name,
34 | }) async {
35 | showDialog(
36 | context: context,
37 | builder: (context) => AlertDialog(
38 | title: Text('$name permission required'),
39 | content: Text('Tap Settings and allow $name permission.'),
40 | actions: [
41 | TextButton(
42 | onPressed: () => Navigator.pop(context),
43 | child: const Text('CANCEL'),
44 | ),
45 | TextButton(
46 | onPressed: () {
47 | openAppSettings();
48 | Navigator.pop(context);
49 | },
50 | child: const Text('SETTINGS'),
51 | ),
52 | ],
53 | ),
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/lib/drawing/ui/toolbar.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:matrix4_transform/matrix4_transform.dart';
3 | import 'package:mooltik/drawing/data/lasso/lasso_model.dart';
4 | import 'package:mooltik/drawing/data/toolbox/toolbox_model.dart';
5 | import 'package:mooltik/drawing/data/toolbox/tools/bucket.dart';
6 | import 'package:mooltik/drawing/data/toolbox/tools/tools.dart';
7 | import 'package:mooltik/drawing/ui/color_button.dart';
8 | import 'package:provider/provider.dart';
9 | import 'package:mooltik/drawing/ui/tool_button.dart';
10 |
11 | class Toolbar extends StatelessWidget {
12 | const Toolbar({Key? key}) : super(key: key);
13 |
14 | @override
15 | Widget build(BuildContext context) {
16 | final toolbox = context.watch();
17 |
18 | return Row(
19 | mainAxisAlignment: MainAxisAlignment.center,
20 | children: [
21 | ToolButton(
22 | tool: toolbox.bucket,
23 | iconTransform:
24 | Matrix4Transform().scale(1.15, origin: Offset(52 / 2, 0)).m,
25 | selected: toolbox.selectedTool is Bucket,
26 | ),
27 | ToolButton(
28 | tool: toolbox.paintBrush,
29 | selected: toolbox.selectedTool is PaintBrush,
30 | ),
31 | ColorButton(),
32 | ToolButton(
33 | tool: toolbox.eraser,
34 | selected: toolbox.selectedTool is Eraser,
35 | ),
36 | ToolButton(
37 | tool: toolbox.lasso,
38 | selected: toolbox.selectedTool is Lasso,
39 | onTap: () {
40 | context.read().endTransformMode();
41 | },
42 | ),
43 | ],
44 | );
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/lib/common/data/project/project_save_data.dart:
--------------------------------------------------------------------------------
1 | import 'package:mooltik/common/data/project/scene.dart';
2 | import 'package:mooltik/common/data/project/sound_clip.dart';
3 |
4 | class ProjectSaveData {
5 | ProjectSaveData({
6 | required this.width,
7 | required this.height,
8 | required this.scenes,
9 | required this.sounds,
10 | });
11 |
12 | ProjectSaveData.fromJson(
13 | Map json,
14 | String frameDirPath,
15 | String soundDirPath,
16 | ) : width = (json[widthKey] as num).toInt(),
17 | height = (json[heightKey] as num).toInt(),
18 | scenes = (json[scenesKey] as List)
19 | .map((d) => Scene.fromJson(
20 | d,
21 | frameDirPath,
22 | (json[widthKey] as num).toInt(),
23 | (json[heightKey] as num).toInt(),
24 | ))
25 | .toList(),
26 | sounds = json[soundsKey] != null
27 | ? (json[soundsKey] as List)
28 | .map((d) => SoundClip.fromJson(d, soundDirPath))
29 | .toList()
30 | : [];
31 |
32 | Map toJson() => {
33 | widthKey: width,
34 | heightKey: height,
35 | scenesKey: scenes.map((d) => d.toJson()).toList(),
36 | soundsKey: sounds.map((d) => d.toJson()).toList(),
37 | };
38 |
39 | final int width;
40 | final int height;
41 | final List scenes;
42 | final List sounds;
43 |
44 | static const String widthKey = 'width';
45 | static const String heightKey = 'height';
46 | static const String scenesKey = 'scenes';
47 | static const String soundsKey = 'sounds';
48 | }
49 |
--------------------------------------------------------------------------------
/lib/drawing/ui/painted_glass.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:mooltik/common/data/project/base_image.dart';
3 |
4 | const frostedGlassColor = Color(0x88A09F9F);
5 |
6 | class PaintedGlass extends StatelessWidget {
7 | const PaintedGlass({
8 | Key? key,
9 | required this.image,
10 | }) : super(key: key);
11 |
12 | final BaseImage image;
13 |
14 | @override
15 | Widget build(BuildContext context) {
16 | return RepaintBoundary(
17 | child: AspectRatio(
18 | aspectRatio: 16 / 9,
19 | child: ColoredBox(
20 | color: frostedGlassColor,
21 | child: FittedBox(
22 | fit: BoxFit.cover,
23 | clipBehavior: Clip.hardEdge,
24 | child: AnimatedBuilder(
25 | animation: image,
26 | builder: (context, child) {
27 | return CustomPaint(
28 | size: image.size,
29 | isComplex: true,
30 | painter: _ImagePainter(image),
31 | );
32 | },
33 | ),
34 | ),
35 | ),
36 | ),
37 | );
38 | }
39 | }
40 |
41 | class _ImagePainter extends CustomPainter {
42 | _ImagePainter(this.image);
43 |
44 | final BaseImage image;
45 |
46 | @override
47 | void paint(Canvas canvas, Size size) {
48 | image.draw(
49 | canvas,
50 | Offset.zero,
51 | Paint()
52 | ..isAntiAlias = true
53 | ..filterQuality = FilterQuality.low,
54 | );
55 | }
56 |
57 | @override
58 | bool shouldRepaint(_ImagePainter oldDelegate) => true;
59 |
60 | @override
61 | bool shouldRebuildSemantics(_ImagePainter oldDelegate) => false;
62 | }
63 |
--------------------------------------------------------------------------------
/lib/editing/ui/export/export_images_form.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:mooltik/editing/ui/export/frame_picker.dart';
3 | import 'package:mooltik/editing/ui/export/open_edit_file_name_dialog.dart';
4 | import 'package:provider/provider.dart';
5 | import 'package:mooltik/editing/data/export/exporter_model.dart';
6 | import 'package:mooltik/common/ui/editable_field.dart';
7 |
8 | class ExportImagesForm extends StatelessWidget {
9 | const ExportImagesForm({
10 | Key? key,
11 | }) : super(key: key);
12 |
13 | @override
14 | Widget build(BuildContext context) {
15 | final exporter = context.watch();
16 | final numberOfSelectedFrames = exporter.selectedFrames.length;
17 |
18 | return Column(
19 | children: [
20 | EditableField(
21 | label: 'File name',
22 | text: '${exporter.fileName}.zip',
23 | onTap: () => openEditFileNameDialog(context),
24 | ),
25 | EditableField(
26 | label: 'Selected frames',
27 | text: '$numberOfSelectedFrames',
28 | onTap: () => _openSelectedFramesDialog(context),
29 | ),
30 | ],
31 | );
32 | }
33 |
34 | void _openSelectedFramesDialog(BuildContext context) {
35 | final exporter = context.read();
36 |
37 | Navigator.of(context).push(
38 | MaterialPageRoute(
39 | fullscreenDialog: true,
40 | builder: (context) => FramesPicker(
41 | framesSceneByScene: exporter.imagesExportFrames,
42 | initialSelectedFrames: exporter.selectedFrames,
43 | onSubmit: (selected) {
44 | exporter.selectedFrames = selected;
45 | },
46 | ),
47 | ),
48 | );
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/lib/common/ui/editable_field.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 |
3 | class EditableField extends StatelessWidget {
4 | const EditableField({
5 | Key? key,
6 | required this.label,
7 | required this.text,
8 | this.onTap,
9 | }) : super(key: key);
10 |
11 | final String label;
12 | final String text;
13 | final VoidCallback? onTap;
14 |
15 | @override
16 | Widget build(BuildContext context) {
17 | return InkWell(
18 | onTap: onTap,
19 | splashColor: Colors.transparent,
20 | borderRadius: BorderRadius.circular(8),
21 | child: Padding(
22 | padding: const EdgeInsets.all(8),
23 | child: Row(
24 | crossAxisAlignment: CrossAxisAlignment.center,
25 | children: [
26 | Expanded(
27 | child: _buildContent(context),
28 | ),
29 | Icon(
30 | Icons.edit,
31 | size: 20,
32 | color: Theme.of(context).colorScheme.secondary,
33 | ),
34 | ],
35 | ),
36 | ),
37 | );
38 | }
39 |
40 | Widget _buildContent(BuildContext context) {
41 | return Column(
42 | crossAxisAlignment: CrossAxisAlignment.stretch,
43 | children: [
44 | Text(
45 | label,
46 | style: TextStyle(
47 | fontSize: 12,
48 | color: Theme.of(context).colorScheme.secondary,
49 | ),
50 | ),
51 | SizedBox(height: 4),
52 | Text(
53 | text,
54 | overflow: TextOverflow.ellipsis,
55 | style: TextStyle(
56 | fontSize: 16,
57 | color: Theme.of(context).colorScheme.onSurface,
58 | ),
59 | ),
60 | ],
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/lib/editing/ui/timeline/view/sliver/image_sliver.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:mooltik/common/data/project/base_image.dart';
3 | import 'package:mooltik/drawing/ui/painted_glass.dart';
4 | import 'package:mooltik/editing/ui/timeline/view/sliver/sliver.dart';
5 |
6 | class ImageSliver extends Sliver {
7 | ImageSliver({
8 | required Rect area,
9 | required this.image,
10 | this.ghost = false,
11 | }) : super(area);
12 |
13 | final BaseImage image;
14 | final bool ghost;
15 |
16 | @override
17 | void paint(Canvas canvas) {
18 | final opacity = ghost ? 0.3 : 1.0;
19 |
20 | final backgroundColor =
21 | frostedGlassColor.withOpacity(frostedGlassColor.opacity * opacity);
22 | canvas.drawRRect(rrect, Paint()..color = backgroundColor);
23 |
24 | _paintThumbnail(canvas, opacity);
25 | }
26 |
27 | void _paintThumbnail(Canvas canvas, double opacity) {
28 | canvas.save();
29 | canvas.clipRRect(rrect);
30 | canvas.translate(area.left, area.top);
31 | final double scaleFactor = area.height / image.height;
32 | canvas.scale(scaleFactor);
33 |
34 | final sliverWidth = rrect.width / scaleFactor;
35 | final paint = Paint()..color = Colors.black.withOpacity(opacity);
36 |
37 | if (image.width.toDouble() > sliverWidth) {
38 | // Center thumbnail if it overflows sliver.
39 | final xOffset = (sliverWidth - image.width) / 2;
40 | image.draw(canvas, Offset(xOffset, 0), paint);
41 | } else {
42 | // Repeat thumbnails until overflow.
43 | for (double xOffset = 0; xOffset < sliverWidth; xOffset += image.width) {
44 | image.draw(canvas, Offset(xOffset, 0), paint);
45 | }
46 | }
47 |
48 | canvas.restore();
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/ios/Runner/Base.lproj/Main.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/lib/ffi_bridge.dart:
--------------------------------------------------------------------------------
1 | import 'dart:ffi';
2 | import 'dart:io';
3 | import 'dart:typed_data';
4 | import 'package:ffi/ffi.dart';
5 |
6 | typedef _NativeFloodFill = Int32 Function(
7 | Pointer pixelsPointer,
8 | Int32 width,
9 | Int32 height,
10 | Int32 x,
11 | Int32 y,
12 | Int32 fillColor,
13 | );
14 |
15 | typedef _DartFloodFill = int Function(
16 | Pointer pixelsPointer,
17 | int width,
18 | int height,
19 | int x,
20 | int y,
21 | int fillColor,
22 | );
23 |
24 | class FFIBridge {
25 | FFIBridge() {
26 | final dl = _getLibrary();
27 | _floodFill =
28 | dl.lookupFunction<_NativeFloodFill, _DartFloodFill>('flood_fill');
29 | }
30 |
31 | DynamicLibrary _getLibrary() {
32 | if (Platform.environment.containsKey('FLUTTER_TEST')) {
33 | return DynamicLibrary.open('build/test/libimage.dylib');
34 | }
35 | if (Platform.isAndroid) {
36 | return DynamicLibrary.open('libimage.so');
37 | }
38 | return DynamicLibrary.process(); // iOS
39 | }
40 |
41 | late _DartFloodFill _floodFill;
42 |
43 | /// Floods the 4-connected color area with another color.
44 | /// Returns 0 if successful.
45 | /// Returns -1 if cancelled.
46 | int floodFill(
47 | Uint32List pixels,
48 | int width,
49 | int height,
50 | int x,
51 | int y,
52 | int fillColor,
53 | ) {
54 | final pixelsPointer = malloc(pixels.length);
55 | final pointerList = pixelsPointer.asTypedList(pixels.length);
56 | pointerList.setAll(0, pixels);
57 |
58 | final exitCode = _floodFill(
59 | pixelsPointer,
60 | width,
61 | height,
62 | x,
63 | y,
64 | fillColor,
65 | );
66 |
67 | if (exitCode == 0) pixels.setAll(0, pointerList);
68 | malloc.free(pixelsPointer);
69 | return exitCode;
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/lib/common/ui/paint_text.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 |
3 | /// Paints text on the canvas.
4 | void paintText(
5 | Canvas canvas, {
6 | required String text,
7 | required Offset anchorCoordinate,
8 | Alignment anchorAlignment = Alignment.center,
9 | TextStyle? style,
10 | }) {
11 | final TextPainter painter = makeTextPainter(text, style);
12 | paintWithTextPainter(
13 | canvas,
14 | painter: painter,
15 | anchorCoordinate: anchorCoordinate,
16 | anchorAlignment: anchorAlignment,
17 | );
18 | }
19 |
20 | /// Constructs a text painter and performs layout.
21 | ///
22 | /// Use this in combination with `paintWithTextPainter`,
23 | /// if you need to know text size on the canvas.
24 | ///
25 | /// e.g.
26 | /// ```
27 | /// final tp = makeTextPainter('Hello', style);
28 | /// final w = tp.width; // text width
29 | /// final h = tp.height; // text height
30 | ///
31 | /// paintWithTextPainter(
32 | /// canvas,
33 | /// painter: tp,
34 | /// anchor: Offset(0, 0),
35 | /// anchorAlignment: Alignment.centerRight,
36 | /// );
37 | /// ```
38 | TextPainter makeTextPainter(String text, TextStyle? style) {
39 | final TextSpan span = TextSpan(
40 | text: text,
41 | style: style,
42 | );
43 | return TextPainter(
44 | text: span,
45 | textDirection: TextDirection.ltr,
46 | )..layout();
47 | }
48 |
49 | /// Paints on the canvas with the given text painter.
50 | void paintWithTextPainter(
51 | Canvas canvas, {
52 | required TextPainter painter,
53 | required Offset anchorCoordinate,
54 | Alignment anchorAlignment = Alignment.center,
55 | }) {
56 | painter.paint(
57 | canvas,
58 | Offset(
59 | anchorCoordinate.dx - painter.width / 2 * (anchorAlignment.x + 1),
60 | anchorCoordinate.dy - painter.height / 2 * (anchorAlignment.y + 1),
61 | ),
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/lib/editing/ui/timeline/view/sliver/video_sliver.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:mooltik/common/data/project/composite_image.dart';
3 | import 'package:mooltik/editing/ui/timeline/view/sliver/sliver.dart';
4 |
5 | typedef ThumbnailAt = CompositeImage Function(double x);
6 |
7 | class VideoSliver extends Sliver {
8 | VideoSliver({
9 | required Rect area,
10 | required this.thumbnailAt,
11 | }) : super(area);
12 |
13 | /// Image at a given X coordinate.
14 | final ThumbnailAt thumbnailAt;
15 |
16 | @override
17 | void paint(Canvas canvas) {
18 | canvas.drawRRect(rrect, Paint()..color = Colors.white);
19 |
20 | canvas.save();
21 | canvas.clipRRect(rrect);
22 |
23 | for (var x = area.left; x < area.right; x += area.height) {
24 | // Separator.
25 | canvas.drawLine(
26 | Offset(x, area.top),
27 | Offset(x, area.bottom),
28 | Paint()..strokeWidth = 0.5,
29 | );
30 |
31 | _paintCenteredThumbnail(
32 | canvas,
33 | thumbnailAt(x),
34 | Rect.fromLTRB(x, area.top, x + area.height, area.bottom),
35 | );
36 | }
37 |
38 | canvas.restore();
39 | }
40 |
41 | void _paintCenteredThumbnail(
42 | Canvas canvas,
43 | CompositeImage thumbnail,
44 | Rect paintArea,
45 | ) {
46 | canvas.save();
47 | canvas.clipRect(paintArea);
48 | canvas.translate(paintArea.left, paintArea.top);
49 | final scaleFactor = paintArea.height / thumbnail.height;
50 | canvas.scale(scaleFactor);
51 |
52 | final centeringOffset = Offset(
53 | -thumbnail.width / 2 + paintArea.width / scaleFactor / 2,
54 | 0,
55 | );
56 |
57 | canvas.drawCompositeImage(
58 | thumbnail,
59 | centeringOffset,
60 | Paint(),
61 | );
62 |
63 | canvas.restore();
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/lib/editing/data/player_model.dart:
--------------------------------------------------------------------------------
1 | import 'dart:io';
2 |
3 | import 'package:flutter/material.dart';
4 | import 'package:just_audio/just_audio.dart';
5 | import 'package:mooltik/common/data/project/sound_clip.dart';
6 | import 'package:mooltik/editing/data/timeline/timeline_model.dart';
7 |
8 | Future getSoundFileDuration(File soundFile) async {
9 | final player = AudioPlayer();
10 | final duration = await player.setFilePath(soundFile.path, preload: true);
11 | await player.dispose();
12 | return duration;
13 | }
14 |
15 | class PlayerModel extends ChangeNotifier {
16 | PlayerModel({
17 | required this.soundClips,
18 | TimelineModel? timeline,
19 | }) : _player = AudioPlayer(),
20 | _timeline = timeline {
21 | _timeline!.addListener(_timelineListener);
22 | }
23 |
24 | void _timelineListener() {
25 | if (_timeline!.isPlaying == _player.playing) return;
26 |
27 | if (_timeline!.isPlaying) {
28 | _player.play();
29 | } else {
30 | _player.stop();
31 | }
32 | }
33 |
34 | @override
35 | void dispose() {
36 | _timeline?.removeListener(_timelineListener);
37 | _player.dispose();
38 | super.dispose();
39 | }
40 |
41 | AudioPlayer _player;
42 |
43 | /// List of sound clips to play.
44 | final List? soundClips;
45 |
46 | /// Reference for listening to play/pause state and playing position.
47 | TimelineModel? _timeline;
48 |
49 | /// Prepare player for playing from current playhead position.
50 | Future prepare() async {
51 | if (soundClips!.isEmpty) {
52 | _player.dispose();
53 | _player = AudioPlayer();
54 | return;
55 | }
56 |
57 | await _player.setFilePath(
58 | soundClips!.first.path,
59 | initialPosition: _timeline!.playheadPosition,
60 | preload: true,
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/test/make_duplicate_path_test.dart:
--------------------------------------------------------------------------------
1 | import 'dart:io';
2 |
3 | import 'package:flutter_test/flutter_test.dart';
4 | import 'package:mooltik/common/data/io/make_duplicate_path.dart';
5 |
6 | void main() {
7 | group('makeFreeDuplicatePath', () {
8 | test('should increment if next path is occupied', () {
9 | final tempFile = File('./image_1.png')..createSync(recursive: true);
10 |
11 | expect(
12 | makeFreeDuplicatePath('./image.png'),
13 | './image_2.png',
14 | );
15 |
16 | tempFile.deleteSync(recursive: true);
17 | });
18 |
19 | test('should increment until path is free', () {
20 | final tempFile = File('./image_1.png')..createSync(recursive: true);
21 | final tempFile2 = File('./image_2.png')..createSync(recursive: true);
22 |
23 | expect(
24 | makeFreeDuplicatePath('./image.png'),
25 | './image_3.png',
26 | );
27 |
28 | tempFile.deleteSync(recursive: true);
29 | tempFile2.deleteSync(recursive: true);
30 | });
31 | });
32 |
33 | group('makeDuplicatePath', () {
34 | test('should add a counter when there is none', () {
35 | expect(
36 | makeDuplicatePath('example/path/image.png'),
37 | 'example/path/image_1.png',
38 | );
39 | });
40 |
41 | test('should increment an existing counter', () {
42 | expect(
43 | makeDuplicatePath('example/path/image_1.png'),
44 | 'example/path/image_2.png',
45 | );
46 |
47 | expect(
48 | makeDuplicatePath('example/path/image_2.png'),
49 | 'example/path/image_3.png',
50 | );
51 | });
52 |
53 | test('should increment an existing counter with a large value', () {
54 | expect(
55 | makeDuplicatePath('example/path/frame123123123_99999999.png'),
56 | 'example/path/frame123123123_100000000.png',
57 | );
58 | });
59 | });
60 | }
61 |
--------------------------------------------------------------------------------
/lib/drawing/ui/color_button.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:mooltik/common/ui/open_side_sheet.dart';
3 | import 'package:mooltik/drawing/ui/color_picker.dart';
4 | import 'package:provider/provider.dart';
5 | import 'package:mooltik/drawing/data/toolbox/toolbox_model.dart';
6 |
7 | class ColorButton extends StatelessWidget {
8 | const ColorButton({Key? key}) : super(key: key);
9 |
10 | @override
11 | Widget build(BuildContext context) {
12 | final toolbox = context.watch();
13 |
14 | return SizedBox(
15 | width: 52,
16 | height: 44,
17 | child: InkWell(
18 | splashColor: Colors.transparent,
19 | onTap: () {
20 | openSideSheet(
21 | context: context,
22 | builder: (context) => ChangeNotifierProvider.value(
23 | value: toolbox,
24 | child: ColorPicker(
25 | initialColor: toolbox.hsvColor,
26 | onSelected: (HSVColor color) {
27 | toolbox.changeColor(color);
28 | },
29 | ),
30 | ),
31 | landscapeSide: Side.top,
32 | portraitSide: Side.right,
33 | );
34 | },
35 | child: Center(
36 | child: _ColorIndicator(color: toolbox.color),
37 | ),
38 | ),
39 | );
40 | }
41 | }
42 |
43 | class _ColorIndicator extends StatelessWidget {
44 | const _ColorIndicator({
45 | Key? key,
46 | required this.color,
47 | }) : super(key: key);
48 |
49 | final Color color;
50 |
51 | @override
52 | Widget build(BuildContext context) {
53 | return Container(
54 | width: 28,
55 | height: 28,
56 | decoration: BoxDecoration(
57 | color: color,
58 | border: Border.all(width: 2, color: Colors.white),
59 | shape: BoxShape.circle,
60 | ),
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/lib/editing/ui/preview/preview.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:mooltik/common/data/project/composite_image.dart';
3 | import 'package:mooltik/common/ui/composite_image_painter.dart';
4 | import 'package:mooltik/drawing/drawing_page.dart';
5 | import 'package:mooltik/editing/data/timeline/timeline_model.dart';
6 | import 'package:mooltik/common/data/project/project.dart';
7 | import 'package:mooltik/editing/data/timeline/timeline_view_model.dart';
8 | import 'package:provider/provider.dart';
9 |
10 | class Preview extends StatelessWidget {
11 | @override
12 | Widget build(BuildContext context) {
13 | context.watch(); // Listen to visibility toggle.
14 |
15 | return ColoredBox(
16 | color: Colors.black,
17 | child: Center(
18 | child: Listener(
19 | onPointerDown: (_) {
20 | final project = context.read();
21 | final timeline = context.read();
22 |
23 | if (timeline.isPlaying) return;
24 |
25 | Navigator.of(context).push(
26 | MaterialPageRoute(
27 | builder: (context) => MultiProvider(
28 | providers: [
29 | ChangeNotifierProvider.value(value: project),
30 | ChangeNotifierProvider.value(value: timeline),
31 | ],
32 | child: DrawingPage(),
33 | ),
34 | ),
35 | );
36 | },
37 | child: FittedBox(
38 | fit: BoxFit.contain,
39 | child: _buildImage(context),
40 | ),
41 | ),
42 | ),
43 | );
44 | }
45 |
46 | Widget _buildImage(BuildContext context) {
47 | final image = context.select(
48 | (timeline) => timeline.currentFrame,
49 | );
50 | return CustomPaint(
51 | size: image.size,
52 | painter: CompositeImagePainter(image),
53 | );
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/lib/common/ui/app_icon_button.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:flutter_svg/svg.dart';
3 |
4 | class AppIconButton extends StatelessWidget {
5 | const AppIconButton({
6 | Key? key,
7 | required this.icon,
8 | this.iconSize = 20,
9 | this.iconTransform,
10 | this.selected = false,
11 | this.onTap,
12 | }) : svgPath = null,
13 | super(key: key);
14 |
15 | const AppIconButton.svg({
16 | Key? key,
17 | required this.svgPath,
18 | this.selected = false,
19 | this.onTap,
20 | }) : icon = null,
21 | iconSize = null,
22 | iconTransform = null,
23 | super(key: key);
24 |
25 | final IconData? icon;
26 | final double? iconSize;
27 | final Matrix4? iconTransform;
28 |
29 | final String? svgPath;
30 |
31 | final bool selected;
32 | final VoidCallback? onTap;
33 |
34 | @override
35 | Widget build(BuildContext context) {
36 | return Material(
37 | type: MaterialType.transparency,
38 | child: InkResponse(
39 | splashColor: Colors.transparent,
40 | onTap: onTap,
41 | child: SizedBox(
42 | height: 44,
43 | width: 52,
44 | child: svgPath != null
45 | ? SvgPicture.asset(
46 | svgPath!,
47 | fit: BoxFit.none,
48 | color: _getColor(context),
49 | )
50 | : Transform(
51 | transform: iconTransform ?? Matrix4.identity(),
52 | child: Icon(
53 | icon,
54 | size: iconSize,
55 | color: _getColor(context),
56 | ),
57 | ),
58 | ),
59 | ),
60 | );
61 | }
62 |
63 | Color _getColor(BuildContext context) {
64 | if (selected) return Theme.of(context).colorScheme.primary;
65 | if (onTap == null) return Theme.of(context).disabledColor;
66 | return Theme.of(context).colorScheme.onSurface;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/lib/drawing/ui/canvas_painter.dart:
--------------------------------------------------------------------------------
1 | import 'dart:ui' as ui;
2 |
3 | import 'package:flutter/material.dart';
4 | import 'package:mooltik/drawing/data/frame/stroke.dart';
5 |
6 | class CanvasPainter extends CustomPainter {
7 | CanvasPainter({
8 | required this.image,
9 | this.strokes,
10 | this.filter,
11 | });
12 |
13 | final ui.Image? image;
14 | final List? strokes;
15 | final ColorFilter? filter;
16 |
17 | @override
18 | void paint(Canvas canvas, Size size) {
19 | final img = image; // For nullability analysis.
20 | if (img == null) return;
21 |
22 | final canvasArea = Rect.fromLTWH(0, 0, size.width, size.height);
23 |
24 | // Clip paint outside canvas.
25 | canvas.clipRect(canvasArea);
26 |
27 | // Scale image to fit the given size.
28 | canvas.scale(size.width / img.width, size.height / img.height);
29 |
30 | final shouldBlendLayers = strokes != null &&
31 | strokes!.isNotEmpty &&
32 | strokes!.any((stroke) => stroke.paint.blendMode != BlendMode.srcOver);
33 |
34 | if (shouldBlendLayers) {
35 | // Save layer to erase paintings on it with `BlendMode.clear`.
36 | canvas.saveLayer(
37 | Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble()),
38 | Paint(),
39 | );
40 | }
41 |
42 | canvas.drawImage(
43 | img,
44 | Offset.zero,
45 | Paint()
46 | ..colorFilter = filter
47 | ..isAntiAlias = true
48 | ..filterQuality = FilterQuality.low,
49 | );
50 |
51 | strokes?.forEach((stroke) => stroke.paintOn(canvas));
52 |
53 | if (shouldBlendLayers) {
54 | // Flatten layer. Combine drawing lines with erasing lines.
55 | canvas.restore();
56 | }
57 | }
58 |
59 | @override
60 | bool shouldRepaint(CanvasPainter oldDelegate) =>
61 | oldDelegate.image != image ||
62 | oldDelegate.strokes != strokes ||
63 | oldDelegate.filter != filter;
64 |
65 | @override
66 | bool shouldRebuildSemantics(CanvasPainter oldDelegate) => false;
67 | }
68 |
--------------------------------------------------------------------------------
/lib/editing/ui/timeline/timeline_panel.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:mooltik/editing/data/timeline/timeline_view_model.dart';
3 | import 'package:provider/provider.dart';
4 | import 'package:mooltik/editing/data/editor_model.dart';
5 | import 'package:mooltik/editing/ui/timeline/board_view.dart';
6 | import 'package:mooltik/editing/ui/timeline/timeline_view_button.dart';
7 | import 'package:mooltik/editing/ui/timeline/view/timeline_view.dart';
8 | import 'package:mooltik/editing/ui/timeline/actionbar/timeline_actionbar.dart';
9 |
10 | class TimelinePanel extends StatelessWidget {
11 | const TimelinePanel({
12 | Key? key,
13 | }) : super(key: key);
14 |
15 | @override
16 | Widget build(BuildContext context) {
17 | final editor = context.watch();
18 |
19 | final safePadding = MediaQuery.of(context).padding;
20 |
21 | return Material(
22 | elevation: 0,
23 | color: Theme.of(context).colorScheme.surface,
24 | child: Column(
25 | children: [
26 | SafeArea(
27 | top: false,
28 | bottom: false,
29 | child: TimelineActionbar(),
30 | ),
31 | Expanded(
32 | child: Stack(
33 | fit: StackFit.expand,
34 | children: [
35 | AnimatedSwitcher(
36 | duration: const Duration(milliseconds: 200),
37 | child: editor.isTimelineView ? TimelineView() : BoardView(),
38 | ),
39 | if (!context.watch().isEditingScene)
40 | Positioned(
41 | bottom: 8 + safePadding.bottom,
42 | left: 4 + safePadding.left,
43 | child: TimelineViewButton(
44 | showTimelineIcon: !editor.isTimelineView,
45 | onTap: editor.switchView,
46 | ),
47 | ),
48 | ],
49 | ),
50 | ),
51 | ],
52 | ),
53 | );
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/lib/common/ui/labeled_icon_button.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 |
3 | class LabeledIconButton extends StatelessWidget {
4 | const LabeledIconButton({
5 | Key? key,
6 | required this.icon,
7 | this.iconSize,
8 | this.iconTransform,
9 | required this.label,
10 | this.color,
11 | this.onTap,
12 | }) : super(key: key);
13 |
14 | final IconData icon;
15 | final double? iconSize;
16 | final Matrix4? iconTransform;
17 | final String label;
18 | final Color? color;
19 | final VoidCallback? onTap;
20 |
21 | @override
22 | Widget build(BuildContext context) {
23 | return Padding(
24 | padding: const EdgeInsets.symmetric(horizontal: 8.0),
25 | child: InkResponse(
26 | radius: 56,
27 | onTap: onTap,
28 | child: Opacity(
29 | opacity: onTap == null ? 0.5 : 1,
30 | child: SizedBox(
31 | width: 56,
32 | height: 56,
33 | child: Column(
34 | mainAxisAlignment: MainAxisAlignment.center,
35 | crossAxisAlignment: CrossAxisAlignment.center,
36 | children: [
37 | Transform(
38 | transform: iconTransform ?? Matrix4.identity(),
39 | child: Icon(
40 | icon,
41 | size: iconSize ?? 18,
42 | color: color ?? Theme.of(context).colorScheme.onPrimary,
43 | ),
44 | ),
45 | SizedBox(height: 6),
46 | Text(
47 | label,
48 | style: TextStyle(
49 | fontSize: 10,
50 | fontWeight: FontWeight.w700,
51 | color: color ?? Theme.of(context).colorScheme.onPrimary,
52 | ),
53 | textAlign: TextAlign.center,
54 | softWrap: false,
55 | overflow: TextOverflow.visible,
56 | ),
57 | ],
58 | ),
59 | ),
60 | ),
61 | ),
62 | );
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/lib/editing/ui/export/export_form.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/cupertino.dart';
2 | import 'package:flutter/material.dart';
3 | import 'package:mooltik/editing/data/export/exporter_model.dart';
4 | import 'package:mooltik/editing/ui/export/export_images_form.dart';
5 | import 'package:mooltik/editing/ui/export/export_video_form.dart';
6 |
7 | class ExportForm extends StatelessWidget {
8 | const ExportForm({
9 | Key? key,
10 | required this.selectedOption,
11 | required this.onValueChanged,
12 | }) : super(key: key);
13 |
14 | final ExportOption selectedOption;
15 | final ValueChanged onValueChanged;
16 |
17 | @override
18 | Widget build(BuildContext context) {
19 | return Column(
20 | crossAxisAlignment: CrossAxisAlignment.stretch,
21 | children: [
22 | _buildTitle(),
23 | SizedBox(height: 8),
24 | _buildOptionMenu(),
25 | SizedBox(height: 8),
26 | _buildForm(),
27 | SizedBox(height: 8),
28 | ],
29 | );
30 | }
31 |
32 | Widget _buildTitle() {
33 | return Padding(
34 | padding: const EdgeInsets.all(8.0),
35 | child: Text(
36 | 'Export as',
37 | style: TextStyle(
38 | fontSize: 20,
39 | fontWeight: FontWeight.w500,
40 | ),
41 | ),
42 | );
43 | }
44 |
45 | Widget _buildOptionMenu() {
46 | return CupertinoSlidingSegmentedControl(
47 | backgroundColor: Colors.black.withOpacity(0.25),
48 | groupValue: selectedOption,
49 | children: {
50 | ExportOption.video: Text('Video'),
51 | ExportOption.images: Text('Images'),
52 | },
53 | onValueChanged: onValueChanged,
54 | );
55 | }
56 |
57 | Widget _buildForm() {
58 | return AnimatedCrossFade(
59 | duration: Duration(milliseconds: 300),
60 | crossFadeState: selectedOption == ExportOption.video
61 | ? CrossFadeState.showFirst
62 | : CrossFadeState.showSecond,
63 | firstChild: ExportVideoForm(),
64 | secondChild: ExportImagesForm(),
65 | );
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/lib/drawing/ui/layers/animated_layer_preview.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 |
3 | import 'package:flutter/material.dart';
4 | import 'package:mooltik/common/data/project/fps_config.dart';
5 | import 'package:mooltik/common/data/project/frame_interface.dart';
6 | import 'package:mooltik/drawing/ui/painted_glass.dart';
7 |
8 | class AnimatedLayerPreview extends StatefulWidget {
9 | AnimatedLayerPreview({
10 | Key? key,
11 | required this.frames,
12 | this.frameDuration = const Duration(milliseconds: singleFrameMs * 5),
13 | this.pingPong = false,
14 | }) : super(key: key);
15 |
16 | final List frames;
17 | final Duration frameDuration;
18 | final bool pingPong;
19 |
20 | @override
21 | AnimatedLayerPreviewState createState() => AnimatedLayerPreviewState();
22 | }
23 |
24 | class AnimatedLayerPreviewState extends State {
25 | int _frameIndex = 0;
26 | late Timer _timer;
27 | bool _playForward = true; // Used to control ping-pong animation.
28 |
29 | @override
30 | void initState() {
31 | super.initState();
32 | _timer = Timer(widget.frameDuration, _tick);
33 | }
34 |
35 | void _tick() {
36 | setState(() {
37 | _frameIndex = widget.pingPong
38 | ? _nextFrameIndexForPingPong()
39 | : _nextFrameIndexForLoop();
40 | });
41 | _timer = Timer(widget.frameDuration, _tick);
42 | }
43 |
44 | int _nextFrameIndexForLoop() => (_frameIndex + 1) % widget.frames.length;
45 |
46 | int _nextFrameIndexForPingPong() {
47 | final step = _playForward ? 1 : -1;
48 |
49 | if (_isValidFrameIndex(_frameIndex + step)) {
50 | return _frameIndex + step;
51 | } else {
52 | _playForward = !_playForward;
53 | return _frameIndex;
54 | }
55 | }
56 |
57 | bool _isValidFrameIndex(int index) =>
58 | index >= 0 && index < widget.frames.length;
59 |
60 | @override
61 | void dispose() {
62 | _timer.cancel();
63 | super.dispose();
64 | }
65 |
66 | @override
67 | Widget build(BuildContext context) {
68 | return PaintedGlass(image: widget.frames[_frameIndex].image);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/test/save_data_transcoder_test.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 | import 'dart:io';
3 |
4 | import 'package:flutter_test/flutter_test.dart';
5 | import 'package:mooltik/common/data/project/sava_data_transcoder.dart';
6 |
7 | String testData(String fileName) {
8 | return File('./test/project_data/$fileName')
9 | .readAsStringSync()
10 | .replaceAll(RegExp(r'\s'), '');
11 | }
12 |
13 | void main() {
14 | group('SaveDataTranscoder should', () {
15 | test('transcode A to the latest', () {
16 | final transcoder = SaveDataTranscoder();
17 | final data_v0_8 = testData('a_v0_8.json');
18 | final data_v1_0 = testData('a_v1_0.json');
19 | final transcodedJson =
20 | transcoder.transcodeToLatest(jsonDecode(data_v0_8));
21 | expect(
22 | jsonEncode(transcodedJson),
23 | data_v1_0,
24 | );
25 | });
26 |
27 | test('transcode C to the latest', () {
28 | final transcoder = SaveDataTranscoder();
29 | final data_v0_9 = testData('c_v0_9.json');
30 | final data_v1_0 = testData('c_v1_0.json');
31 | final transcodedJson =
32 | transcoder.transcodeToLatest(jsonDecode(data_v0_9));
33 | expect(
34 | jsonEncode(transcodedJson),
35 | data_v1_0,
36 | );
37 | });
38 |
39 | test('transcode v0.8 to v0.9', () {
40 | final transcoder = SaveDataTranscoder();
41 | final data_v0_8 = testData('a_v0_8.json');
42 | final data_v0_9 = testData('a_v0_9.json');
43 | final transcodedJson =
44 | transcoder.convert_v0_8_to_v0_9(jsonDecode(data_v0_8));
45 | expect(
46 | jsonEncode(transcodedJson),
47 | data_v0_9,
48 | );
49 | });
50 |
51 | test('transcode v0.9 to v1.0', () {
52 | final transcoder = SaveDataTranscoder();
53 | final data_v0_9 = testData('a_v0_9.json');
54 | final data_v1_0 = testData('a_v1_0.json');
55 | final transcodedJson =
56 | transcoder.convert_v0_9_to_v1_0(jsonDecode(data_v0_9));
57 | expect(
58 | jsonEncode(transcodedJson),
59 | data_v1_0,
60 | );
61 | });
62 | });
63 | }
64 |
--------------------------------------------------------------------------------
/lib/drawing/data/frame_reel_model.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 |
3 | import 'package:flutter/material.dart';
4 | import 'package:mooltik/common/data/project/frame_interface.dart';
5 | import 'package:mooltik/common/data/sequence/sequence.dart';
6 | import 'package:mooltik/drawing/data/frame/frame.dart';
7 |
8 | class FrameReelModel extends ChangeNotifier {
9 | FrameReelModel({
10 | required this.frameSeq,
11 | }) : _currentIndex = frameSeq.currentIndex;
12 |
13 | final Sequence frameSeq;
14 |
15 | Frame get currentFrame => frameSeq[_currentIndex];
16 |
17 | FrameInterface get deleteDialogFrame => currentFrame;
18 |
19 | int get currentIndex => _currentIndex;
20 | int _currentIndex;
21 |
22 | void setCurrent(int index) {
23 | if (index < 0 || index >= frameSeq.length) return;
24 | _currentIndex = index;
25 | notifyListeners();
26 | }
27 |
28 | Future appendFrame() async {
29 | frameSeq.insert(
30 | frameSeq.length,
31 | await frameSeq.current.cloneEmpty(),
32 | );
33 | notifyListeners();
34 | }
35 |
36 | Future addBeforeCurrent() async {
37 | frameSeq.insert(
38 | _currentIndex,
39 | await frameSeq.current.cloneEmpty(),
40 | );
41 | _currentIndex++;
42 | notifyListeners();
43 | }
44 |
45 | Future addAfterCurrent() async {
46 | frameSeq.insert(
47 | _currentIndex + 1,
48 | await frameSeq.current.cloneEmpty(),
49 | );
50 | notifyListeners();
51 | }
52 |
53 | Future duplicateCurrent() async {
54 | if (currentFrame.image.snapshot == null) return;
55 |
56 | frameSeq.insert(
57 | _currentIndex + 1,
58 | await currentFrame.duplicate(),
59 | );
60 | notifyListeners();
61 | }
62 |
63 | bool get canDeleteCurrent => frameSeq.length > 1;
64 |
65 | void deleteCurrent() {
66 | final removedFrame = frameSeq.removeAt(_currentIndex);
67 |
68 | Future.delayed(
69 | Duration(seconds: 1),
70 | () => removedFrame.dispose(),
71 | );
72 |
73 | _currentIndex = _currentIndex.clamp(0, frameSeq.length - 1);
74 | notifyListeners();
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/lib/drawing/data/frame/frame.dart:
--------------------------------------------------------------------------------
1 | import 'dart:io';
2 |
3 | import 'package:equatable/equatable.dart';
4 | import 'package:mooltik/common/data/io/disk_image.dart';
5 | import 'package:mooltik/common/data/extensions/duration_methods.dart';
6 | import 'package:mooltik/common/data/project/fps_config.dart';
7 | import 'package:mooltik/common/data/project/frame_interface.dart';
8 | import 'package:mooltik/common/data/sequence/time_span.dart';
9 | import 'package:path/path.dart' as p;
10 |
11 | /// Single image with duration.
12 | class Frame extends TimeSpan with EquatableMixin implements FrameInterface {
13 | Frame({
14 | required this.image,
15 | Duration duration = const Duration(milliseconds: singleFrameMs * 5),
16 | }) : super(duration);
17 |
18 | final DiskImage image;
19 |
20 | Future duplicate() async {
21 | return this.copyWith(image: await image.duplicate());
22 | }
23 |
24 | /// Creates an empty frame with the same dimensions and duration.
25 | Future cloneEmpty() async {
26 | return this.copyWith(image: await image.cloneEmpty());
27 | }
28 |
29 | factory Frame.fromJson(
30 | Map json,
31 | String frameDirPath,
32 | int width,
33 | int height,
34 | ) =>
35 | Frame(
36 | image: DiskImage(
37 | file: File(p.join(frameDirPath, json[_fileNameKey])),
38 | width: width,
39 | height: height,
40 | ),
41 | duration: (json[_durationKey] as String).parseDuration(),
42 | );
43 |
44 | Map toJson() => {
45 | _fileNameKey: p.basename(image.file.path),
46 | _durationKey: duration.toString(),
47 | };
48 |
49 | @override
50 | Frame copyWith({
51 | DiskImage? image,
52 | Duration? duration,
53 | }) =>
54 | Frame(
55 | image: image ?? this.image,
56 | duration: duration ?? this.duration,
57 | );
58 |
59 | void dispose() {
60 | image.dispose();
61 | }
62 |
63 | @override
64 | List