├── android ├── settings_aar.gradle ├── gradle.properties ├── app │ ├── src │ │ ├── main │ │ │ ├── ic_launcher-playstore.png │ │ │ ├── res │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ ├── ic_launcher_background.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ ├── ic_launcher_background.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ ├── ic_launcher_background.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ ├── ic_launcher_background.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ ├── ic_launcher_background.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── drawable-hdpi │ │ │ │ │ └── ic_stat_ic_notification.png │ │ │ │ ├── drawable-mdpi │ │ │ │ │ └── ic_stat_ic_notification.png │ │ │ │ ├── drawable-xhdpi │ │ │ │ │ └── ic_stat_ic_notification.png │ │ │ │ ├── drawable-xxhdpi │ │ │ │ │ └── ic_stat_ic_notification.png │ │ │ │ ├── drawable-xxxhdpi │ │ │ │ │ └── ic_stat_ic_notification.png │ │ │ │ ├── values │ │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ │ └── styles.xml │ │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ │ ├── ic_launcher.xml │ │ │ │ │ └── ic_launcher_round.xml │ │ │ │ └── drawable │ │ │ │ │ └── launch_background.xml │ │ │ └── kotlin │ │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── record_painting │ │ │ │ └── MainActivity.kt │ │ ├── debug │ │ │ └── AndroidManifest.xml │ │ └── profile │ │ │ └── AndroidManifest.xml │ └── google-services.json ├── .gitignore ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties ├── settings.gradle └── build.gradle ├── fastlane └── metadata │ └── android │ └── en-US │ ├── video.txt │ ├── title.txt │ ├── short_description.txt │ ├── images │ ├── icon.png │ └── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ └── 4.png │ └── full_description.txt ├── ios ├── Runner │ ├── Runner-Bridging-Header.h │ ├── Assets.xcassets │ │ ├── LaunchImage.imageset │ │ │ ├── LaunchImage.png │ │ │ ├── LaunchImage@2x.png │ │ │ ├── LaunchImage@3x.png │ │ │ ├── README.md │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ ├── Icon-App-20x20@1x.png │ │ │ ├── Icon-App-20x20@2x.png │ │ │ ├── Icon-App-20x20@3x.png │ │ │ ├── Icon-App-29x29@1x.png │ │ │ ├── Icon-App-29x29@2x.png │ │ │ ├── Icon-App-29x29@3x.png │ │ │ ├── Icon-App-40x40@1x.png │ │ │ ├── Icon-App-40x40@2x.png │ │ │ ├── Icon-App-40x40@3x.png │ │ │ ├── Icon-App-60x60@2x.png │ │ │ ├── Icon-App-60x60@3x.png │ │ │ ├── Icon-App-76x76@1x.png │ │ │ ├── Icon-App-76x76@2x.png │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ └── Icon-App-83.5x83.5@2x.png │ ├── Runner.entitlements │ ├── AppViewController.swift │ ├── AppDelegate.swift │ └── Base.lproj │ │ └── Main.storyboard ├── Runner.xcodeproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ └── IDEWorkspaceChecks.plist ├── Flutter │ ├── Debug.xcconfig │ ├── Release.xcconfig │ └── AppFrameworkInfo.plist ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ └── IDEWorkspaceChecks.plist ├── .gitignore └── GoogleService-Info.plist ├── assets ├── readme_logo.png ├── logo_foreground.png ├── readme_screenshots.png ├── tutorial_thumbnails │ └── 1.png ├── app_icon │ └── mooltik_ios_1024.png └── icons │ ├── board.svg │ ├── timeline.svg │ ├── export.svg │ └── fa-solid_expand.svg ├── test ├── test_images │ ├── bg_red.png │ ├── bg_green.png │ ├── rabbit_pink.png │ ├── rabbit_black.png │ ├── rabbit_yellow.png │ └── flood_test │ │ ├── test_1 │ │ ├── input.png │ │ ├── actual_output.png │ │ └── expected_output.png │ │ ├── test_2 │ │ ├── input.png │ │ ├── actual_output.png │ │ └── expected_output.png │ │ ├── test_3 │ │ ├── input.png │ │ ├── actual_output.png │ │ └── expected_output.png │ │ ├── test_4 │ │ ├── input.png │ │ ├── actual_output.png │ │ └── expected_output.png │ │ ├── test_5 │ │ ├── input.png │ │ ├── actual_output.png │ │ └── expected_output.png │ │ └── test_6 │ │ ├── input.png │ │ ├── actual_output.png │ │ └── expected_output.png ├── project_data │ ├── a_v0_8.json │ ├── a_v0_9.json │ ├── a_v1_0.json │ ├── a_v1_6.json │ ├── a_v1_17.json │ ├── a_v1_13.json │ ├── a_v1_18.json │ └── c_v0_9.json ├── debouncer_test.dart ├── project_save_data_test.dart ├── make_duplicate_path_test.dart └── save_data_transcoder_test.dart ├── lib ├── drawing │ ├── data │ │ ├── toolbox │ │ │ └── tools │ │ │ │ ├── tools.dart │ │ │ │ ├── eraser.dart │ │ │ │ ├── paint_brush.dart │ │ │ │ ├── tool.dart │ │ │ │ ├── bucket.dart │ │ │ │ └── lasso.dart │ │ ├── lasso │ │ │ └── masked_image_painter.dart │ │ ├── frame │ │ │ ├── stroke.dart │ │ │ ├── selection_stroke.dart │ │ │ └── frame.dart │ │ ├── drawing_page_options_model.dart │ │ ├── onion_model.dart │ │ └── frame_reel_model.dart │ └── ui │ │ ├── layers │ │ ├── visibility_switch.dart │ │ ├── all_fingers_lifted_listener.dart │ │ ├── layer_button.dart │ │ └── animated_layer_preview.dart │ │ ├── lasso │ │ ├── transformed_image_layer.dart │ │ ├── lasso_ui_layer.dart │ │ └── transformed_image_painter.dart │ │ ├── fit_to_screen_button.dart │ │ ├── checkerboard_painter.dart │ │ ├── reel │ │ └── frame_number_box.dart │ │ ├── menu_button.dart │ │ ├── brush_tip_picker.dart │ │ ├── easel │ │ └── cursor_painter.dart │ │ ├── toolbar.dart │ │ ├── painted_glass.dart │ │ ├── color_button.dart │ │ ├── canvas_painter.dart │ │ ├── drawing_menu.dart │ │ └── brush_tip_button.dart ├── common │ ├── data │ │ ├── project │ │ │ ├── fps_config.dart │ │ │ ├── frame_interface.dart │ │ │ ├── base_image.dart │ │ │ ├── layer_group │ │ │ │ ├── layer_group_info.dart │ │ │ │ ├── combine_frames.dart │ │ │ │ └── frame_reel_group.dart │ │ │ ├── composite_frame.dart │ │ │ ├── composite_image.dart │ │ │ ├── sound_clip.dart │ │ │ └── project_save_data.dart │ │ ├── extensions │ │ │ ├── iterable_methods.dart │ │ │ ├── color_methods.dart │ │ │ └── duration_methods.dart │ │ ├── io │ │ │ ├── delete_files_where.dart │ │ │ ├── png.dart │ │ │ ├── generate_image.dart │ │ │ ├── image.dart │ │ │ ├── aac_to_m4a.dart │ │ │ ├── mp4 │ │ │ │ ├── slide.dart │ │ │ │ └── ffmpeg_helpers.dart │ │ │ └── make_duplicate_path.dart │ │ ├── debouncer.dart │ │ ├── task_queue.dart │ │ ├── sequence │ │ │ └── time_span.dart │ │ ├── copy_paster_model.dart │ │ └── flood_fill.dart │ └── ui │ │ ├── sheet_title.dart │ │ ├── dialog_done_button.dart │ │ ├── menu_tile.dart │ │ ├── composite_image_painter.dart │ │ ├── open_delete_confirmation_dialog.dart │ │ ├── app_checkbox.dart │ │ ├── measure_hack.dart │ │ ├── orientation_listener.dart │ │ ├── get_permission.dart │ │ ├── editable_field.dart │ │ ├── paint_text.dart │ │ ├── app_icon_button.dart │ │ ├── labeled_icon_button.dart │ │ ├── slide_action_button.dart │ │ └── popup_entry.dart ├── editing │ ├── data │ │ ├── timeline │ │ │ ├── convert.dart │ │ │ ├── timeline_scene_row.dart │ │ │ └── timeline_row_interfaces.dart │ │ ├── export │ │ │ ├── save_video_to_gallery.dart │ │ │ └── generators │ │ │ │ └── generator.dart │ │ ├── editor_model.dart │ │ ├── importer_model.dart │ │ └── player_model.dart │ └── ui │ │ ├── timeline │ │ ├── view │ │ │ ├── sliver │ │ │ │ ├── sound_sliver.dart │ │ │ │ ├── sliver.dart │ │ │ │ ├── image_sliver.dart │ │ │ │ └── video_sliver.dart │ │ │ ├── overlay │ │ │ │ ├── playhead.dart │ │ │ │ ├── resize_handle.dart │ │ │ │ ├── resize_start_handle.dart │ │ │ │ ├── sliver_action_buttons │ │ │ │ │ ├── visibility_button.dart │ │ │ │ │ ├── sliver_action_button.dart │ │ │ │ │ ├── play_mode_button.dart │ │ │ │ │ └── speed_button.dart │ │ │ │ ├── scene_end_handle.dart │ │ │ │ ├── resize_end_handle.dart │ │ │ │ └── animated_scene_preview.dart │ │ │ └── timeline_painter.dart │ │ ├── timeline_view_button.dart │ │ ├── actionbar │ │ │ ├── add_scene_button.dart │ │ │ ├── play_button.dart │ │ │ └── time_label.dart │ │ └── timeline_panel.dart │ │ ├── export │ │ ├── export_video_form.dart │ │ ├── open_edit_file_name_dialog.dart │ │ ├── export_images_form.dart │ │ └── export_form.dart │ │ └── preview │ │ └── preview.dart ├── home │ ├── ui │ │ ├── logo.dart │ │ ├── help │ │ │ └── help_button.dart │ │ ├── bin │ │ │ └── bin_button.dart │ │ ├── discord_sliver.dart │ │ └── add_project_button.dart │ └── home_page.dart └── ffi_bridge.dart ├── image_library └── CMakeLists.txt ├── release_notes.txt ├── .metadata ├── .gitignore ├── .github └── workflows │ └── main.yml └── README.md /android/settings_aar.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/video.txt: -------------------------------------------------------------------------------- 1 | https://youtu.be/Yx3c2NcHNWE 2 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Mooltik - storyboard and animate cartoons 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Animation studio in your pocket. 2 | -------------------------------------------------------------------------------- /assets/readme_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/assets/readme_logo.png -------------------------------------------------------------------------------- /assets/logo_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/assets/logo_foreground.png -------------------------------------------------------------------------------- /test/test_images/bg_red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/bg_red.png -------------------------------------------------------------------------------- /assets/readme_screenshots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/assets/readme_screenshots.png -------------------------------------------------------------------------------- /test/test_images/bg_green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/bg_green.png -------------------------------------------------------------------------------- /assets/tutorial_thumbnails/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/assets/tutorial_thumbnails/1.png -------------------------------------------------------------------------------- /test/test_images/rabbit_pink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/rabbit_pink.png -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /test/test_images/rabbit_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/rabbit_black.png -------------------------------------------------------------------------------- /test/test_images/rabbit_yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/rabbit_yellow.png -------------------------------------------------------------------------------- /assets/app_icon/mooltik_ios_1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/assets/app_icon/mooltik_ios_1024.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_1/input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_1/input.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_2/input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_2/input.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_3/input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_3/input.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_4/input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_4/input.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_5/input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_5/input.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_6/input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_6/input.png -------------------------------------------------------------------------------- /android/app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_1/actual_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_1/actual_output.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_2/actual_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_2/actual_output.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_3/actual_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_3/actual_output.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_4/actual_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_4/actual_output.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_5/actual_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_5/actual_output.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_6/actual_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_6/actual_output.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_1/expected_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_1/expected_output.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_2/expected_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_2/expected_output.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_3/expected_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_3/expected_output.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_4/expected_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_4/expected_output.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_5/expected_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_5/expected_output.png -------------------------------------------------------------------------------- /test/test_images/flood_test/test_6/expected_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/test/test_images/flood_test/test_6/expected_output.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | /app/.cxx 9 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /lib/drawing/data/toolbox/tools/tools.dart: -------------------------------------------------------------------------------- 1 | export 'tool.dart'; 2 | export 'eraser.dart'; 3 | export 'paint_brush.dart'; 4 | export 'lasso.dart'; 5 | export 'bucket.dart'; 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/ic_stat_ic_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/drawable-hdpi/ic_stat_ic_notification.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/ic_stat_ic_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/drawable-mdpi/ic_stat_ic_notification.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/ic_stat_ic_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/drawable-xhdpi/ic_stat_ic_notification.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /lib/common/data/project/fps_config.dart: -------------------------------------------------------------------------------- 1 | const int fps = 25; 2 | const int singleFrameMs = 1000 ~/ fps; 3 | const Duration singleFrameDuration = Duration(milliseconds: singleFrameMs); 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/ic_stat_ic_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/drawable-xxhdpi/ic_stat_ic_notification.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/ic_stat_ic_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/android/app/src/main/res/drawable-xxxhdpi/ic_stat_ic_notification.png -------------------------------------------------------------------------------- /android/app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFC107 4 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruskakimov/mooltik/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /image_library/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.4.1) 2 | set (CMAKE_CXX_STANDARD 11) 3 | 4 | add_library( 5 | image 6 | SHARED 7 | flood_fill.cpp 8 | ) -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/record_painting/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.kakimov.mooltik 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /release_notes.txt: -------------------------------------------------------------------------------- 1 | - Image import! 2 | - Flip frame vertically or horizontally 3 | - Dragging corner of the transform box doesn't change the aspect ratio 4 | - Fix a bug where an unfinished transformed image was lost on exit -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /lib/editing/data/timeline/convert.dart: -------------------------------------------------------------------------------- 1 | Duration pxToDuration(double offset, double msPerPx) => 2 | Duration(milliseconds: (offset * msPerPx).round()); 3 | 4 | double durationToPx(Duration duration, double msPerPx) => 5 | duration.inMilliseconds / msPerPx; 6 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | 4 | // https://github.com/flutter/flutter/issues/66222#issuecomment-718040317 5 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip 7 | -------------------------------------------------------------------------------- /lib/common/data/project/frame_interface.dart: -------------------------------------------------------------------------------- 1 | import 'package:mooltik/common/data/project/base_image.dart'; 2 | import 'package:mooltik/common/data/sequence/time_span.dart'; 3 | 4 | abstract class FrameInterface implements TimeSpan { 5 | BaseImage get image; 6 | Duration get duration; 7 | } 8 | -------------------------------------------------------------------------------- /ios/Runner/Runner.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 8af6b2f038c1172e61d418869363a28dffec3cb4 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /lib/common/data/extensions/iterable_methods.dart: -------------------------------------------------------------------------------- 1 | extension IterableMethods on Iterable { 2 | Iterable mapIndexed(T Function(E e, int i) f) { 3 | var i = 0; 4 | return map((e) => f(e, i++)); 5 | } 6 | 7 | void forEachIndexed(void Function(E e, int i) f) { 8 | var i = 0; 9 | forEach((e) => f(e, i++)); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | 4 | // https://github.com/flutter/flutter/issues/66222#issuecomment-718040317 5 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 6 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig" -------------------------------------------------------------------------------- /ios/Runner/AppViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | public class AppViewController: FlutterViewController { 5 | public override var prefersStatusBarHidden: Bool { 6 | return true 7 | } 8 | 9 | public override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { 10 | return UIRectEdge.all 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /lib/common/data/project/base_image.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | abstract class BaseImage extends ChangeNotifier with EquatableMixin { 5 | int get width; 6 | int get height; 7 | 8 | Size get size => Size(width.toDouble(), height.toDouble()); 9 | 10 | void draw(Canvas canvas, Offset offset, Paint paint); 11 | } 12 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /assets/icons/board.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /lib/editing/ui/timeline/view/sliver/sound_sliver.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mooltik/editing/ui/timeline/view/sliver/sliver.dart'; 3 | 4 | class SoundSliver extends Sliver { 5 | SoundSliver({ 6 | required Rect area, 7 | }) : super(area); 8 | 9 | @override 10 | void paint(Canvas canvas) { 11 | canvas.drawRRect(rrect, Paint()..color = Colors.green[300]!); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @UIApplicationMain 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/common/data/io/delete_files_where.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | /// Deletes some files from given directory. 4 | Future deleteFilesWhere( 5 | Directory directory, 6 | bool Function(String filePath) test, 7 | ) async { 8 | final List filesToDelete = await directory 9 | .list() 10 | .where((entity) => entity is File && test(entity.path)) 11 | .toList(); 12 | 13 | await Future.wait(filesToDelete.map((file) => file.delete())); 14 | } 15 | -------------------------------------------------------------------------------- /lib/common/data/io/png.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'dart:ui'; 3 | 4 | import 'package:mooltik/common/data/io/image.dart'; 5 | 6 | Future pngRead(File pngFile) async { 7 | final bytes = await pngFile.readAsBytes(); 8 | return imageFromFileBytes(bytes); 9 | } 10 | 11 | Future pngWrite(File pngFile, Image image) async { 12 | final byteData = await image.toByteData(format: ImageByteFormat.png); 13 | await pngFile.writeAsBytes(byteData!.buffer.asUint8List(), flush: true); 14 | } 15 | -------------------------------------------------------------------------------- /lib/common/ui/sheet_title.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class SheetTitle extends StatelessWidget { 4 | const SheetTitle(this.text, {Key? key}) : super(key: key); 5 | 6 | final String text; 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Padding( 11 | padding: const EdgeInsets.all(16), 12 | child: Text( 13 | text, 14 | style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), 15 | ), 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/project_data/a_v0_8.json: -------------------------------------------------------------------------------- 1 | { 2 | "width": 1280.0, 3 | "height": 720.0, 4 | "frames": [ 5 | { 6 | "id": 0, 7 | "duration": "0:00:03.000000" 8 | }, 9 | { 10 | "id": 1, 11 | "duration": "0:00:01.000000" 12 | } 13 | ], 14 | "sounds": [ 15 | { 16 | "file_name": "01234.mp3", 17 | "start_time": "0:00:01.000000", 18 | "duration": "0:00:02.000000" 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /lib/common/data/debouncer.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | /// Manages a single debounce timer. 6 | class Debouncer { 7 | Debouncer(this.timeout); 8 | 9 | final Duration timeout; 10 | Timer? _timer; 11 | 12 | bool get isActive => _timer?.isActive ?? false; 13 | 14 | void debounce(VoidCallback callback) { 15 | cancel(); 16 | _timer = Timer(timeout, callback); 17 | } 18 | 19 | void cancel() { 20 | _timer?.cancel(); 21 | _timer = null; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/common/data/project/layer_group/layer_group_info.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | class LayerGroupInfo with EquatableMixin { 4 | LayerGroupInfo(this.firstLayerIndex, this.lastLayerIndex); 5 | 6 | int firstLayerIndex; 7 | int lastLayerIndex; 8 | 9 | int get layerCount => lastLayerIndex - firstLayerIndex + 1; 10 | 11 | bool contains(int index) => 12 | index >= firstLayerIndex && index <= lastLayerIndex; 13 | 14 | @override 15 | List get props => [firstLayerIndex, lastLayerIndex]; 16 | } 17 | -------------------------------------------------------------------------------- /lib/common/ui/dialog_done_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class DialogDoneButton extends StatelessWidget { 4 | const DialogDoneButton({ 5 | Key? key, 6 | this.onPressed, 7 | }) : super(key: key); 8 | 9 | final VoidCallback? onPressed; 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return IconButton( 14 | constraints: BoxConstraints.tight(Size.square(kToolbarHeight)), 15 | icon: Icon(Icons.done), 16 | tooltip: 'Done', 17 | onPressed: onPressed, 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lib/common/data/task_queue.dart: -------------------------------------------------------------------------------- 1 | import 'dart:collection'; 2 | 3 | typedef AsyncTask = Future Function(); 4 | 5 | /// Executes async functions in order. 6 | class TaskQueue { 7 | final _queue = Queue(); 8 | 9 | void add(AsyncTask task) { 10 | _queue.add(task); 11 | if (!_isRunning) _run(); 12 | } 13 | 14 | bool _isRunning = false; 15 | 16 | void _run() async { 17 | _isRunning = true; 18 | 19 | while (_queue.isNotEmpty) { 20 | final task = _queue.removeFirst(); 21 | await task(); 22 | } 23 | 24 | _isRunning = false; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/editing/ui/timeline/view/sliver/sliver.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | abstract class Sliver { 4 | Sliver(this.area) 5 | : rrect = RRect.fromRectAndRadius( 6 | area.width > 4 ? _deflateSides(area, 1) : area, 7 | Radius.circular(8), 8 | ); 9 | 10 | final Rect area; 11 | 12 | final RRect rrect; 13 | 14 | void paint(Canvas canvas); 15 | } 16 | 17 | Rect _deflateSides(Rect rect, double delta) { 18 | return Rect.fromLTRB( 19 | rect.left + delta, 20 | rect.top, 21 | rect.right - delta, 22 | rect.bottom, 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /assets/icons/timeline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /lib/editing/data/export/save_video_to_gallery.dart: -------------------------------------------------------------------------------- 1 | import 'package:gallery_saver/gallery_saver.dart'; 2 | import 'package:firebase_crashlytics/firebase_crashlytics.dart'; 3 | 4 | Future saveVideoToGallery(String path) async { 5 | try { 6 | final success = 7 | await GallerySaver.saveVideo(path).timeout(Duration(seconds: 5)); 8 | 9 | if (success != true) { 10 | throw Exception( 11 | 'Failed when tried uploading video to the gallery. Return value: $success'); 12 | } 13 | } catch (e, stack) { 14 | FirebaseCrashlytics.instance.recordError(e, stack); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/common/data/io/generate_image.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/material.dart' show CustomPainter; 4 | 5 | Future generateImage( 6 | CustomPainter? painter, 7 | int width, 8 | int height, 9 | ) async { 10 | final recorder = PictureRecorder(); 11 | final canvas = Canvas(recorder); 12 | painter?.paint(canvas, Size(width.toDouble(), height.toDouble())); 13 | final picture = recorder.endRecording(); 14 | final image = await picture.toImage(width, height); 15 | picture.dispose(); 16 | return image; 17 | } 18 | 19 | Future generateEmptyImage(int width, int height) => 20 | generateImage(null, width, height); 21 | -------------------------------------------------------------------------------- /lib/home/ui/logo.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class Logo extends StatelessWidget { 4 | const Logo({ 5 | Key? key, 6 | }) : super(key: key); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Container( 11 | margin: const EdgeInsets.all(8), 12 | decoration: BoxDecoration( 13 | color: Theme.of(context).colorScheme.primary, 14 | borderRadius: BorderRadius.circular(8), 15 | ), 16 | child: Image.asset( 17 | 'assets/logo_foreground.png', 18 | fit: BoxFit.contain, 19 | filterQuality: FilterQuality.medium, 20 | ), 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/common/data/io/image.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:typed_data'; 3 | 4 | import 'dart:ui'; 5 | 6 | Future imageFromRawBytes(ByteData bytes, int width, int height) { 7 | final Completer completer = Completer(); 8 | decodeImageFromPixels( 9 | bytes.buffer.asUint8List(), 10 | width, 11 | height, 12 | PixelFormat.rgba8888, 13 | (Image image) => completer.complete(image), 14 | ); 15 | return completer.future; 16 | } 17 | 18 | Future imageFromFileBytes(Uint8List bytes) async { 19 | final codec = await instantiateImageCodec(bytes); 20 | final frame = await codec.getNextFrame(); 21 | return frame.image; 22 | } 23 | -------------------------------------------------------------------------------- /lib/common/data/io/aac_to_m4a.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'package:path/path.dart' as p; 3 | 4 | import 'package:flutter_ffmpeg/flutter_ffmpeg.dart'; 5 | 6 | /// Wraps raw AAC with M4A container. 7 | /// Raw AAC doesn't support seeking, therefore it is unusable. 8 | Future aacToM4a(File inputAAC, File outputM4A) async { 9 | assert(p.extension(inputAAC.path) == '.aac'); 10 | assert(p.extension(outputM4A.path) == '.m4a'); 11 | 12 | final command = '-i ${inputAAC.path} -codec: copy ${outputM4A.path}'; 13 | final code = await FlutterFFmpeg().execute(command); 14 | 15 | if (code != 0) { 16 | throw Exception('Could not convert AAC to M4A, error code: $code'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/editing/ui/timeline/view/overlay/playhead.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class Playhead extends StatelessWidget { 4 | const Playhead({ 5 | Key? key, 6 | }) : super(key: key); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Center( 11 | child: IgnorePointer( 12 | child: SizedBox( 13 | width: 2, 14 | height: double.infinity, 15 | child: Material( 16 | color: Theme.of(context).colorScheme.primary, 17 | borderRadius: BorderRadius.vertical(top: Radius.circular(2)), 18 | elevation: 4, 19 | ), 20 | ), 21 | ), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Flutter Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | include ':app' 6 | 7 | def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 8 | def properties = new Properties() 9 | 10 | assert localPropertiesFile.exists() 11 | localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 12 | 13 | def flutterSdkPath = properties.getProperty("flutter.sdk") 14 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 15 | apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 16 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | *.mode1v3 2 | *.mode2v3 3 | *.moved-aside 4 | *.pbxuser 5 | *.perspectivev3 6 | **/*sync/ 7 | .sconsign.dblite 8 | .tags* 9 | **/.vagrant/ 10 | **/DerivedData/ 11 | Icon? 12 | **/Pods/ 13 | **/.symlinks/ 14 | profile 15 | xcuserdata 16 | **/.generated/ 17 | Flutter/App.framework 18 | Flutter/Flutter.framework 19 | Flutter/Flutter.podspec 20 | Flutter/Generated.xcconfig 21 | Flutter/app.flx 22 | Flutter/app.zip 23 | Flutter/flutter_assets/ 24 | Flutter/flutter_export_environment.sh 25 | ServiceDefinitions.json 26 | Runner/GeneratedPluginRegistrant.* 27 | 28 | # Exceptions to above rules. 29 | !default.mode1v3 30 | !default.mode2v3 31 | !default.pbxuser 32 | !default.perspectivev3 33 | 34 | build/ -------------------------------------------------------------------------------- /lib/home/ui/help/help_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; 3 | import 'package:mooltik/home/ui/help/help_contents.dart'; 4 | import 'package:mooltik/common/ui/open_side_sheet.dart'; 5 | 6 | class HelpButton extends StatelessWidget { 7 | const HelpButton({ 8 | Key? key, 9 | }) : super(key: key); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return IconButton( 14 | icon: Icon(MdiIcons.help), 15 | onPressed: () { 16 | openSideSheet( 17 | context: context, 18 | builder: (context) => HelpContents(), 19 | ); 20 | }, 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/project_data/a_v0_9.json: -------------------------------------------------------------------------------- 1 | { 2 | "width": 1280.0, 3 | "height": 720.0, 4 | "scenes": [ 5 | { 6 | "frames": [ 7 | { 8 | "file_name": "frame0.png", 9 | "duration": "0:00:03.000000" 10 | }, 11 | { 12 | "file_name": "frame1.png", 13 | "duration": "0:00:01.000000" 14 | } 15 | ], 16 | "duration": "0:00:04.000000", 17 | "play_mode": 1 18 | } 19 | ], 20 | "sounds": [ 21 | { 22 | "file_name": "01234.mp3", 23 | "start_time": "0:00:01.000000", 24 | "duration": "0:00:02.000000" 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /lib/common/data/io/mp4/slide.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:mooltik/common/data/extensions/duration_methods.dart'; 4 | 5 | /// Data class for video export. 6 | class Slide { 7 | Slide(this.pngImage, this.duration); 8 | 9 | /// Slide image in PNG format. 10 | final File pngImage; 11 | 12 | /// Slide duration. 13 | final Duration duration; 14 | 15 | factory Slide.fromJson(Map json) => Slide( 16 | File(json[_filePathKey]), 17 | (json[_durationKey] as String).parseDuration(), 18 | ); 19 | 20 | Map toJson() => { 21 | _filePathKey: pngImage.path, 22 | _durationKey: duration.toString(), 23 | }; 24 | 25 | static const String _filePathKey = 'path'; 26 | static const String _durationKey = 'duration'; 27 | } 28 | -------------------------------------------------------------------------------- /lib/editing/ui/export/export_video_form.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mooltik/editing/ui/export/open_edit_file_name_dialog.dart'; 3 | import 'package:provider/provider.dart'; 4 | import 'package:mooltik/editing/data/export/exporter_model.dart'; 5 | import 'package:mooltik/common/ui/editable_field.dart'; 6 | 7 | class ExportVideoForm extends StatelessWidget { 8 | const ExportVideoForm({ 9 | Key? key, 10 | }) : super(key: key); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | final fileName = context.select( 15 | (exporter) => exporter.fileName, 16 | ); 17 | 18 | return EditableField( 19 | label: 'File name', 20 | text: '$fileName.mp4', 21 | onTap: () => openEditFileNameDialog(context), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/drawing/ui/layers/visibility_switch.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class VisibilitySwitch extends StatelessWidget { 4 | const VisibilitySwitch({ 5 | Key? key, 6 | required this.value, 7 | required this.onChanged, 8 | }) : super(key: key); 9 | 10 | final bool? value; 11 | final ValueChanged onChanged; 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return GestureDetector( 16 | behavior: HitTestBehavior.opaque, 17 | onTap: () => onChanged(!value!), 18 | child: SizedBox( 19 | height: 52, 20 | width: 52, 21 | child: Icon( 22 | value! ? Icons.visibility_outlined : Icons.visibility_off_outlined, 23 | color: Theme.of(context).colorScheme.onSurface, 24 | size: 20, 25 | ), 26 | ), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | .vscode/ 22 | launch.json 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | **/ios/Flutter/.last_build_id 27 | .dart_tool/ 28 | .flutter-plugins 29 | .flutter-plugins-dependencies 30 | .packages 31 | .pub-cache/ 32 | .pub/ 33 | /build/ 34 | 35 | # Web related 36 | lib/generated_plugin_registrant.dart 37 | 38 | # Symbolication related 39 | app.*.symbols 40 | 41 | # Obfuscation related 42 | app.*.map.json 43 | 44 | # App signing 45 | key.properties 46 | -------------------------------------------------------------------------------- /lib/editing/ui/timeline/timeline_view_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_svg/svg.dart'; 3 | 4 | class TimelineViewButton extends StatelessWidget { 5 | const TimelineViewButton({ 6 | Key? key, 7 | required this.showTimelineIcon, 8 | this.onTap, 9 | }) : super(key: key); 10 | 11 | final bool showTimelineIcon; 12 | final VoidCallback? onTap; 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | return FloatingActionButton( 17 | heroTag: null, 18 | mini: true, 19 | elevation: 2, 20 | child: _buildIcon(), 21 | onPressed: onTap, 22 | ); 23 | } 24 | 25 | Widget _buildIcon() => SvgPicture.asset( 26 | showTimelineIcon 27 | ? 'assets/icons/timeline.svg' 28 | : 'assets/icons/board.svg', 29 | fit: BoxFit.none, 30 | color: Colors.white, 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 11.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /lib/common/data/extensions/color_methods.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | extension ColorMethods on Color { 6 | int toRGBA() { 7 | return [ 8 | red, 9 | green, 10 | blue, 11 | alpha, 12 | ].reduce((color, channel) => (color << 8) | channel); 13 | } 14 | 15 | int toABGR() { 16 | return [ 17 | alpha, 18 | blue, 19 | green, 20 | red, 21 | ].reduce((color, channel) => (color << 8) | channel); 22 | } 23 | } 24 | 25 | extension HSVMethods on HSVColor { 26 | String toStr() => '$alpha,$hue,$saturation,$value'; 27 | } 28 | 29 | extension HSVParsing on String { 30 | HSVColor parseHSVColor() { 31 | final parts = this.split(',').map((part) => double.parse(part)).toList(); 32 | return HSVColor.fromAHSV( 33 | parts[0], 34 | parts[1], 35 | parts[2], 36 | parts[3], 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/project_data/a_v1_0.json: -------------------------------------------------------------------------------- 1 | { 2 | "width": 1280.0, 3 | "height": 720.0, 4 | "scenes": [ 5 | { 6 | "layers": [ 7 | { 8 | "frames": [ 9 | { 10 | "file_name": "frame0.png", 11 | "duration": "0:00:03.000000" 12 | }, 13 | { 14 | "file_name": "frame1.png", 15 | "duration": "0:00:01.000000" 16 | } 17 | ], 18 | "play_mode": 1 19 | } 20 | ], 21 | "duration": "0:00:04.000000" 22 | } 23 | ], 24 | "sounds": [ 25 | { 26 | "file_name": "01234.mp3", 27 | "start_time": "0:00:01.000000", 28 | "duration": "0:00:02.000000" 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Think of it as a combination of a video editor and a drawing app. 2 | Perfect for a short cartoon, movie animatic, or a music video storyboard. 3 | 4 | Some artists say this is the EASIEST way to make a quick storyboard animation out there. But don't take others' words as gospel truth, give it a try and find it out yourself! 5 | 6 | Features: 7 | - Simple drawing tools 8 | - Precisely time the duration of each scene and frame 9 | - Loop animations 10 | - Layers acting as independent timelines (e.g. action in the background can be animated separately from the main action) 11 | - Annotate scenes with dialog or other description 12 | - Import music (for now only a single soundtrack) 13 | - Export animatic as an mp4 14 | 15 | This app is under active development. 16 | Email me (kakimov.dev@gmail.com) about features you would like to see and any issues you have faced. 17 | 18 | More features are coming... 19 | -------------------------------------------------------------------------------- /lib/drawing/data/toolbox/tools/eraser.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; 3 | import 'package:shared_preferences/shared_preferences.dart'; 4 | import 'brush.dart'; 5 | 6 | class Eraser extends Brush { 7 | Eraser(SharedPreferences sharedPreferences) : super(sharedPreferences); 8 | 9 | @override 10 | String get name => 'eraser'; 11 | 12 | @override 13 | IconData get icon => MdiIcons.eraser; 14 | 15 | @override 16 | BlendMode get blendMode => BlendMode.dstOut; 17 | 18 | @override 19 | double get minStrokeWidth => 10; 20 | 21 | @override 22 | double get maxStrokeWidth => 300; 23 | 24 | @override 25 | List get defaultBrushTips => [ 26 | BrushTip(strokeWidth: 20, opacity: 1, blur: 0), 27 | BrushTip(strokeWidth: 100, opacity: 1, blur: 0), 28 | BrushTip(strokeWidth: 300, opacity: 1, blur: 0), 29 | ]; 30 | } 31 | -------------------------------------------------------------------------------- /lib/editing/ui/timeline/view/overlay/resize_handle.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | const double resizeHandleWidth = 48; 4 | const double resizeHandleHeight = 48; 5 | 6 | class ResizeHandle extends StatelessWidget { 7 | @override 8 | Widget build(BuildContext context) { 9 | return Padding( 10 | padding: const EdgeInsets.symmetric(horizontal: 12), 11 | child: Material( 12 | borderRadius: BorderRadius.circular(8), 13 | color: Theme.of(context).colorScheme.primary, 14 | elevation: 10, 15 | child: SizedBox( 16 | width: resizeHandleWidth - 24, 17 | height: resizeHandleHeight, 18 | child: RotatedBox( 19 | quarterTurns: 1, 20 | child: Icon( 21 | Icons.drag_handle_rounded, 22 | color: Theme.of(context).colorScheme.onPrimary, 23 | ), 24 | ), 25 | ), 26 | ), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/drawing/data/lasso/masked_image_painter.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui' as ui; 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | /// Clips selection mask. 6 | class MaskedImagePainter extends CustomPainter { 7 | MaskedImagePainter({ 8 | required this.original, 9 | required this.mask, 10 | }); 11 | 12 | final ui.Image? original; 13 | final Path mask; 14 | 15 | @override 16 | void paint(Canvas canvas, Size size) { 17 | final maskBounds = mask.getBounds(); 18 | 19 | canvas.translate(-maskBounds.left, -maskBounds.top); 20 | 21 | canvas.clipPath(mask); 22 | canvas.drawImage( 23 | original!, 24 | Offset.zero, 25 | Paint() 26 | ..isAntiAlias = true 27 | ..filterQuality = FilterQuality.high, 28 | ); 29 | } 30 | 31 | @override 32 | bool shouldRepaint(MaskedImagePainter oldDelegate) => false; 33 | 34 | @override 35 | bool shouldRebuildSemantics(MaskedImagePainter oldDelegate) => false; 36 | } 37 | -------------------------------------------------------------------------------- /assets/icons/export.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /lib/drawing/data/toolbox/tools/paint_brush.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; 3 | import 'package:shared_preferences/shared_preferences.dart'; 4 | import 'brush.dart'; 5 | 6 | class PaintBrush extends Brush { 7 | PaintBrush(SharedPreferences sharedPreferences) : super(sharedPreferences); 8 | 9 | @override 10 | String get name => 'paint_brush'; 11 | 12 | @override 13 | IconData get icon => MdiIcons.brush; 14 | 15 | @override 16 | BlendMode get blendMode => BlendMode.srcOver; 17 | 18 | @override 19 | double get minStrokeWidth => 1; 20 | 21 | @override 22 | double get maxStrokeWidth => 100; 23 | 24 | @override 25 | List get defaultBrushTips => [ 26 | BrushTip(strokeWidth: 5, opacity: 1, blur: 0), 27 | BrushTip(strokeWidth: 10, opacity: 1, blur: 0), 28 | BrushTip(strokeWidth: 20, opacity: 1, blur: 0), 29 | ]; 30 | } 31 | -------------------------------------------------------------------------------- /lib/common/ui/menu_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class MenuTile extends StatelessWidget { 4 | const MenuTile({ 5 | Key? key, 6 | required this.icon, 7 | required this.title, 8 | this.trailing, 9 | this.onTap, 10 | }) : super(key: key); 11 | 12 | final IconData icon; 13 | final String title; 14 | final Widget? trailing; 15 | final VoidCallback? onTap; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return ListTile( 20 | leading: Transform.translate( 21 | offset: Offset(0, 1), 22 | child: Icon(icon, size: 20), 23 | ), 24 | title: Transform.translate( 25 | offset: Offset(-18, 0), 26 | child: Text( 27 | title, 28 | style: TextStyle( 29 | fontSize: 14, 30 | fontWeight: FontWeight.w500, 31 | ), 32 | ), 33 | ), 34 | trailing: trailing, 35 | onTap: onTap, 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/common/data/project/layer_group/combine_frames.dart: -------------------------------------------------------------------------------- 1 | import 'package:mooltik/common/data/project/composite_frame.dart'; 2 | import 'package:mooltik/common/data/project/composite_image.dart'; 3 | import 'package:mooltik/drawing/data/frame/frame.dart'; 4 | 5 | CompositeFrame combineFrames(Iterable frames) { 6 | assert(frames.isNotEmpty); 7 | assert(frames.every((frame) => frame.duration == frames.first.duration)); 8 | 9 | final images = frames.map((frame) => frame.image).toList(); 10 | return CompositeFrame(CompositeImage(images), frames.first.duration); 11 | } 12 | 13 | Iterable combineFrameSequences( 14 | Iterable> frameSequences, 15 | ) sync* { 16 | assert(frameSequences.isNotEmpty); 17 | 18 | final iterators = 19 | frameSequences.map((sequence) => sequence.iterator).toList(); 20 | 21 | while (iterators.every((iterator) => iterator.moveNext())) { 22 | yield combineFrames(iterators.map((iterator) => iterator.current)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/common/ui/composite_image_painter.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mooltik/common/data/project/composite_image.dart'; 3 | 4 | class CompositeImagePainter extends CustomPainter { 5 | CompositeImagePainter(this.image); 6 | 7 | final CompositeImage image; 8 | 9 | @override 10 | void paint(Canvas canvas, Size size) { 11 | canvas.drawRect( 12 | Rect.fromLTWH(0, 0, size.width, size.height), 13 | Paint()..color = Colors.white, 14 | ); 15 | canvas.scale(size.width / image.width, size.height / image.height); 16 | 17 | canvas.drawCompositeImage( 18 | image, 19 | Offset.zero, 20 | Paint() 21 | ..isAntiAlias = true 22 | ..filterQuality = FilterQuality.high, 23 | ); 24 | } 25 | 26 | @override 27 | bool shouldRepaint(CompositeImagePainter oldDelegate) => 28 | image != oldDelegate.image; 29 | 30 | @override 31 | bool shouldRebuildSemantics(CompositeImagePainter oldDelegate) => false; 32 | } 33 | -------------------------------------------------------------------------------- /lib/common/ui/open_delete_confirmation_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | Future openDeleteConfirmationDialog({ 4 | required BuildContext context, 5 | required String name, 6 | required Widget preview, 7 | }) async { 8 | return showDialog( 9 | context: context, 10 | builder: (context) => AlertDialog( 11 | title: Text('Delete this $name?'), 12 | content: Container( 13 | width: 200, 14 | clipBehavior: Clip.antiAlias, 15 | decoration: BoxDecoration( 16 | borderRadius: BorderRadius.circular(8), 17 | ), 18 | child: preview, 19 | ), 20 | actions: [ 21 | TextButton( 22 | onPressed: () => Navigator.pop(context, false), 23 | child: const Text('CANCEL'), 24 | ), 25 | TextButton( 26 | onPressed: () => Navigator.pop(context, true), 27 | child: const Text('DELETE'), 28 | ), 29 | ], 30 | ), 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /assets/icons/fa-solid_expand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /lib/home/ui/bin/bin_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mooltik/home/data/gallery_model.dart'; 3 | import 'package:provider/provider.dart'; 4 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 5 | import 'package:mooltik/common/ui/open_side_sheet.dart'; 6 | 7 | import 'bin_contents.dart'; 8 | 9 | class BinButton extends StatelessWidget { 10 | const BinButton({ 11 | Key? key, 12 | }) : super(key: key); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | return IconButton( 17 | icon: Icon( 18 | FontAwesomeIcons.trashAlt, 19 | size: 20, 20 | ), 21 | onPressed: () { 22 | final gallery = context.read(); 23 | 24 | openSideSheet( 25 | context: context, 26 | builder: (context) => ChangeNotifierProvider.value( 27 | value: gallery, 28 | child: BinContents(), 29 | ), 30 | ); 31 | }, 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/editing/data/export/generators/generator.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:path/path.dart' as p; 4 | 5 | abstract class Generator { 6 | Generator(this.temporaryDirectory); 7 | 8 | /// Temporary directory for writting files with intermediate results. 9 | final Directory temporaryDirectory; 10 | 11 | bool get isCancelled => _isCancelled; 12 | bool _isCancelled = false; 13 | 14 | /// Generates and writes an output to a file. 15 | /// 16 | /// Returns `null` if operation is cancelled. 17 | /// Throws if fails to generate an output. 18 | Future generate(); 19 | 20 | /// Cancels ongoing output generation. 21 | /// 22 | /// Nothing happens if there is no ongoing generation. 23 | @mustCallSuper 24 | Future cancel() async { 25 | _isCancelled = true; 26 | } 27 | 28 | File makeTemporaryFile(String fileName, [Directory? directory]) => 29 | File(p.join((directory ?? temporaryDirectory).path, fileName)); 30 | } 31 | -------------------------------------------------------------------------------- /test/project_data/a_v1_6.json: -------------------------------------------------------------------------------- 1 | { 2 | "width": 1280.0, 3 | "height": 720.0, 4 | "scenes": [ 5 | { 6 | "layers": [ 7 | { 8 | "frames": [ 9 | { 10 | "file_name": "frame0.png", 11 | "duration": "0:00:03.000000" 12 | }, 13 | { 14 | "file_name": "frame1.png", 15 | "duration": "0:00:01.000000" 16 | } 17 | ], 18 | "play_mode": 1, 19 | "visible": true 20 | } 21 | ], 22 | "duration": "0:00:04.000000", 23 | "description": "" 24 | } 25 | ], 26 | "sounds": [ 27 | { 28 | "file_name": "01234.mp3", 29 | "start_time": "0:00:01.000000", 30 | "duration": "0:00:02.000000" 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.4.32' 3 | repositories { 4 | google() 5 | mavenCentral() 6 | jcenter() 7 | } 8 | 9 | dependencies { 10 | classpath 'com.android.tools.build:gradle:4.1.2' 11 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 12 | classpath 'com.google.gms:google-services:4.3.4' // firebase 13 | classpath 'com.google.firebase:firebase-crashlytics-gradle:2.2.0' // crashlytics 14 | } 15 | } 16 | 17 | allprojects { 18 | repositories { 19 | google() 20 | mavenCentral() 21 | jcenter() 22 | } 23 | } 24 | 25 | rootProject.buildDir = '../build' 26 | subprojects { 27 | project.buildDir = "${rootProject.buildDir}/${project.name}" 28 | } 29 | subprojects { 30 | project.evaluationDependsOn(':app') 31 | } 32 | 33 | task clean(type: Delete) { 34 | delete rootProject.buildDir 35 | } 36 | 37 | ext { 38 | flutterFFmpegPackage = 'full-gpl' 39 | } 40 | -------------------------------------------------------------------------------- /lib/editing/ui/timeline/view/overlay/resize_start_handle.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/resize_handle.dart'; 4 | import 'package:mooltik/editing/ui/timeline/view/overlay/timeline_positioned.dart'; 5 | 6 | class ResizeStartHandle extends StatelessWidget { 7 | const ResizeStartHandle({ 8 | Key? key, 9 | required this.timelineView, 10 | }) : super(key: key); 11 | 12 | final TimelineViewModel timelineView; 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | return TimelinePositioned( 17 | timestamp: timelineView.selectedSliverStartTime, 18 | y: timelineView.selectedSliverMidY, 19 | width: resizeHandleWidth, 20 | height: resizeHandleHeight, 21 | onDragUpdate: (Duration updatedTime) => 22 | timelineView.onStartTimeHandleDragUpdate(updatedTime), 23 | child: ResizeHandle(), 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/editing/ui/timeline/actionbar/add_scene_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | import 'package:mooltik/common/data/project/project.dart'; 4 | import 'package:mooltik/common/ui/app_icon_button.dart'; 5 | import 'package:mooltik/editing/data/timeline/timeline_model.dart'; 6 | import 'package:provider/provider.dart'; 7 | 8 | class AddSceneButton extends StatelessWidget { 9 | @override 10 | Widget build(BuildContext context) { 11 | final timeline = context.watch(); 12 | return AppIconButton( 13 | icon: FontAwesomeIcons.plus, 14 | onTap: timeline.isPlaying ? null : () => _addSceneAfterCurrent(context), 15 | ); 16 | } 17 | 18 | Future _addSceneAfterCurrent(BuildContext context) async { 19 | final newScene = await context.read().createNewScene(); 20 | final scenes = context.read().sceneSeq; 21 | scenes.insert(scenes.currentIndex + 1, newScene); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/common/ui/app_checkbox.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class AppCheckbox extends StatelessWidget { 4 | AppCheckbox({ 5 | Key? key, 6 | required this.value, 7 | }) : super(key: key); 8 | 9 | final bool value; 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | final checkedDecoration = BoxDecoration( 14 | color: Theme.of(context).colorScheme.primary, 15 | border: Border.all(width: 2, color: Colors.black), 16 | shape: BoxShape.circle, 17 | ); 18 | 19 | final uncheckedDecoration = BoxDecoration( 20 | border: Border.all( 21 | width: 2, 22 | color: Theme.of(context).colorScheme.secondary, 23 | ), 24 | shape: BoxShape.circle, 25 | ); 26 | 27 | return Container( 28 | width: 32, 29 | height: 32, 30 | margin: const EdgeInsets.all(8), 31 | decoration: value ? checkedDecoration : uncheckedDecoration, 32 | child: value ? Icon(Icons.done, color: Colors.black) : null, 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/drawing/ui/lasso/transformed_image_layer.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mooltik/drawing/data/lasso/lasso_model.dart'; 3 | import 'package:mooltik/drawing/ui/lasso/transformed_image_painter.dart'; 4 | import 'package:provider/provider.dart'; 5 | 6 | class TransformedImageLayer extends StatelessWidget { 7 | const TransformedImageLayer({ 8 | Key? key, 9 | required this.frameSize, 10 | }) : super(key: key); 11 | 12 | final Size frameSize; 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final lassoModel = context.watch(); 17 | 18 | if (lassoModel.transformImage == null) { 19 | return SizedBox.shrink(); 20 | } 21 | 22 | return FittedBox( 23 | fit: BoxFit.fill, 24 | child: CustomPaint( 25 | size: frameSize, 26 | painter: TransformedImagePainter( 27 | transform: lassoModel.imageTransform, 28 | transformedImage: lassoModel.transformImage, 29 | ), 30 | ), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /test/project_data/a_v1_17.json: -------------------------------------------------------------------------------- 1 | { 2 | "width": 1280, 3 | "height": 720, 4 | "scenes": [ 5 | { 6 | "layers": [ 7 | { 8 | "frames": [ 9 | { 10 | "file_name": "frame0.png", 11 | "duration": "0:00:03.000000" 12 | }, 13 | { 14 | "file_name": "frame1.png", 15 | "duration": "0:00:01.000000" 16 | } 17 | ], 18 | "play_mode": 1, 19 | "visible": true, 20 | "name": "layer 1" 21 | } 22 | ], 23 | "duration": "0:00:04.000000", 24 | "description": "" 25 | } 26 | ], 27 | "sounds": [ 28 | { 29 | "file_name": "01234.mp3", 30 | "start_time": "0:00:01.000000", 31 | "duration": "0:00:02.000000" 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /lib/editing/data/editor_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mooltik/common/data/project/scene.dart'; 3 | import 'package:mooltik/common/data/sequence/sequence.dart'; 4 | 5 | class EditorModel extends ChangeNotifier { 6 | EditorModel({ 7 | required Sequence? sceneSeq, 8 | this.writeToDisk, 9 | }) : _sceneSeq = sceneSeq; 10 | 11 | final Sequence? _sceneSeq; 12 | final VoidCallback? writeToDisk; 13 | 14 | void changeCurrentSceneDescription(String newDescription) { 15 | final currentScene = _sceneSeq!.current; 16 | _sceneSeq!.swapSpanAt( 17 | _sceneSeq!.currentIndex, 18 | currentScene.copyWith(description: newDescription), 19 | ); 20 | notifyListeners(); 21 | writeToDisk?.call(); 22 | } 23 | 24 | /// Whether the bottom part shows timeline or board view. 25 | bool get isTimelineView => _isTimelineView; 26 | bool _isTimelineView = true; 27 | 28 | void switchView() { 29 | _isTimelineView = !_isTimelineView; 30 | notifyListeners(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/project_data/a_v1_13.json: -------------------------------------------------------------------------------- 1 | { 2 | "width": 1280.0, 3 | "height": 720.0, 4 | "scenes": [ 5 | { 6 | "layers": [ 7 | { 8 | "frames": [ 9 | { 10 | "file_name": "frame0.png", 11 | "duration": "0:00:03.000000" 12 | }, 13 | { 14 | "file_name": "frame1.png", 15 | "duration": "0:00:01.000000" 16 | } 17 | ], 18 | "play_mode": 1, 19 | "visible": true, 20 | "name": "layer 1" 21 | } 22 | ], 23 | "duration": "0:00:04.000000", 24 | "description": "" 25 | } 26 | ], 27 | "sounds": [ 28 | { 29 | "file_name": "01234.mp3", 30 | "start_time": "0:00:01.000000", 31 | "duration": "0:00:02.000000" 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /lib/common/data/sequence/time_span.dart: -------------------------------------------------------------------------------- 1 | import 'package:mooltik/common/data/project/fps_config.dart'; 2 | 3 | abstract class TimeSpan { 4 | TimeSpan(Duration duration) : _duration = roundDurationToFrames(duration); 5 | 6 | /// Round duration so that it is a multiple of [singleFrameDuration]. 7 | static Duration roundDurationToFrames(Duration duration) { 8 | final frames = 9 | (duration.inMicroseconds / singleFrameDuration.inMicroseconds) 10 | .round() 11 | .clamp(1, double.infinity); 12 | return singleFrameDuration * frames; 13 | } 14 | 15 | /// Ceils duration so that it is a multiple of [singleFrameDuration]. 16 | static Duration ceilDurationToFrames(Duration duration) { 17 | final frames = 18 | (duration.inMicroseconds / singleFrameDuration.inMicroseconds) 19 | .ceil() 20 | .clamp(1, double.infinity); 21 | return singleFrameDuration * frames; 22 | } 23 | 24 | Duration get duration => _duration; 25 | final Duration _duration; 26 | 27 | TimeSpan copyWith({Duration? duration}); 28 | } 29 | -------------------------------------------------------------------------------- /lib/editing/ui/timeline/view/overlay/sliver_action_buttons/visibility_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:provider/provider.dart'; 5 | 6 | class VisibilityButton extends StatelessWidget { 7 | const VisibilityButton({ 8 | Key? key, 9 | required this.rowIndex, 10 | required this.colIndex, 11 | }) : super(key: key); 12 | 13 | final int rowIndex; 14 | final int colIndex; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | final timelineView = context.watch(); 19 | 20 | return SliverActionButton( 21 | iconData: timelineView.isLayerVisible(rowIndex) 22 | ? Icons.visibility_outlined 23 | : Icons.visibility_off_outlined, 24 | onPressed: () => timelineView.toggleLayerVisibility(rowIndex), 25 | rowIndex: rowIndex, 26 | colIndex: colIndex, 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/drawing/ui/lasso/lasso_ui_layer.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mooltik/drawing/data/lasso/lasso_model.dart'; 3 | import 'package:mooltik/drawing/ui/easel/animated_selection.dart'; 4 | import 'package:mooltik/drawing/ui/lasso/transform_box.dart'; 5 | import 'package:provider/provider.dart'; 6 | 7 | class LassoUiLayer extends StatelessWidget { 8 | const LassoUiLayer({Key? key}) : super(key: key); 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | final lassoModel = context.watch(); 13 | 14 | if (lassoModel.isTransformMode) { 15 | return Transform.translate( 16 | offset: lassoModel.transformBoxCenterOffset, 17 | child: TransformBox(size: lassoModel.transformBoxDisplaySize), 18 | ); 19 | } 20 | 21 | if (lassoModel.selectionStroke == null) { 22 | return SizedBox.shrink(); 23 | } 24 | 25 | return Transform.translate( 26 | offset: lassoModel.selectionOffset, 27 | child: AnimatedSelection(selection: lassoModel.selectionPath), 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/common/ui/measure_hack.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/rendering.dart'; 3 | 4 | class MeasureSizeRenderObject extends RenderProxyBox { 5 | MeasureSizeRenderObject(this.onChange); 6 | void Function(Size size) onChange; 7 | 8 | Size? _prevSize; 9 | @override 10 | void performLayout() { 11 | super.performLayout(); 12 | Size newSize = child!.size; 13 | if (_prevSize == newSize) return; 14 | _prevSize = newSize; 15 | WidgetsBinding.instance!.addPostFrameCallback((_) => onChange(newSize)); 16 | } 17 | } 18 | 19 | /// For measuring child size. 20 | /// 21 | /// Source: https://blog.gskinner.com/archives/2021/01/flutter-how-to-measure-widgets.html 22 | class MeasurableWidget extends SingleChildRenderObjectWidget { 23 | const MeasurableWidget( 24 | {Key? key, required this.onChange, required Widget child}) 25 | : super(key: key, child: child); 26 | final void Function(Size size) onChange; 27 | @override 28 | RenderObject createRenderObject(BuildContext context) => 29 | MeasureSizeRenderObject(onChange); 30 | } 31 | -------------------------------------------------------------------------------- /lib/drawing/data/toolbox/tools/tool.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui' as ui; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:mooltik/drawing/data/frame/stroke.dart'; 5 | import 'package:shared_preferences/shared_preferences.dart'; 6 | 7 | typedef PaintOn = Future Function(ui.Image); 8 | 9 | abstract class Tool { 10 | Tool(this.sharedPreferences); 11 | 12 | /// Icon diplayed on the tool's button. 13 | IconData get icon; 14 | 15 | /// Tool name used to prefix shared preferences keys. 16 | String get name; 17 | 18 | final SharedPreferences sharedPreferences; 19 | 20 | Stroke? onStrokeStart(Offset canvasPoint); 21 | void onStrokeUpdate(Offset canvasPoint); 22 | Stroke? onStrokeEnd(); 23 | Stroke? onStrokeCancel(); 24 | 25 | PaintOn? makePaintOn(Rect canvasArea); 26 | } 27 | 28 | abstract class ToolWithColor extends Tool { 29 | ToolWithColor(SharedPreferences sharedPreferences) : super(sharedPreferences); 30 | 31 | Color get color => _color; 32 | Color _color = Colors.black; 33 | 34 | void applyColor(Color color) { 35 | _color = color; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/drawing/ui/fit_to_screen_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_svg/svg.dart'; 3 | import 'package:mooltik/drawing/data/easel_model.dart'; 4 | import 'package:provider/provider.dart'; 5 | 6 | class FitToScreenButton extends StatelessWidget { 7 | const FitToScreenButton({ 8 | Key? key, 9 | }) : super(key: key); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | final isFittedToScreen = context.select( 14 | (easel) => easel.isFittedToScreen, 15 | ); 16 | 17 | return GestureDetector( 18 | behavior: HitTestBehavior.opaque, 19 | onTap: isFittedToScreen 20 | ? null 21 | : () => context.read().fitToScreen(), 22 | child: SizedBox( 23 | width: 52, 24 | height: 44, 25 | child: Opacity( 26 | opacity: isFittedToScreen ? 0.5 : 1, 27 | child: SvgPicture.asset( 28 | 'assets/icons/fa-solid_expand.svg', 29 | fit: BoxFit.none, 30 | ), 31 | ), 32 | ), 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /test/project_data/a_v1_18.json: -------------------------------------------------------------------------------- 1 | { 2 | "width": 1280, 3 | "height": 720, 4 | "scenes": [ 5 | { 6 | "layers": [ 7 | { 8 | "frames": [ 9 | { 10 | "file_name": "frame0.png", 11 | "duration": "0:00:03.000000" 12 | }, 13 | { 14 | "file_name": "frame1.png", 15 | "duration": "0:00:01.000000" 16 | } 17 | ], 18 | "play_mode": 1, 19 | "visible": true, 20 | "name": "layer 1", 21 | "grouped_with_next": false 22 | } 23 | ], 24 | "duration": "0:00:04.000000", 25 | "description": "" 26 | } 27 | ], 28 | "sounds": [ 29 | { 30 | "file_name": "01234.mp3", 31 | "start_time": "0:00:01.000000", 32 | "duration": "0:00:02.000000" 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /lib/editing/ui/export/open_edit_file_name_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mooltik/common/ui/edit_text_dialog.dart'; 3 | import 'package:mooltik/editing/data/export/exporter_model.dart'; 4 | import 'package:provider/provider.dart'; 5 | 6 | void openEditFileNameDialog(BuildContext context) { 7 | final exporter = context.read(); 8 | 9 | Navigator.of(context).push( 10 | MaterialPageRoute( 11 | fullscreenDialog: true, 12 | builder: (context) => EditTextDialog( 13 | title: 'File name', 14 | initialValue: exporter.fileName, 15 | onSubmit: (newName) { 16 | exporter.fileName = newName; 17 | }, 18 | maxLength: 30, 19 | validator: _fileNameValidator, 20 | ), 21 | ), 22 | ); 23 | } 24 | 25 | String? _fileNameValidator(value) { 26 | if (value == null || value.isEmpty) { 27 | return 'Cannot be empty'; 28 | } 29 | 30 | final reg = RegExp(r'^[A-Za-z0-9_-]+$'); 31 | 32 | if (!reg.hasMatch(value)) { 33 | return 'Invalid character used'; 34 | } 35 | 36 | return null; 37 | } 38 | -------------------------------------------------------------------------------- /lib/drawing/ui/layers/all_fingers_lifted_listener.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class AllFingersLiftedListener extends StatefulWidget { 4 | AllFingersLiftedListener({ 5 | Key? key, 6 | required this.onAllFingersLifted, 7 | required this.child, 8 | }) : super(key: key); 9 | 10 | final VoidCallback onAllFingersLifted; 11 | final Widget child; 12 | 13 | @override 14 | _AllFingersLiftedListenerState createState() => 15 | _AllFingersLiftedListenerState(); 16 | } 17 | 18 | class _AllFingersLiftedListenerState extends State { 19 | int _pointersDown = 0; 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | return Listener( 24 | onPointerDown: (_) { 25 | _pointersDown++; 26 | }, 27 | onPointerUp: (_) { 28 | _pointersDown--; 29 | if (_pointersDown == 0) widget.onAllFingersLifted(); 30 | }, 31 | onPointerCancel: (_) { 32 | _pointersDown--; 33 | if (_pointersDown == 0) widget.onAllFingersLifted(); 34 | }, 35 | child: widget.child, 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/drawing/ui/checkerboard_painter.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class CheckerboardPainter extends CustomPainter { 4 | CheckerboardPainter({ 5 | this.tileWidth = 4, 6 | }); 7 | 8 | final double tileWidth; 9 | 10 | @override 11 | void paint(Canvas canvas, Size size) { 12 | canvas.clipRect(Rect.fromLTWH(0, 0, size.width, size.height)); 13 | 14 | final grey = Paint()..color = Colors.grey[300]!; 15 | final white = Paint()..color = Colors.white; 16 | 17 | for (double x = 0; x < size.width; x += tileWidth) { 18 | for (double y = 0; y < size.height; y += tileWidth) { 19 | final rowId = x ~/ tileWidth; 20 | final colId = y ~/ tileWidth; 21 | 22 | final isGrey = (rowId + colId) % 2 == 1; 23 | 24 | canvas.drawRect( 25 | Rect.fromLTWH(x, y, tileWidth, tileWidth), 26 | isGrey ? grey : white, 27 | ); 28 | } 29 | } 30 | } 31 | 32 | @override 33 | bool shouldRepaint(CheckerboardPainter oldDelegate) => false; 34 | 35 | @override 36 | bool shouldRebuildSemantics(CheckerboardPainter oldDelegate) => false; 37 | } 38 | -------------------------------------------------------------------------------- /lib/drawing/ui/reel/frame_number_box.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | class FrameNumberBox extends StatelessWidget { 6 | const FrameNumberBox({ 7 | Key? key, 8 | required this.selected, 9 | required this.number, 10 | }) : super(key: key); 11 | 12 | final bool selected; 13 | final int? number; 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return Container( 18 | height: 20, 19 | constraints: const BoxConstraints(minWidth: 20), 20 | padding: const EdgeInsets.symmetric(horizontal: 4), 21 | decoration: BoxDecoration( 22 | color: selected ? Colors.white : Colors.transparent, 23 | borderRadius: BorderRadius.circular(6), 24 | ), 25 | child: Center( 26 | child: Text( 27 | '$number', 28 | style: TextStyle( 29 | color: selected ? Colors.grey[900] : Colors.white, 30 | fontSize: 12, 31 | fontWeight: FontWeight.bold, 32 | fontFeatures: [FontFeature.tabularFigures()], 33 | ), 34 | ), 35 | ), 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/home/ui/discord_sliver.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | import 'package:url_launcher/url_launcher.dart'; 4 | 5 | class DiscordSliver extends StatelessWidget { 6 | const DiscordSliver({Key? key}) : super(key: key); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return SliverToBoxAdapter( 11 | child: Column( 12 | children: [ 13 | const SizedBox(height: 32), 14 | Text( 15 | [ 16 | 'Ask for more features.', 17 | 'Get technical support.', 18 | 'Look what others have created.', 19 | ].join('\n'), 20 | textAlign: TextAlign.center, 21 | style: TextStyle(height: 1.4), 22 | ), 23 | const SizedBox(height: 8), 24 | ElevatedButton.icon( 25 | icon: Icon(FontAwesomeIcons.discord), 26 | label: Text('Join Discord community'), 27 | onPressed: () => launch('https://discord.gg/qCra96BsN4'), 28 | ), 29 | ], 30 | ), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/drawing/data/frame/stroke.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | class Stroke { 6 | Stroke(Offset startingPoint, this.paint) 7 | : path = Path()..moveTo(startingPoint.dx, startingPoint.dy), 8 | _lastPoint = startingPoint; 9 | 10 | final Paint paint; 11 | Path path; 12 | 13 | Rect get boundingRect => path.getBounds().inflate(paint.strokeWidth); 14 | 15 | Offset get lastPoint => _lastPoint; 16 | Offset _lastPoint; 17 | 18 | double get width => paint.strokeWidth; 19 | 20 | void extend(Offset point) { 21 | if (_lastPoint == point) return; 22 | final mid = _midPoint(_lastPoint, point); 23 | path.quadraticBezierTo(_lastPoint.dx, _lastPoint.dy, mid.dx, mid.dy); 24 | _lastPoint = point; 25 | } 26 | 27 | void finish() { 28 | // Extend a single point stroke. 29 | if (path.getBounds().isEmpty) { 30 | extend(_lastPoint.translate(0.01, 0.01)); 31 | } 32 | } 33 | 34 | Offset _midPoint(Offset p1, Offset p2) { 35 | return p1 + (p2 - p1) / 2; 36 | } 37 | 38 | void paintOn(Canvas canvas) { 39 | canvas.drawPath(path, paint); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/editing/ui/timeline/actionbar/play_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | import 'package:mooltik/common/ui/app_icon_button.dart'; 4 | import 'package:mooltik/editing/data/player_model.dart'; 5 | import 'package:mooltik/editing/data/timeline/timeline_model.dart'; 6 | import 'package:provider/provider.dart'; 7 | 8 | class PlayButton extends StatelessWidget { 9 | @override 10 | Widget build(BuildContext context) { 11 | final timeline = context.watch(); 12 | 13 | // Pause 14 | if (timeline.isPlaying) 15 | return AppIconButton( 16 | icon: FontAwesomeIcons.pause, 17 | onTap: () { 18 | timeline.pause(); 19 | }, 20 | ); 21 | 22 | // Play 23 | return AppIconButton( 24 | icon: FontAwesomeIcons.play, 25 | onTap: () async { 26 | if (timeline.playheadPosition == timeline.playheadEndBound) { 27 | timeline.jumpTo(timeline.playheadStartBound); 28 | } 29 | await context.read().prepare(); 30 | timeline.play(); 31 | }, 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /android/app/google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "177520826749", 4 | "project_id": "mooltik-760af", 5 | "storage_bucket": "mooltik-760af.appspot.com" 6 | }, 7 | "client": [ 8 | { 9 | "client_info": { 10 | "mobilesdk_app_id": "1:177520826749:android:ee4b46b2fb323e2636d414", 11 | "android_client_info": { 12 | "package_name": "com.kakimov.mooltik" 13 | } 14 | }, 15 | "oauth_client": [ 16 | { 17 | "client_id": "177520826749-n8h076a21dsusirg091dq1snnjcs8ra9.apps.googleusercontent.com", 18 | "client_type": 3 19 | } 20 | ], 21 | "api_key": [ 22 | { 23 | "current_key": "AIzaSyD9tZfQYKwJrfywl7QtdNs91rN6vuazfmg" 24 | } 25 | ], 26 | "services": { 27 | "appinvite_service": { 28 | "other_platform_oauth_client": [ 29 | { 30 | "client_id": "177520826749-n8h076a21dsusirg091dq1snnjcs8ra9.apps.googleusercontent.com", 31 | "client_type": 3 32 | } 33 | ] 34 | } 35 | } 36 | } 37 | ], 38 | "configuration_version": "1" 39 | } -------------------------------------------------------------------------------- /test/debouncer_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:mooltik/common/data/debouncer.dart'; 3 | import 'package:fake_async/fake_async.dart'; 4 | 5 | void main() { 6 | group('Debouncer', () { 7 | test('handles single call', () { 8 | fakeAsync((async) { 9 | final d = Debouncer(Duration(seconds: 2)); 10 | final s = Stopwatch(); 11 | s.start(); 12 | d.debounce(() { 13 | expect(s.elapsed, Duration(seconds: 2)); 14 | s.stop(); 15 | }); 16 | }); 17 | }); 18 | 19 | test('handles multiple calls', () { 20 | fakeAsync((async) { 21 | final d = Debouncer(Duration(seconds: 2)); 22 | final s = Stopwatch(); 23 | s.start(); 24 | int callbacksExecuted = 0; 25 | d.debounce(() => callbacksExecuted++); 26 | async.elapse(Duration(seconds: 1)); 27 | d.debounce(() => callbacksExecuted++); 28 | async.elapse(Duration(seconds: 1)); 29 | d.debounce(() { 30 | expect(callbacksExecuted, 0); 31 | expect(s.elapsed, Duration(seconds: 4)); 32 | s.stop(); 33 | }); 34 | }); 35 | }); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /lib/common/data/project/composite_frame.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui' as ui; 2 | 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:mooltik/common/data/io/generate_image.dart'; 5 | import 'package:mooltik/common/data/project/composite_image.dart'; 6 | import 'package:mooltik/common/data/project/frame_interface.dart'; 7 | import 'package:mooltik/common/data/sequence/time_span.dart'; 8 | import 'package:mooltik/common/ui/composite_image_painter.dart'; 9 | 10 | /// Composite image with duration. 11 | class CompositeFrame extends TimeSpan 12 | with EquatableMixin 13 | implements FrameInterface { 14 | CompositeFrame(this.image, Duration duration) : super(duration); 15 | 16 | final CompositeImage image; 17 | 18 | int get width => image.width; 19 | 20 | int get height => image.height; 21 | 22 | @override 23 | TimeSpan copyWith({Duration? duration}) => CompositeFrame( 24 | this.image, 25 | duration ?? this.duration, 26 | ); 27 | 28 | Future toImage() => generateImage( 29 | CompositeImagePainter(image), 30 | width, 31 | height, 32 | ); 33 | 34 | @override 35 | List get props => [width, height, image, duration]; 36 | } 37 | -------------------------------------------------------------------------------- /lib/editing/ui/timeline/view/overlay/scene_end_handle.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mooltik/editing/data/timeline/timeline_model.dart'; 3 | import 'package:mooltik/editing/data/timeline/timeline_view_model.dart'; 4 | import 'package:mooltik/editing/ui/timeline/view/overlay/timeline_positioned.dart'; 5 | import 'package:provider/provider.dart'; 6 | 7 | class SceneEndHandle extends StatelessWidget { 8 | @override 9 | Widget build(BuildContext context) { 10 | final timelineView = context.watch(); 11 | 12 | return TimelinePositioned( 13 | timestamp: timelineView.sceneEnd, 14 | y: timelineView.rowMiddle(0), 15 | offset: const Offset(48 / 2, 0), 16 | width: 48, 17 | height: 48, 18 | onDragUpdate: (Duration updatedTime) => 19 | timelineView.onSceneEndHandleDragUpdate(updatedTime), 20 | onDragEnd: (_) { 21 | // Keep playhead within boundaries. 22 | final timeline = context.read(); 23 | timeline.jumpTo(timeline.playheadPosition); 24 | }, 25 | child: RotatedBox( 26 | quarterTurns: 1, 27 | child: Icon(Icons.drag_handle_rounded), 28 | ), 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ios/GoogleService-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CLIENT_ID 6 | 177520826749-k5dq2vp1qnj8iv267ltlue4iqkb57gh0.apps.googleusercontent.com 7 | REVERSED_CLIENT_ID 8 | com.googleusercontent.apps.177520826749-k5dq2vp1qnj8iv267ltlue4iqkb57gh0 9 | API_KEY 10 | AIzaSyA0ON8Ax_kPbfQcDMhqFJLOIqCKu2YEFIg 11 | GCM_SENDER_ID 12 | 177520826749 13 | PLIST_VERSION 14 | 1 15 | BUNDLE_ID 16 | com.kakimov.mooltik 17 | PROJECT_ID 18 | mooltik-760af 19 | STORAGE_BUCKET 20 | mooltik-760af.appspot.com 21 | IS_ADS_ENABLED 22 | 23 | IS_ANALYTICS_ENABLED 24 | 25 | IS_APPINVITE_ENABLED 26 | 27 | IS_GCM_ENABLED 28 | 29 | IS_SIGNIN_ENABLED 30 | 31 | GOOGLE_APP_ID 32 | 1:177520826749:ios:165ef566a03b0f1d36d414 33 | 34 | -------------------------------------------------------------------------------- /lib/common/data/extensions/duration_methods.dart: -------------------------------------------------------------------------------- 1 | extension DurationMethods on Duration { 2 | Duration clamp(Duration min, Duration max) => Duration( 3 | microseconds: this.inMicroseconds.clamp( 4 | min.inMicroseconds, 5 | max.inMicroseconds, 6 | )); 7 | 8 | Duration operator %(Duration other) => 9 | Duration(microseconds: this.inMicroseconds % other.inMicroseconds); 10 | 11 | double operator /(Duration other) => 12 | this.inMicroseconds / other.inMicroseconds; 13 | 14 | Duration operator *(double scalar) => 15 | Duration(microseconds: (this.inMicroseconds * scalar).round()); 16 | } 17 | 18 | Duration minDuration(Duration a, Duration b) => a <= b ? a : b; 19 | 20 | extension DurationParsing on String { 21 | Duration parseDuration() { 22 | final re = RegExp(r'^(\d+):(\d{2}):(\d{2})\.(\d{6})$'); 23 | final match = re.firstMatch(this); 24 | 25 | if (match == null) { 26 | throw Exception('Could not parse duration from $this.'); 27 | } 28 | 29 | return Duration( 30 | hours: int.parse(match.group(1)!), 31 | minutes: int.parse(match.group(2)!), 32 | seconds: int.parse(match.group(3)!), 33 | microseconds: int.parse(match.group(4)!), 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/editing/ui/timeline/view/overlay/resize_end_handle.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mooltik/editing/data/timeline/timeline_model.dart'; 3 | import 'package:mooltik/editing/data/timeline/timeline_view_model.dart'; 4 | import 'package:mooltik/editing/ui/timeline/view/overlay/resize_handle.dart'; 5 | import 'package:mooltik/editing/ui/timeline/view/overlay/timeline_positioned.dart'; 6 | import 'package:provider/provider.dart'; 7 | 8 | class ResizeEndHandle extends StatelessWidget { 9 | const ResizeEndHandle({ 10 | Key? key, 11 | required this.timelineView, 12 | }) : super(key: key); 13 | 14 | final TimelineViewModel timelineView; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return TimelinePositioned( 19 | timestamp: timelineView.selectedSliverEndTime, 20 | y: timelineView.selectedSliverMidY, 21 | width: resizeHandleWidth, 22 | height: resizeHandleHeight, 23 | onDragUpdate: (Duration updatedTime) => 24 | timelineView.onEndTimeHandleDragUpdate(updatedTime), 25 | onDragEnd: (_) { 26 | // Keep playhead within boundaries. 27 | final timeline = context.read(); 28 | timeline.jumpTo(timeline.playheadPosition); 29 | }, 30 | child: ResizeHandle(), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/drawing/ui/layers/layer_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mooltik/common/data/project/project.dart'; 3 | import 'package:provider/provider.dart'; 4 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 5 | import 'package:mooltik/drawing/data/reel_stack_model.dart'; 6 | import 'package:mooltik/drawing/ui/layers/layer_sheet.dart'; 7 | import 'package:mooltik/common/ui/open_side_sheet.dart'; 8 | 9 | class LayerButton extends StatelessWidget { 10 | const LayerButton({ 11 | Key? key, 12 | }) : super(key: key); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | return FloatingActionButton( 17 | mini: true, 18 | elevation: 2, 19 | child: Icon(FontAwesomeIcons.layerGroup, size: 20), 20 | onPressed: () => _openLayersSheet(context), 21 | ); 22 | } 23 | 24 | void _openLayersSheet(BuildContext context) { 25 | final reelStack = context.read(); 26 | final project = context.read(); 27 | 28 | openSideSheet( 29 | context: context, 30 | builder: (context) => MultiProvider( 31 | providers: [ 32 | ChangeNotifierProvider.value(value: reelStack), 33 | ChangeNotifierProvider.value(value: project), 34 | ], 35 | child: LayerSheet(), 36 | ), 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/drawing/ui/menu_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | import 'package:mooltik/common/ui/app_icon_button.dart'; 4 | import 'package:mooltik/common/ui/popup_with_arrow.dart'; 5 | import 'package:mooltik/drawing/ui/drawing_menu.dart'; 6 | 7 | class MenuButton extends StatefulWidget { 8 | const MenuButton({ 9 | Key? key, 10 | }) : super(key: key); 11 | 12 | @override 13 | _MenuButtonState createState() => _MenuButtonState(); 14 | } 15 | 16 | class _MenuButtonState extends State { 17 | bool _menuOpen = false; 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return PopupWithArrowEntry( 22 | visible: _menuOpen, 23 | arrowSide: ArrowSide.top, 24 | arrowSidePosition: ArrowSidePosition.start, 25 | popupBody: DrawingMenu( 26 | onDone: () { 27 | setState(() => _menuOpen = false); 28 | }, 29 | ), 30 | child: AppIconButton( 31 | icon: FontAwesomeIcons.cog, 32 | onTap: () { 33 | setState(() => _menuOpen = true); 34 | }, 35 | ), 36 | onTapOutside: () { 37 | setState(() => _menuOpen = false); 38 | }, 39 | onDragOutside: () { 40 | setState(() => _menuOpen = false); 41 | }, 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/common/data/copy_paster_model.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui' as ui; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:mooltik/common/data/io/generate_image.dart'; 5 | 6 | class CopyPasterModel extends ChangeNotifier { 7 | ui.Image? get copiedImage => _copiedImage; 8 | ui.Image? _copiedImage; 9 | 10 | void copyImage(ui.Image? image) { 11 | _copiedImage?.dispose(); 12 | _copiedImage = image?.clone(); 13 | notifyListeners(); 14 | } 15 | 16 | bool get canPaste => _copiedImage != null; 17 | 18 | Future pasteOn(ui.Image destination) async { 19 | return generateImage( 20 | PastePainter(source: _copiedImage!, destination: destination), 21 | destination.width, 22 | destination.height, 23 | ); 24 | } 25 | } 26 | 27 | class PastePainter extends CustomPainter { 28 | PastePainter({ 29 | required this.source, 30 | required this.destination, 31 | }); 32 | 33 | final ui.Image source; 34 | final ui.Image destination; 35 | 36 | @override 37 | void paint(Canvas canvas, Size size) { 38 | canvas.drawImage(destination, Offset.zero, Paint()); 39 | canvas.drawImage(source, Offset.zero, Paint()); 40 | } 41 | 42 | @override 43 | bool shouldRepaint(PastePainter oldDelegate) => false; 44 | 45 | @override 46 | bool shouldRebuildSemantics(PastePainter oldDelegate) => false; 47 | } 48 | -------------------------------------------------------------------------------- /lib/drawing/ui/brush_tip_picker.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mooltik/drawing/data/toolbox/tools/brush.dart'; 3 | import 'package:mooltik/drawing/ui/brush_tip_button.dart'; 4 | 5 | class BrushTipPicker extends StatelessWidget { 6 | const BrushTipPicker({ 7 | Key? key, 8 | required this.selectedIndex, 9 | required this.brushTips, 10 | required this.minStrokeWidth, 11 | required this.maxStrokeWidth, 12 | this.onSelected, 13 | }) : super(key: key); 14 | 15 | final int selectedIndex; 16 | final List brushTips; 17 | 18 | final double minStrokeWidth; 19 | final double maxStrokeWidth; 20 | 21 | final void Function(int)? onSelected; 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | return Padding( 26 | padding: const EdgeInsets.all(12), 27 | child: Row( 28 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 29 | children: [ 30 | for (var i = 0; i < brushTips.length; i++) 31 | BrushTipButton( 32 | canvasSize: Size.square(maxStrokeWidth * 1.3), 33 | brushTip: brushTips[i], 34 | selected: i == selectedIndex, 35 | onTap: () { 36 | onSelected?.call(i); 37 | }, 38 | ), 39 | ], 40 | ), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/common/data/project/composite_image.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:mooltik/common/data/project/base_image.dart'; 5 | import 'package:mooltik/common/data/io/disk_image.dart'; 6 | 7 | /// Stack of images of the same size. 8 | class CompositeImage extends BaseImage { 9 | CompositeImage(this.layers) 10 | : width = layers.first.width, 11 | height = layers.first.height, 12 | assert(layers.isNotEmpty && 13 | layers.every((image) => 14 | image.height == layers.first.height && 15 | image.width == layers.first.width)); 16 | 17 | CompositeImage.empty({ 18 | required this.width, 19 | required this.height, 20 | }) : layers = []; 21 | 22 | final int width; 23 | final int height; 24 | 25 | /// Image layers from top to bottom. 26 | final List layers; 27 | 28 | @override 29 | List get props => [width, height, layers]; 30 | 31 | @override 32 | void draw(Canvas canvas, Offset offset, Paint paint) { 33 | canvas.drawCompositeImage(this, offset, paint); 34 | } 35 | } 36 | 37 | extension CompositeImageDrawing on Canvas { 38 | void drawCompositeImage(CompositeImage image, Offset offset, Paint paint) { 39 | for (final layer in image.layers.reversed) { 40 | layer.draw(this, offset, paint); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/editing/ui/timeline/view/overlay/sliver_action_buttons/sliver_action_button.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/ui/timeline/view/overlay/timeline_positioned.dart'; 5 | 6 | const _width = 48.0; 7 | 8 | class SliverActionButton extends StatelessWidget { 9 | const SliverActionButton({ 10 | Key? key, 11 | required this.iconData, 12 | required this.onPressed, 13 | required this.rowIndex, 14 | required this.colIndex, 15 | }) : super(key: key); 16 | 17 | final IconData? iconData; 18 | final VoidCallback onPressed; 19 | 20 | final int rowIndex; 21 | final int colIndex; 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | final timelineView = context.watch(); 26 | 27 | return TimelinePositioned( 28 | timestamp: timelineView.sceneStart, 29 | y: timelineView.rowMiddle(rowIndex), 30 | width: _width, 31 | height: _width, 32 | offset: Offset(-32.0 - _width * colIndex, 0), 33 | child: IconButton( 34 | icon: Icon( 35 | iconData, 36 | color: Theme.of(context).colorScheme.onBackground, 37 | size: 20, 38 | ), 39 | onPressed: onPressed, 40 | ), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/drawing/ui/easel/cursor_painter.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mooltik/drawing/data/frame/stroke.dart'; 3 | 4 | class CursorPainter extends CustomPainter { 5 | CursorPainter({ 6 | required this.frameSize, 7 | required this.lastStroke, 8 | }); 9 | 10 | final Size frameSize; 11 | final Stroke lastStroke; 12 | 13 | @override 14 | void paint(Canvas canvas, Size size) { 15 | final scale = size.width / frameSize.width; 16 | canvas.scale(scale); 17 | _drawCursor( 18 | canvas, 19 | lastStroke.lastPoint, 20 | lastStroke.width / 2, 21 | scale, 22 | ); 23 | } 24 | 25 | @override 26 | bool shouldRepaint(CursorPainter oldDelegate) => true; 27 | 28 | @override 29 | bool shouldRebuildSemantics(CursorPainter oldDelegate) => false; 30 | 31 | void _drawCursor( 32 | Canvas canvas, 33 | Offset center, 34 | double radius, 35 | double scale, 36 | ) { 37 | canvas.drawCircle( 38 | center, 39 | radius, 40 | Paint() 41 | ..style = PaintingStyle.stroke 42 | ..strokeWidth = 3 / scale 43 | ..color = Colors.white, 44 | ); 45 | canvas.drawCircle( 46 | center, 47 | radius, 48 | Paint() 49 | ..style = PaintingStyle.stroke 50 | ..strokeWidth = 1 / scale 51 | ..color = Colors.black, 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | 23 | # Steps represent a sequence of tasks that will be executed as part of the job 24 | steps: 25 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 26 | - uses: actions/checkout@v2 27 | 28 | # Runs a single command using the runners shell 29 | # - name: Run a one-line script 30 | # run: echo Hello, world! 31 | 32 | # Runs a set of commands using the runners shell 33 | # - name: Run a multi-line script 34 | # run: | 35 | # echo Add other actions to build, 36 | # echo test, and deploy your project. 37 | 38 | - name: Flutter action 39 | uses: subosito/flutter-action@v1.5.3 40 | -------------------------------------------------------------------------------- /lib/common/data/io/make_duplicate_path.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:path/path.dart' as p; 5 | 6 | /// Returns an unoccupied duplicate path. 7 | String makeFreeDuplicatePath(String originalPath) { 8 | var duplicatePath = makeDuplicatePath(originalPath); 9 | 10 | while (File(duplicatePath).existsSync()) { 11 | duplicatePath = makeDuplicatePath(duplicatePath); 12 | } 13 | 14 | return duplicatePath; 15 | } 16 | 17 | /// Returns a new path for a duplicate file. 18 | /// `example/path/image.png` -> `example/path/image_1.png` 19 | /// `example/path/image_1.png` -> `example/path/image_2.png` 20 | @visibleForTesting 21 | String makeDuplicatePath(String path) { 22 | final dir = p.dirname(path); 23 | final name = p.basenameWithoutExtension(path); 24 | final ext = p.extension(path); 25 | 26 | final newName = 27 | _hasCounter(name) ? _incrementCounter(name) : _createCounter(name); 28 | 29 | return p.join(dir, newName + ext); 30 | } 31 | 32 | bool _hasCounter(String name) { 33 | return RegExp(r'_\d+$').hasMatch(name); 34 | } 35 | 36 | String _createCounter(String name) { 37 | return name + '_1'; 38 | } 39 | 40 | String _incrementCounter(String name) { 41 | final parts = name.split('_'); 42 | 43 | final newCounterValue = int.parse(parts.last) + 1; 44 | parts.last = newCounterValue.toString(); 45 | 46 | return parts.join('_'); 47 | } 48 | -------------------------------------------------------------------------------- /lib/drawing/data/frame/selection_stroke.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:mooltik/drawing/data/frame/stroke.dart'; 5 | 6 | class SelectionStroke extends Stroke { 7 | SelectionStroke(Offset startingPoint) : super(startingPoint, Paint()); 8 | 9 | /// Whether the path has been closed. 10 | bool get finished => _finished; 11 | bool _finished = false; 12 | 13 | @override 14 | Rect get boundingRect => path.getBounds(); 15 | 16 | double get area { 17 | final bounds = boundingRect; 18 | return bounds.width * bounds.height; 19 | } 20 | 21 | bool get isTooSmall => area < 100; 22 | 23 | @override 24 | void finish() { 25 | path.close(); 26 | _finished = true; 27 | } 28 | 29 | void clipToFrame(Rect frameArea) { 30 | final frameCircumference = Path() 31 | ..addPolygon([ 32 | frameArea.topLeft, 33 | frameArea.topRight, 34 | frameArea.bottomRight, 35 | frameArea.bottomLeft, 36 | ], true); 37 | path = Path.combine(PathOperation.intersect, path, frameCircumference); 38 | } 39 | 40 | void setColorPaint(Color color) { 41 | paint 42 | ..style = PaintingStyle.fill 43 | ..color = color 44 | ..blendMode = BlendMode.srcOver; 45 | } 46 | 47 | void setErasingPaint() { 48 | paint 49 | ..style = PaintingStyle.fill 50 | ..blendMode = BlendMode.dstOut; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /lib/editing/ui/timeline/view/overlay/sliver_action_buttons/play_mode_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mooltik/common/data/project/scene_layer.dart'; 3 | import 'package:mooltik/editing/data/timeline/timeline_view_model.dart'; 4 | import 'package:mooltik/editing/ui/timeline/view/overlay/sliver_action_buttons/sliver_action_button.dart'; 5 | import 'package:provider/provider.dart'; 6 | 7 | class PlayModeButton extends StatelessWidget { 8 | const PlayModeButton({ 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 | final timelineView = context.watch(); 20 | final playMode = timelineView.layerPlayMode(rowIndex); 21 | 22 | return SliverActionButton( 23 | iconData: _getIcon(playMode), 24 | onPressed: () => timelineView.nextScenePlayModeForLayer(rowIndex), 25 | rowIndex: rowIndex, 26 | colIndex: colIndex, 27 | ); 28 | } 29 | 30 | IconData? _getIcon(PlayMode playMode) { 31 | switch (playMode) { 32 | case PlayMode.extendLast: 33 | return Icons.trending_flat_rounded; 34 | case PlayMode.loop: 35 | return Icons.autorenew_rounded; 36 | case PlayMode.pingPong: 37 | return Icons.sync_alt_rounded; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/drawing/data/drawing_page_options_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:shared_preferences/shared_preferences.dart'; 3 | 4 | class DrawingPageOptionsModel extends ChangeNotifier { 5 | DrawingPageOptionsModel( 6 | SharedPreferences sharedPreferences, 7 | ) : _sharedPreferences = sharedPreferences, 8 | _showFrameReel = sharedPreferences.getBool(_showFrameReelKey) ?? true, 9 | _allowDrawingWithFinger = 10 | sharedPreferences.getBool(_allowDrawingWithFingerKey) ?? true; 11 | 12 | SharedPreferences _sharedPreferences; 13 | 14 | /// Whether frame reel UI is visible. 15 | bool get showFrameReel => _showFrameReel; 16 | bool _showFrameReel; 17 | 18 | Future toggleFrameReelVisibility() async { 19 | _showFrameReel = !_showFrameReel; 20 | notifyListeners(); 21 | await _sharedPreferences.setBool(_showFrameReelKey, _showFrameReel); 22 | } 23 | 24 | bool get allowDrawingWithFinger => _allowDrawingWithFinger; 25 | bool _allowDrawingWithFinger; 26 | 27 | Future toggleDrawingWithFinger() async { 28 | _allowDrawingWithFinger = !_allowDrawingWithFinger; 29 | notifyListeners(); 30 | 31 | await _sharedPreferences.setBool( 32 | _allowDrawingWithFingerKey, 33 | _allowDrawingWithFinger, 34 | ); 35 | } 36 | } 37 | 38 | const _showFrameReelKey = 'frame_reel_visible'; 39 | const _allowDrawingWithFingerKey = 'allow_drawing_with_finger'; 40 | -------------------------------------------------------------------------------- /lib/drawing/ui/lasso/transformed_image_painter.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui' as ui; 2 | import 'package:flutter/material.dart'; 3 | 4 | /// Paints transformed image over the frame. Must be the size of the frame. 5 | class TransformedImagePainter extends CustomPainter { 6 | TransformedImagePainter({ 7 | required this.transformedImage, 8 | required this.transform, 9 | this.background, 10 | }); 11 | 12 | final ui.Image? transformedImage; 13 | final Matrix4 transform; 14 | final ui.Image? background; 15 | 16 | @override 17 | void paint(Canvas canvas, Size size) { 18 | canvas.clipRect(Rect.fromLTWH(0, 0, size.width, size.height)); 19 | 20 | if (background != null) { 21 | canvas.drawImage( 22 | background!, 23 | Offset.zero, 24 | Paint() 25 | ..isAntiAlias = true 26 | ..filterQuality = FilterQuality.high, 27 | ); 28 | } 29 | 30 | canvas.transform(transform.storage); 31 | 32 | canvas.drawImage( 33 | transformedImage!, 34 | Offset.zero, 35 | Paint() 36 | ..isAntiAlias = true 37 | ..filterQuality = FilterQuality.high, 38 | ); 39 | } 40 | 41 | @override 42 | bool shouldRepaint(TransformedImagePainter oldDelegate) => 43 | oldDelegate.transformedImage != transformedImage || 44 | oldDelegate.transform != transform; 45 | 46 | @override 47 | bool shouldRebuildSemantics(TransformedImagePainter oldDelegate) => false; 48 | } 49 | -------------------------------------------------------------------------------- /lib/editing/data/timeline/timeline_scene_row.dart: -------------------------------------------------------------------------------- 1 | import 'package:mooltik/common/data/sequence/sequence.dart'; 2 | import 'package:mooltik/common/data/project/scene.dart'; 3 | import 'package:mooltik/editing/data/timeline/timeline_row_interfaces.dart'; 4 | 5 | class TimelineSceneRow implements TimelineSceneRowInterface { 6 | TimelineSceneRow(this._sceneSequence); 7 | 8 | final Sequence _sceneSequence; 9 | 10 | @override 11 | int get clipCount => _sceneSequence.length; 12 | 13 | @override 14 | Iterable get clips => _sceneSequence.iterable; 15 | 16 | @override 17 | Scene clipAt(int index) { 18 | return _sceneSequence[index]; 19 | } 20 | 21 | @override 22 | void insertSceneAfter(int index, Scene newScene) { 23 | _sceneSequence.insert(index + 1, newScene); 24 | } 25 | 26 | @override 27 | Scene deleteAt(int index) { 28 | return _sceneSequence.removeAt(index); 29 | } 30 | 31 | @override 32 | Future 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 | Get it on Google Play 9 | Download on the App Store 10 |

11 | 12 |
13 | 14 | [![Codemagic build status](https://api.codemagic.io/apps/60363e65c9d4d7cf9b10cfc0/default-workflow/status_badge.svg)](https://codemagic.io/apps/60363e65c9d4d7cf9b10cfc0/default-workflow/latest_build) 15 | 16 | Awesome Flutter 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 get props => [image.file.path, duration]; 65 | 66 | static const String _fileNameKey = 'file_name'; 67 | static const String _durationKey = 'duration'; 68 | } 69 | -------------------------------------------------------------------------------- /lib/common/data/project/layer_group/frame_reel_group.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mooltik/common/data/project/frame_interface.dart'; 3 | import 'package:mooltik/common/data/project/layer_group/combine_frames.dart'; 4 | import 'package:mooltik/common/data/sequence/sequence.dart'; 5 | import 'package:mooltik/drawing/data/frame/frame.dart'; 6 | import 'package:mooltik/drawing/data/frame_reel_model.dart'; 7 | 8 | /// Wrapper around `FrameReelModel` to keep grouped layers in sync. 9 | class FrameReelGroup extends ChangeNotifier implements FrameReelModel { 10 | FrameReelGroup({ 11 | required this.activeReel, 12 | required this.group, 13 | }); 14 | 15 | final FrameReelModel activeReel; 16 | final List group; 17 | 18 | @override 19 | Sequence get frameSeq => activeReel.frameSeq; 20 | 21 | @override 22 | Frame get currentFrame => activeReel.currentFrame; 23 | 24 | @override 25 | FrameInterface get deleteDialogFrame => 26 | combineFrames(group.map((reel) => reel.currentFrame)); 27 | 28 | @override 29 | int get currentIndex => activeReel.currentIndex; 30 | 31 | @override 32 | void setCurrent(int index) { 33 | group.forEach((reel) => reel.setCurrent(index)); 34 | notifyListeners(); 35 | } 36 | 37 | @override 38 | Future appendFrame() async { 39 | await Future.wait(group.map((reel) => reel.appendFrame())); 40 | notifyListeners(); 41 | } 42 | 43 | @override 44 | Future addBeforeCurrent() async { 45 | await Future.wait(group.map((reel) => reel.addBeforeCurrent())); 46 | notifyListeners(); 47 | } 48 | 49 | @override 50 | Future addAfterCurrent() async { 51 | await Future.wait(group.map((reel) => reel.addAfterCurrent())); 52 | notifyListeners(); 53 | } 54 | 55 | @override 56 | Future duplicateCurrent() async { 57 | await Future.wait(group.map((reel) => reel.duplicateCurrent())); 58 | notifyListeners(); 59 | } 60 | 61 | @override 62 | bool get canDeleteCurrent => activeReel.canDeleteCurrent; 63 | 64 | @override 65 | void deleteCurrent() { 66 | group.forEach((reel) => reel.deleteCurrent()); 67 | notifyListeners(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/common/ui/slide_action_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_slidable/flutter_slidable.dart'; 3 | 4 | class SlideActionButton extends StatelessWidget { 5 | const SlideActionButton({ 6 | Key? key, 7 | required this.icon, 8 | required this.label, 9 | required this.color, 10 | this.onTap, 11 | this.rightSide = true, 12 | }) : super(key: key); 13 | 14 | /// Must be an icon from the material design icons font. 15 | final IconData icon; 16 | 17 | final String label; 18 | final Color color; 19 | final VoidCallback? onTap; 20 | final bool rightSide; 21 | 22 | @override 23 | Widget build(BuildContext context) { 24 | return Theme( 25 | data: ThemeData( 26 | highlightColor: Colors.transparent, // Remove square splash. 27 | ), 28 | child: SlideAction( 29 | closeOnTap: true, 30 | onTap: onTap, 31 | child: Container( 32 | margin: rightSide 33 | ? const EdgeInsets.only(left: 8) 34 | : const EdgeInsets.only(right: 8), 35 | height: double.infinity, 36 | width: double.infinity, 37 | decoration: BoxDecoration( 38 | color: color, 39 | borderRadius: BorderRadius.circular(8), 40 | ), 41 | child: Opacity( 42 | opacity: onTap == null ? 0.5 : 1, 43 | child: _buildLabeledIcon(), 44 | ), 45 | ), 46 | ), 47 | ); 48 | } 49 | 50 | Widget _buildLabeledIcon() { 51 | return Column( 52 | mainAxisAlignment: MainAxisAlignment.center, 53 | crossAxisAlignment: CrossAxisAlignment.center, 54 | children: [ 55 | Icon( 56 | icon, 57 | size: 20, 58 | color: Colors.white, 59 | ), 60 | SizedBox(height: 6), 61 | Text( 62 | label, 63 | style: TextStyle( 64 | fontSize: 10, 65 | fontWeight: FontWeight.w700, 66 | color: Colors.white, 67 | ), 68 | textAlign: TextAlign.center, 69 | softWrap: false, 70 | overflow: TextOverflow.visible, 71 | ), 72 | ], 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/common/data/flood_fill.dart: -------------------------------------------------------------------------------- 1 | import 'dart:isolate'; 2 | import 'dart:typed_data'; 3 | import 'dart:ui' as ui; 4 | 5 | import 'package:mooltik/common/data/extensions/color_methods.dart'; 6 | import 'package:mooltik/common/data/io/image.dart'; 7 | import 'package:mooltik/ffi_bridge.dart'; 8 | 9 | /// Flood fills [image] with the given [color] starting at [startX], [startY]. 10 | Future floodFill( 11 | ui.Image source, 12 | int startX, 13 | int startY, 14 | ui.Color color, 15 | ) async { 16 | final imageByteData = await source.toByteData(); 17 | 18 | // Can be refactored with `compute` after this PR (https://github.com/flutter/flutter/pull/86591) lands in stable. 19 | final receivePort = ReceivePort(); 20 | 21 | final isolate = await Isolate.spawn( 22 | _fillIsolate, 23 | _FillIsolateParams( 24 | imageByteData: imageByteData!, 25 | width: source.width, 26 | height: source.height, 27 | startX: startX, 28 | startY: startY, 29 | fillColor: color, 30 | sendPort: receivePort.sendPort, 31 | ), 32 | ); 33 | 34 | final resultByteData = await receivePort.first as ByteData?; 35 | 36 | receivePort.close(); 37 | isolate.kill(); 38 | 39 | if (resultByteData == null) return null; 40 | 41 | return imageFromRawBytes(resultByteData, source.width, source.height); 42 | } 43 | 44 | class _FillIsolateParams { 45 | final ByteData imageByteData; 46 | final int width; 47 | final int height; 48 | final int startX; 49 | final int startY; 50 | final ui.Color fillColor; 51 | final SendPort sendPort; 52 | 53 | _FillIsolateParams({ 54 | required this.imageByteData, 55 | required this.width, 56 | required this.height, 57 | required this.startX, 58 | required this.startY, 59 | required this.fillColor, 60 | required this.sendPort, 61 | }); 62 | } 63 | 64 | void _fillIsolate(_FillIsolateParams params) { 65 | final exitCode = FFIBridge().floodFill( 66 | params.imageByteData.buffer.asUint32List(), 67 | params.width, 68 | params.height, 69 | params.startX, 70 | params.startY, 71 | params.fillColor.toABGR(), 72 | ); 73 | 74 | final result = exitCode == 0 ? params.imageByteData : null; 75 | 76 | params.sendPort.send(result); 77 | } 78 | -------------------------------------------------------------------------------- /lib/home/home_page.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:mooltik/home/ui/bin/bin_button.dart'; 5 | import 'package:mooltik/home/ui/discord_sliver.dart'; 6 | import 'package:mooltik/home/ui/help/help_button.dart'; 7 | import 'package:path_provider/path_provider.dart'; 8 | import 'package:provider/provider.dart'; 9 | 10 | import 'ui/logo.dart'; 11 | import 'ui/project_list.dart'; 12 | import 'data/gallery_model.dart'; 13 | 14 | class HomePage extends StatefulWidget { 15 | const HomePage({Key? key}) : super(key: key); 16 | 17 | @override 18 | _HomePageState createState() => _HomePageState(); 19 | } 20 | 21 | class _HomePageState extends State { 22 | final gallery = GalleryModel(); 23 | final controller = ScrollController(); 24 | 25 | @override 26 | void initState() { 27 | super.initState(); 28 | _initProjectsManager(); 29 | } 30 | 31 | @override 32 | void dispose() { 33 | controller.dispose(); 34 | super.dispose(); 35 | } 36 | 37 | Future _initProjectsManager() async { 38 | final Directory dir = await getApplicationDocumentsDirectory(); 39 | await gallery.init(dir); 40 | } 41 | 42 | @override 43 | Widget build(BuildContext context) { 44 | return MultiProvider( 45 | providers: [ 46 | ChangeNotifierProvider.value(value: gallery), 47 | ChangeNotifierProvider.value(value: controller), 48 | ], 49 | child: Scaffold( 50 | backgroundColor: Theme.of(context).colorScheme.background, 51 | body: CustomScrollView( 52 | controller: controller, 53 | slivers: [ 54 | SliverAppBar( 55 | leading: Logo(), 56 | title: Text('Mooltik'), 57 | titleSpacing: 4, 58 | centerTitle: false, 59 | actions: [HelpButton(), BinButton()], 60 | backgroundColor: Theme.of(context).colorScheme.surface, 61 | floating: true, 62 | ), 63 | DiscordSliver(), 64 | SliverSafeArea( 65 | top: false, 66 | sliver: ProjectList(), 67 | ), 68 | ], 69 | ), 70 | ), 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/editing/ui/timeline/actionbar/time_label.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:mooltik/editing/data/timeline/timeline_model.dart'; 5 | import 'package:provider/provider.dart'; 6 | 7 | class TimeLabel extends StatelessWidget { 8 | const TimeLabel({Key? key}) : super(key: key); 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | final style = TextStyle( 13 | color: Theme.of(context).colorScheme.onSurface, 14 | fontSize: 12, 15 | fontFeatures: [FontFeature.tabularFigures()], 16 | ); 17 | 18 | return Padding( 19 | padding: const EdgeInsets.symmetric(horizontal: 12.0), 20 | child: Row( 21 | children: [ 22 | _PlayheadTimeLabel(style: style), 23 | _TotalTimeLabel(style: style), 24 | ], 25 | ), 26 | ); 27 | } 28 | } 29 | 30 | class _PlayheadTimeLabel extends StatelessWidget { 31 | const _PlayheadTimeLabel({ 32 | Key? key, 33 | required this.style, 34 | }) : super(key: key); 35 | 36 | final TextStyle style; 37 | 38 | @override 39 | Widget build(BuildContext context) { 40 | return Text( 41 | durationToLabel(context.select( 42 | (timeline) => timeline.playheadPosition, 43 | )), 44 | style: style, 45 | ); 46 | } 47 | } 48 | 49 | class _TotalTimeLabel extends StatelessWidget { 50 | const _TotalTimeLabel({ 51 | Key? key, 52 | required this.style, 53 | }) : super(key: key); 54 | 55 | final TextStyle style; 56 | 57 | @override 58 | Widget build(BuildContext context) { 59 | final totalLabel = durationToLabel(context.select( 60 | (timeline) => timeline.totalDuration, 61 | )); 62 | 63 | return Opacity( 64 | opacity: 0.5, 65 | child: Text( 66 | ' / $totalLabel', 67 | style: style, 68 | ), 69 | ); 70 | } 71 | } 72 | 73 | String durationToLabel(Duration duration) { 74 | String twoDigits(int n) => n.toString().padLeft(2, "0"); 75 | String min = twoDigits(duration.inMinutes); 76 | String sec = twoDigits(duration.inSeconds.remainder(60)); 77 | String secFr = twoDigits(duration.inMilliseconds.remainder(1000) ~/ 10); 78 | return '$min:$sec.$secFr'; 79 | } 80 | -------------------------------------------------------------------------------- /test/project_data/c_v0_9.json: -------------------------------------------------------------------------------- 1 | { 2 | "width": 1280.0, 3 | "height": 720.0, 4 | "scenes": [ 5 | { 6 | "frames": [ 7 | { 8 | "file_name": "frame1620873684152.png", 9 | "duration": "0:00:01.000000" 10 | }, 11 | { 12 | "file_name": "frame1620873707886.png", 13 | "duration": "0:00:01.000000" 14 | }, 15 | { 16 | "file_name": "frame1620873713128.png", 17 | "duration": "0:00:01.000000" 18 | } 19 | ], 20 | "duration": "0:00:03.000000", 21 | "play_mode": 0 22 | }, 23 | { 24 | "frames": [ 25 | { 26 | "file_name": "frame1620873732599.png", 27 | "duration": "0:00:01.000000" 28 | }, 29 | { 30 | "file_name": "frame1620873748372.png", 31 | "duration": "0:00:01.000000" 32 | }, 33 | { 34 | "file_name": "frame1620873757853.png", 35 | "duration": "0:00:01.000000" 36 | } 37 | ], 38 | "duration": "0:00:04.040000", 39 | "play_mode": 0 40 | }, 41 | { 42 | "frames": [ 43 | { 44 | "file_name": "frame1620873765416.png", 45 | "duration": "0:00:01.000000" 46 | }, 47 | { 48 | "file_name": "frame1620873773737.png", 49 | "duration": "0:00:01.000000" 50 | } 51 | ], 52 | "duration": "0:00:08.260000", 53 | "play_mode": 1 54 | }, 55 | { 56 | "frames": [ 57 | { 58 | "file_name": "frame1620873792351.png", 59 | "duration": "0:00:00.080000" 60 | }, 61 | { 62 | "file_name": "frame1620873800056.png", 63 | "duration": "0:00:00.080000" 64 | } 65 | ], 66 | "duration": "0:00:05.000000", 67 | "play_mode": 1 68 | } 69 | ], 70 | "sounds": [] 71 | } -------------------------------------------------------------------------------- /lib/drawing/ui/drawing_menu.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | import 'package:mooltik/common/ui/menu_tile.dart'; 4 | import 'package:mooltik/drawing/data/drawing_page_options_model.dart'; 5 | import 'package:provider/provider.dart'; 6 | import 'package:mooltik/drawing/data/onion_model.dart'; 7 | 8 | class DrawingMenu extends StatelessWidget { 9 | const DrawingMenu({ 10 | Key? key, 11 | this.width = 320, 12 | this.onDone, 13 | }) : super(key: key); 14 | 15 | final double width; 16 | final VoidCallback? onDone; 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | return SizedBox( 21 | width: width, 22 | child: ListView( 23 | shrinkWrap: true, 24 | physics: ScrollPhysics(), 25 | children: [ 26 | _buildOnionToggle(context), 27 | _buildFrameReelToggle(context), 28 | _buildFingerDrawToggle(context), 29 | ], 30 | ), 31 | ); 32 | } 33 | 34 | MenuTile _buildOnionToggle(BuildContext context) { 35 | final onion = context.watch(); 36 | 37 | return MenuTile( 38 | icon: FontAwesomeIcons.lightbulb, 39 | title: 'Onion skinning', 40 | trailing: Switch( 41 | value: onion.enabled, 42 | onChanged: (_) => onion.toggle(), 43 | ), 44 | onTap: () => onion.toggle(), 45 | ); 46 | } 47 | 48 | MenuTile _buildFrameReelToggle(BuildContext context) { 49 | final options = context.watch(); 50 | 51 | return MenuTile( 52 | icon: FontAwesomeIcons.film, 53 | title: 'Frame reel', 54 | trailing: Switch( 55 | value: options.showFrameReel, 56 | onChanged: (_) => options.toggleFrameReelVisibility(), 57 | ), 58 | onTap: () => options.toggleFrameReelVisibility(), 59 | ); 60 | } 61 | 62 | MenuTile _buildFingerDrawToggle(BuildContext context) { 63 | final easel = context.watch(); 64 | 65 | return MenuTile( 66 | icon: FontAwesomeIcons.handPointUp, 67 | title: 'Draw with finger', 68 | trailing: Switch( 69 | value: easel.allowDrawingWithFinger, 70 | onChanged: (_) => easel.toggleDrawingWithFinger(), 71 | ), 72 | onTap: () => easel.toggleDrawingWithFinger(), 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/common/ui/popup_entry.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_portal/flutter_portal.dart'; 3 | 4 | const _popupAnimationDuration = Duration(milliseconds: 150); 5 | 6 | class PopupEntry extends StatefulWidget { 7 | const PopupEntry({ 8 | Key? key, 9 | required this.visible, 10 | required this.popup, 11 | required this.child, 12 | this.popupAnchor = Alignment.topCenter, 13 | this.childAnchor = const Alignment(0, 1.2), 14 | this.onTapOutside, 15 | this.onDragOutside, 16 | }) : super(key: key); 17 | 18 | final bool visible; 19 | final Widget popup; 20 | final Widget child; 21 | final Alignment popupAnchor; 22 | final Alignment childAnchor; 23 | final VoidCallback? onTapOutside; 24 | final VoidCallback? onDragOutside; 25 | 26 | @override 27 | _PopupEntryState createState() => _PopupEntryState(); 28 | } 29 | 30 | class _PopupEntryState extends State { 31 | Widget? _popup; 32 | 33 | @override 34 | void didUpdateWidget(covariant PopupEntry oldWidget) { 35 | super.didUpdateWidget(oldWidget); 36 | 37 | // Don't update popup configuration when popup is fading out. 38 | if (widget.visible) { 39 | _popup = widget.popup; 40 | } 41 | } 42 | 43 | @override 44 | Widget build(BuildContext context) { 45 | return PortalEntry( 46 | visible: widget.visible, 47 | portal: Listener( 48 | behavior: HitTestBehavior.translucent, 49 | onPointerUp: (_) { 50 | widget.onTapOutside?.call(); 51 | }, 52 | onPointerMove: (_) { 53 | widget.onDragOutside?.call(); 54 | }, 55 | ), 56 | child: PortalEntry( 57 | visible: widget.visible, 58 | closeDuration: _popupAnimationDuration, 59 | portal: TweenAnimationBuilder( 60 | duration: _popupAnimationDuration, 61 | tween: Tween(begin: 0, end: widget.visible ? 1 : 0), 62 | builder: (context, progress, child) => Opacity( 63 | opacity: progress, 64 | child: child, 65 | ), 66 | child: _popup, 67 | ), 68 | portalAnchor: widget.popupAnchor, 69 | child: IgnorePointer( 70 | ignoring: widget.visible, 71 | child: widget.child, 72 | ), 73 | childAnchor: widget.childAnchor, 74 | ), 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /lib/drawing/ui/brush_tip_button.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:mooltik/drawing/data/toolbox/tools/brush.dart'; 5 | 6 | class BrushTipButton extends StatelessWidget { 7 | const BrushTipButton({ 8 | Key? key, 9 | this.size = 52, 10 | required this.canvasSize, 11 | required this.brushTip, 12 | this.selected = false, 13 | this.onTap, 14 | }) : super(key: key); 15 | 16 | final double size; 17 | 18 | final Size canvasSize; 19 | final BrushTip brushTip; 20 | 21 | final bool selected; 22 | final VoidCallback? onTap; 23 | 24 | @override 25 | Widget build(BuildContext context) { 26 | final borderRadius = BorderRadius.circular(8); 27 | 28 | return InkWell( 29 | borderRadius: borderRadius, 30 | splashColor: Colors.transparent, 31 | onTap: onTap, 32 | child: Container( 33 | width: size, 34 | height: size, 35 | foregroundDecoration: selected 36 | ? BoxDecoration( 37 | border: Border.all( 38 | color: Theme.of(context).colorScheme.primary, 39 | width: 3, 40 | ), 41 | borderRadius: borderRadius, 42 | ) 43 | : null, 44 | decoration: BoxDecoration( 45 | color: Colors.black.withOpacity(0.25), 46 | borderRadius: borderRadius, 47 | ), 48 | clipBehavior: Clip.antiAlias, 49 | child: FittedBox( 50 | alignment: Alignment.center, 51 | fit: BoxFit.contain, 52 | child: CustomPaint( 53 | size: canvasSize, 54 | painter: BrushTipPainter(brushTip), 55 | ), 56 | ), 57 | ), 58 | ); 59 | } 60 | } 61 | 62 | class BrushTipPainter extends CustomPainter { 63 | BrushTipPainter(this.brushTip); 64 | 65 | final BrushTip brushTip; 66 | 67 | @override 68 | void paint(Canvas canvas, Size size) { 69 | final center = Offset(size.width / 2, size.height / 2); 70 | final paint = brushTip.paint 71 | ..color = Colors.white.withOpacity(brushTip.opacity); 72 | 73 | canvas.drawPoints( 74 | PointMode.points, 75 | [center], 76 | paint, 77 | ); 78 | } 79 | 80 | @override 81 | bool shouldRepaint(BrushTipPainter oldDelegate) => false; 82 | 83 | @override 84 | bool shouldRebuildSemantics(BrushTipPainter oldDelegate) => false; 85 | } 86 | -------------------------------------------------------------------------------- /lib/editing/ui/timeline/view/timeline_painter.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:mooltik/editing/data/timeline/timeline_view_model.dart'; 5 | 6 | class TimelinePainter extends CustomPainter { 7 | TimelinePainter(this.timelineView); 8 | 9 | final TimelineViewModel timelineView; 10 | 11 | @override 12 | void paint(Canvas canvas, Size size) { 13 | final canvasArea = Rect.fromLTWH(0, 0, size.width, size.height); 14 | final rows = timelineView.getSliverRows(); 15 | final selectedSliverId = timelineView.selectedSliverId; 16 | 17 | // Slivers. 18 | for (var row in rows) { 19 | for (var sliver in row) { 20 | if (sliver.area.overlaps(canvasArea)) { 21 | sliver.paint(canvas); 22 | } 23 | } 24 | } 25 | 26 | // Selection. 27 | if (selectedSliverId != null) { 28 | final selectedSliver = 29 | rows[selectedSliverId.rowIndex][selectedSliverId.colIndex]; 30 | 31 | if (selectedSliver.area.overlaps(canvasArea)) { 32 | _paintSelection(canvas, selectedSliver.rrect); 33 | } 34 | } 35 | 36 | // Curtains. 37 | if (timelineView.isEditingScene) { 38 | final sceneStartX = timelineView.xFromTime(timelineView.sceneStart); 39 | final sceneEndX = timelineView.xFromTime(timelineView.sceneEnd); 40 | 41 | _paintCurtains(canvas, size, sceneStartX, sceneEndX); 42 | } 43 | } 44 | 45 | @override 46 | bool shouldRepaint(TimelinePainter oldDelegate) => true; 47 | 48 | @override 49 | bool shouldRebuildSemantics(TimelinePainter oldDelegate) => false; 50 | } 51 | 52 | void _paintSelection(Canvas canvas, RRect rect) { 53 | canvas.drawRRect( 54 | rect, 55 | Paint() 56 | ..color = Colors.black45 57 | ..style = PaintingStyle.fill, 58 | ); 59 | 60 | canvas.drawRRect( 61 | rect.deflate(2), 62 | Paint() 63 | ..color = Colors.amber 64 | ..style = PaintingStyle.stroke 65 | ..strokeWidth = 4, 66 | ); 67 | } 68 | 69 | void _paintCurtains( 70 | Canvas canvas, 71 | Size size, 72 | double sceneStartX, 73 | double sceneEndX, 74 | ) { 75 | final curtainPaint = Paint()..color = Colors.black45; 76 | canvas.drawRect( 77 | Rect.fromLTRB(0, 0, sceneStartX, size.height), 78 | curtainPaint, 79 | ); 80 | canvas.drawRect( 81 | Rect.fromLTRB(sceneEndX, 0, size.width, size.height), 82 | curtainPaint, 83 | ); 84 | } 85 | --------------------------------------------------------------------------------