From b1920dbe1cafb0257ec04cb8e1298ca7af3fbe41 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 14 May 2023 12:50:08 +0200 Subject: [PATCH] lab: transform --- android/app/src/main/AndroidManifest.xml | 9 + .../deckers/thibault/aves/MainActivity.kt | 13 + lib/app_mode.dart | 1 + lib/l10n/app_en.arb | 11 + lib/main_common.dart | 4 +- lib/main_play_test_editor.dart | 17 + lib/model/app/dependencies.dart | 6 + lib/model/settings/enums/widget_shape.dart | 2 +- .../state.dart => model/view_state.dart} | 14 + lib/theme/icons.dart | 324 ++++---- lib/utils/math_utils.dart | 41 + lib/view/src/editor/enums.dart | 57 ++ lib/view/view.dart | 1 + lib/widgets/aves_app.dart | 5 +- lib/widgets/collection/collection_page.dart | 1 + lib/widgets/collection/grid/tile.dart | 1 + .../common/basic/multi_cross_fader.dart | 4 + lib/widgets/common/extensions/geometry.dart | 7 + .../common/fx/checkered_decoration.dart | 2 +- .../common/fx/dashed_path_painter.dart | 142 ++++ lib/widgets/common/fx/transition_image.dart | 3 +- lib/widgets/common/grid/item_tracker.dart | 2 +- lib/widgets/editor/control_panel.dart | 137 ++++ lib/widgets/editor/entry_editor_page.dart | 136 ++++ lib/widgets/editor/image.dart | 224 ++++++ .../editor/transform/control_panel.dart | 196 +++++ lib/widgets/editor/transform/controller.dart | 88 +++ lib/widgets/editor/transform/crop_region.dart | 48 ++ lib/widgets/editor/transform/cropper.dart | 734 ++++++++++++++++++ lib/widgets/editor/transform/handles.dart | 120 +++ lib/widgets/editor/transform/painter.dart | 131 ++++ .../editor/transform/transformation.dart | 87 +++ .../filter_grids/common/filter_tile.dart | 1 + lib/widgets/home_page.dart | 226 +++--- lib/widgets/home_widget.dart | 2 +- lib/widgets/intent.dart | 24 + lib/widgets/viewer/overlay/minimap.dart | 95 ++- .../viewer/overlay/wallpaper_buttons.dart | 2 +- lib/widgets/viewer/visual/conductor.dart | 4 +- .../viewer/visual/entry_page_view.dart | 15 +- lib/widgets/viewer/visual/raster.dart | 2 +- lib/widgets/viewer/visual/vector.dart | 2 +- lib/widgets/viewer/visual/video/cover.dart | 2 +- .../visual/video/subtitle/subtitle.dart | 2 +- .../aves_magnifier/lib/aves_magnifier.dart | 2 +- .../lib/src/controller/controller.dart | 8 +- .../src/controller/controller_delegate.dart | 90 +-- .../lib/src/controller/range.dart | 15 + plugins/aves_magnifier/lib/src/core/core.dart | 168 +++- .../lib/src/core/gesture_detector.dart | 2 +- .../src/core/scale_gesture_recognizer.dart | 8 +- plugins/aves_magnifier/lib/src/magnifier.dart | 107 --- .../lib/src/pan/edge_hit_detector.dart | 39 +- .../lib/src/scale/scale_boundaries.dart | 136 +++- .../lib/src/scale/scale_level.dart | 4 +- .../aves_magnifier/lib/src/scale/state.dart | 3 - plugins/aves_magnifier/pubspec.lock | 7 + plugins/aves_magnifier/pubspec.yaml | 2 + plugins/aves_model/lib/aves_model.dart | 1 + plugins/aves_model/lib/src/editor/enums.dart | 66 ++ plugins/aves_utils/lib/aves_utils.dart | 1 + .../aves_utils/lib/src/change_notifier.dart | 2 +- plugins/aves_utils/lib/src/vector_utils.dart | 15 + plugins/aves_utils/pubspec.lock | 2 +- plugins/aves_utils/pubspec.yaml | 1 + pubspec.lock | 2 +- pubspec.yaml | 1 + test/model/view_state_test.dart | 90 +++ test/utils/math_utils_test.dart | 9 + untranslated.json | 297 +++++++ 70 files changed, 3409 insertions(+), 614 deletions(-) create mode 100644 lib/main_play_test_editor.dart rename lib/{widgets/viewer/visual/state.dart => model/view_state.dart} (66%) create mode 100644 lib/view/src/editor/enums.dart create mode 100644 lib/widgets/common/extensions/geometry.dart create mode 100644 lib/widgets/common/fx/dashed_path_painter.dart create mode 100644 lib/widgets/editor/control_panel.dart create mode 100644 lib/widgets/editor/entry_editor_page.dart create mode 100644 lib/widgets/editor/image.dart create mode 100644 lib/widgets/editor/transform/control_panel.dart create mode 100644 lib/widgets/editor/transform/controller.dart create mode 100644 lib/widgets/editor/transform/crop_region.dart create mode 100644 lib/widgets/editor/transform/cropper.dart create mode 100644 lib/widgets/editor/transform/handles.dart create mode 100644 lib/widgets/editor/transform/painter.dart create mode 100644 lib/widgets/editor/transform/transformation.dart create mode 100644 lib/widgets/intent.dart create mode 100644 plugins/aves_magnifier/lib/src/controller/range.dart delete mode 100644 plugins/aves_magnifier/lib/src/magnifier.dart create mode 100644 plugins/aves_model/lib/src/editor/enums.dart create mode 100644 plugins/aves_utils/lib/src/vector_utils.dart create mode 100644 test/model/view_state_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index eec888a79..014a84516 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -145,6 +145,15 @@ This change eventually prevents building the app with Flutter v3.7.11. + diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 2e46d64d2..abab47814 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -277,6 +277,18 @@ open class MainActivity : FlutterFragmentActivity() { } } + Intent.ACTION_EDIT -> { + (intent.data ?: intent.getParcelableExtraCompat(Intent.EXTRA_STREAM))?.let { uri -> + // MIME type is optional + val type = intent.type ?: intent.resolveType(this) + return hashMapOf( + INTENT_DATA_KEY_ACTION to INTENT_ACTION_EDIT, + INTENT_DATA_KEY_MIME_TYPE to type, + INTENT_DATA_KEY_URI to uri.toString(), + ) + } + } + Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> { return hashMapOf( INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK_ITEMS, @@ -433,6 +445,7 @@ open class MainActivity : FlutterFragmentActivity() { const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6 const val PICK_COLLECTION_FILTERS_REQUEST = 7 + const val INTENT_ACTION_EDIT = "edit" const val INTENT_ACTION_PICK_ITEMS = "pick_items" const val INTENT_ACTION_PICK_COLLECTION_FILTERS = "pick_collection_filters" const val INTENT_ACTION_SCREEN_SAVER = "screen_saver" diff --git a/lib/app_mode.dart b/lib/app_mode.dart index 99dd00ef4..8135170b4 100644 --- a/lib/app_mode.dart +++ b/lib/app_mode.dart @@ -9,6 +9,7 @@ enum AppMode { setWallpaper, slideshow, view, + edit, } extension ExtraAppMode on AppMode { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b8c620c98..a83d54d89 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -50,7 +50,9 @@ "showButtonLabel": "SHOW", "hideButtonLabel": "HIDE", "continueButtonLabel": "CONTINUE", + "saveCopyButtonLabel": "SAVE COPY", + "applyTooltip": "Apply", "cancelTooltip": "Cancel", "changeTooltip": "Change", "clearTooltip": "Clear", @@ -141,6 +143,15 @@ "entryInfoActionExportMetadata": "Export metadata", "entryInfoActionRemoveLocation": "Remove location", + "editorActionTransform": "Transform", + + "editorTransformCrop": "Crop", + "editorTransformRotate": "Rotate", + + "cropAspectRatioFree": "Free", + "cropAspectRatioOriginal": "Original", + "cropAspectRatioSquare": "Square", + "filterAspectRatioLandscapeLabel": "Landscape", "filterAspectRatioPortraitLabel": "Portrait", "filterBinLabel": "Recycle bin", diff --git a/lib/main_common.dart b/lib/main_common.dart index a02f671be..f525bab0d 100644 --- a/lib/main_common.dart +++ b/lib/main_common.dart @@ -5,7 +5,7 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/aves_app.dart'; import 'package:flutter/material.dart'; -void mainCommon(AppFlavor flavor) { +void mainCommon(AppFlavor flavor, {Map? debugIntentData}) { // HttpClient.enableTimelineLogging = true; // enable network traffic logging // debugPrintGestureArenaDiagnostics = true; @@ -35,5 +35,5 @@ void mainCommon(AppFlavor flavor) { // ErrorWidget.builder = (details) => ErrorWidget(details.exception); // cf https://docs.flutter.dev/testing/errors - runApp(AvesApp(flavor: flavor)); + runApp(AvesApp(flavor: flavor, debugIntentData: debugIntentData)); } diff --git a/lib/main_play_test_editor.dart b/lib/main_play_test_editor.dart new file mode 100644 index 000000000..95e5f4d6c --- /dev/null +++ b/lib/main_play_test_editor.dart @@ -0,0 +1,17 @@ +import 'package:aves/app_flavor.dart'; +import 'package:aves/main_common.dart'; +import 'package:aves/widgets/intent.dart'; + +// https://developer.android.com/studio/command-line/adb.html#IntentSpec +// adb shell am start -n deckers.thibault.aves.debug/deckers.thibault.aves.MainActivity -a android.intent.action.EDIT -d content://media/external/images/media/183128 -t image/* + +@pragma('vm:entry-point') +void main() => mainCommon( + AppFlavor.play, + debugIntentData: { + IntentDataKeys.action: IntentActions.edit, + IntentDataKeys.mimeType: 'image/*', + IntentDataKeys.uri: 'content://media/external/images/media/183128', + // IntentDataKeys.uri: 'content://media/external/images/media/183534', + }, + ); diff --git a/lib/model/app/dependencies.dart b/lib/model/app/dependencies.dart index 48fdf5e41..794029ea8 100644 --- a/lib/model/app/dependencies.dart +++ b/lib/model/app/dependencies.dart @@ -6,6 +6,7 @@ class Dependencies { static const String bsd3 = 'BSD 3-Clause “Revised” License'; static const String eclipse1 = 'Eclipse Public License 1.0'; static const String mit = 'MIT License'; + static const String zlib = 'zlib License'; static const List androidDependencies = [ Dependency( @@ -369,6 +370,11 @@ class Dependencies { license: bsd2, sourceUrl: 'https://github.com/google/tuple.dart', ), + Dependency( + name: 'Vector Math', + license: '$zlib, $bsd3', + sourceUrl: 'https://github.com/google/vector_math.dart', + ), Dependency( name: 'XML', license: mit, diff --git a/lib/model/settings/enums/widget_shape.dart b/lib/model/settings/enums/widget_shape.dart index adb2d6730..28a64f3ab 100644 --- a/lib/model/settings/enums/widget_shape.dart +++ b/lib/model/settings/enums/widget_shape.dart @@ -4,7 +4,7 @@ import 'package:flutter/painting.dart'; extension ExtraWidgetShape on WidgetShape { Path path(Size widgetSize, double devicePixelRatio) { - final rect = Rect.fromLTWH(0, 0, widgetSize.width, widgetSize.height); + final rect = Offset.zero & widgetSize; switch (this) { case WidgetShape.rrect: return Path()..addRRect(BorderRadius.circular(24 * devicePixelRatio).toRRect(rect)); diff --git a/lib/widgets/viewer/visual/state.dart b/lib/model/view_state.dart similarity index 66% rename from lib/widgets/viewer/visual/state.dart rename to lib/model/view_state.dart index a523d7eb5..6e8c573dc 100644 --- a/lib/widgets/viewer/visual/state.dart +++ b/lib/model/view_state.dart @@ -37,4 +37,18 @@ class ViewState extends Equatable { contentSize: contentSize ?? this.contentSize, ); } + + Matrix4 get matrix { + final _viewportSize = viewportSize ?? Size.zero; + final _contentSize = contentSize ?? Size.zero; + final _scale = scale ?? 1.0; + + final scaledContentSize = _contentSize * _scale; + final viewOffset = _viewportSize.center(Offset.zero) - scaledContentSize.center(Offset.zero); + + return Matrix4.identity() + ..translate(position.dx, position.dy) + ..translate(viewOffset.dx, viewOffset.dy) + ..scale(_scale, _scale, 1); + } } diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 4c683e908..8f46027fa 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -2,172 +2,180 @@ import 'package:flutter/material.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; class AIcons { - static const IconData allCollection = Icons.collections_outlined; - static const IconData image = Icons.photo_outlined; - static const IconData video = Icons.movie_outlined; - static const IconData vector = Icons.code_outlined; + static const allCollection = Icons.collections_outlined; + static const image = Icons.photo_outlined; + static const video = Icons.movie_outlined; + static const vector = Icons.code_outlined; - static const IconData accessibility = Icons.accessibility_new_outlined; - static const IconData android = Icons.android; - static const IconData app = Icons.apps_outlined; - static const IconData apply = Icons.done_outlined; - static const IconData aspectRatio = Icons.aspect_ratio_outlined; - static const IconData bin = Icons.delete_outlined; - static const IconData broken = Icons.broken_image_outlined; - static const IconData brightnessMin = Icons.brightness_low_outlined; - static const IconData brightnessMax = Icons.brightness_high_outlined; - static const IconData checked = Icons.done_outlined; - static const IconData count = MdiIcons.counter; - static const IconData counter = Icons.plus_one_outlined; - static const IconData date = Icons.calendar_today_outlined; - static const IconData dateByDay = Icons.today_outlined; - static const IconData dateByMonth = Icons.calendar_month_outlined; - static const IconData dateRecent = Icons.today_outlined; - static const IconData dateUndated = Icons.event_busy_outlined; - static const IconData description = Icons.description_outlined; - static const IconData descriptionUntitled = Icons.comments_disabled_outlined; - static const IconData disc = Icons.fiber_manual_record; - static const IconData display = Icons.light_mode_outlined; - static const IconData error = Icons.error_outline; - static const IconData folder = Icons.folder_outlined; - static const IconData grid = Icons.grid_on_outlined; - static const IconData home = Icons.home_outlined; - static const IconData important = Icons.label_important_outline; - static const IconData language = Icons.translate_outlined; - static const IconData location = Icons.place_outlined; - static const IconData locationUnlocated = Icons.location_off_outlined; - static const IconData country = Icons.flag_outlined; - static const IconData state = Icons.flag_outlined; - static const IconData place = Icons.place_outlined; - static const IconData mainStorage = Icons.smartphone_outlined; - static const IconData mimeType = Icons.code_outlined; - static const IconData opacity = Icons.opacity; - static const IconData privacy = MdiIcons.shieldAccountOutline; - static const IconData rating = Icons.star_border_outlined; - static const IconData ratingFull = Icons.star; - static const IconData ratingRejected = MdiIcons.starMinusOutline; - static const IconData ratingUnrated = MdiIcons.starOffOutline; - static const IconData raw = Icons.raw_on_outlined; - static const IconData shooting = Icons.camera_outlined; - static const IconData removableStorage = Icons.sd_storage_outlined; - static const IconData sensorControlEnabled = Icons.explore_outlined; - static const IconData sensorControlDisabled = Icons.explore_off_outlined; - static const IconData settings = Icons.settings_outlined; - static const IconData size = Icons.data_usage_outlined; - static const IconData text = Icons.format_quote_outlined; - static const IconData tag = Icons.local_offer_outlined; - static const IconData tagUntagged = MdiIcons.tagOffOutline; - static const IconData volumeMin = Icons.volume_mute_outlined; - static const IconData volumeMax = Icons.volume_up_outlined; + static const accessibility = Icons.accessibility_new_outlined; + static const android = Icons.android; + static const app = Icons.apps_outlined; + static const apply = Icons.done_outlined; + static const aspectRatio = Icons.aspect_ratio_outlined; + static const bin = Icons.delete_outlined; + static const broken = Icons.broken_image_outlined; + static const brightnessMin = Icons.brightness_low_outlined; + static const brightnessMax = Icons.brightness_high_outlined; + static const checked = Icons.done_outlined; + static const count = MdiIcons.counter; + static const counter = Icons.plus_one_outlined; + static const date = Icons.calendar_today_outlined; + static const dateByDay = Icons.today_outlined; + static const dateByMonth = Icons.calendar_month_outlined; + static const dateRecent = Icons.today_outlined; + static const dateUndated = Icons.event_busy_outlined; + static const description = Icons.description_outlined; + static const descriptionUntitled = Icons.comments_disabled_outlined; + static const disc = Icons.fiber_manual_record; + static const display = Icons.light_mode_outlined; + static const error = Icons.error_outline; + static const folder = Icons.folder_outlined; + static const grid = Icons.grid_on_outlined; + static const home = Icons.home_outlined; + static const important = Icons.label_important_outline; + static const language = Icons.translate_outlined; + static const location = Icons.place_outlined; + static const locationUnlocated = Icons.location_off_outlined; + static const country = Icons.flag_outlined; + static const state = Icons.flag_outlined; + static const place = Icons.place_outlined; + static const mainStorage = Icons.smartphone_outlined; + static const mimeType = Icons.code_outlined; + static const opacity = Icons.opacity; + static const privacy = MdiIcons.shieldAccountOutline; + static const rating = Icons.star_border_outlined; + static const ratingFull = Icons.star; + static const ratingRejected = MdiIcons.starMinusOutline; + static const ratingUnrated = MdiIcons.starOffOutline; + static const raw = Icons.raw_on_outlined; + static const shooting = Icons.camera_outlined; + static const removableStorage = Icons.sd_storage_outlined; + static const sensorControlEnabled = Icons.explore_outlined; + static const sensorControlDisabled = Icons.explore_off_outlined; + static const settings = Icons.settings_outlined; + static const size = Icons.data_usage_outlined; + static const text = Icons.format_quote_outlined; + static const tag = Icons.local_offer_outlined; + static const tagUntagged = MdiIcons.tagOffOutline; + static const volumeMin = Icons.volume_mute_outlined; + static const volumeMax = Icons.volume_up_outlined; // view - static const IconData group = Icons.group_work_outlined; - static const IconData layout = Icons.grid_view_outlined; - static const IconData layoutMosaic = Icons.view_comfy_outlined; - static const IconData layoutGrid = Icons.view_compact_outlined; - static const IconData layoutList = Icons.list_outlined; - static const IconData sort = Icons.sort_outlined; - static const IconData sortOrder = Icons.swap_vert_outlined; - static const IconData thumbnailLarge = Icons.photo_size_select_large_outlined; - static const IconData thumbnailSmall = Icons.photo_size_select_small_outlined; + static const group = Icons.group_work_outlined; + static const layout = Icons.grid_view_outlined; + static const layoutMosaic = Icons.view_comfy_outlined; + static const layoutGrid = Icons.view_compact_outlined; + static const layoutList = Icons.list_outlined; + static const sort = Icons.sort_outlined; + static const sortOrder = Icons.swap_vert_outlined; + static const thumbnailLarge = Icons.photo_size_select_large_outlined; + static const thumbnailSmall = Icons.photo_size_select_small_outlined; // actions - static const IconData add = Icons.add_circle_outline; - static const IconData addShortcut = Icons.add_to_home_screen_outlined; - static const IconData cancel = Icons.cancel_outlined; - static const IconData captureFrame = Icons.screenshot_outlined; - static const IconData clear = Icons.clear_outlined; - static const IconData clipboard = Icons.content_copy_outlined; - static const IconData convert = Icons.transform_outlined; - static const IconData convertToStillImage = MdiIcons.movieRemoveOutline; - static const IconData copy = Icons.file_copy_outlined; - static const IconData debug = Icons.whatshot_outlined; - static const IconData delete = Icons.delete_outlined; - static const IconData edit = Icons.edit_outlined; - static const IconData emptyBin = Icons.delete_sweep_outlined; - static const IconData export = Icons.open_with_outlined; - static const IconData fileExport = MdiIcons.fileExportOutline; - static const IconData fileImport = MdiIcons.fileImportOutline; - static const IconData flip = Icons.flip_outlined; - static const IconData favourite = Icons.favorite_border; - static const IconData favouriteActive = Icons.favorite; - static const IconData filter = MdiIcons.filterOutline; - static const IconData filterOff = MdiIcons.filterOffOutline; - static const IconData geoBounds = Icons.public_outlined; - static const IconData goUp = Icons.arrow_upward_outlined; - static const IconData hide = Icons.visibility_off_outlined; - static const IconData info = Icons.info_outlined; - static const IconData layers = Icons.layers_outlined; - static const IconData map = Icons.map_outlined; - static const IconData move = MdiIcons.fileMoveOutline; - static const IconData mute = Icons.volume_off_outlined; - static const IconData unmute = Icons.volume_up_outlined; - static const IconData name = Icons.abc_outlined; - static const IconData newTier = Icons.fiber_new_outlined; - static const IconData openOutside = Icons.open_in_new_outlined; - static const IconData openVideo = MdiIcons.moviePlayOutline; - static const IconData pin = Icons.push_pin_outlined; - static const IconData unpin = MdiIcons.pinOffOutline; - static const IconData play = Icons.play_arrow; - static const IconData pause = Icons.pause; - static const IconData print = Icons.print_outlined; - static const IconData refresh = Icons.refresh_outlined; - static const IconData replay10 = Icons.replay_10_outlined; - static const IconData reverse = Icons.invert_colors_outlined; - static const IconData skip10 = Icons.forward_10_outlined; - static const IconData reset = Icons.restart_alt_outlined; - static const IconData restore = Icons.restore_outlined; - static const IconData rotateLeft = Icons.rotate_left_outlined; - static const IconData rotateRight = Icons.rotate_right_outlined; - static const IconData rotateScreen = Icons.screen_rotation_outlined; - static const IconData search = Icons.search_outlined; - static const IconData select = Icons.select_all_outlined; - static const IconData setAs = Icons.wallpaper_outlined; - static const IconData setCover = MdiIcons.imageEditOutline; - static const IconData share = Icons.share_outlined; - static const IconData show = Icons.visibility_outlined; - static const IconData showFullscreen = MdiIcons.arrowExpand; - static const IconData slideshow = Icons.slideshow_outlined; - static const IconData speed = Icons.speed_outlined; - static const IconData stats = Icons.donut_small_outlined; - static const IconData streams = Icons.translate_outlined; - static const IconData streamVideo = Icons.movie_outlined; - static const IconData streamAudio = Icons.audiotrack_outlined; - static const IconData streamText = Icons.closed_caption_outlined; - static const IconData vaultLock = Icons.lock_outline; - static const IconData vaultAdd = Icons.enhanced_encryption_outlined; - static const IconData vaultConfigure = MdiIcons.shieldLockOutline; - static const IconData videoSettings = Icons.video_settings_outlined; - static const IconData view = Icons.grid_view_outlined; - static const IconData viewerLock = Icons.lock_outline; - static const IconData viewerUnlock = Icons.lock_open_outlined; - static const IconData zoomIn = Icons.add_outlined; - static const IconData zoomOut = Icons.remove_outlined; - static const IconData collapse = Icons.expand_less_outlined; - static const IconData expand = Icons.expand_more_outlined; - static const IconData previous = Icons.chevron_left_outlined; - static const IconData next = Icons.chevron_right_outlined; + static const add = Icons.add_circle_outline; + static const addShortcut = Icons.add_to_home_screen_outlined; + static const cancel = Icons.cancel_outlined; + static const captureFrame = Icons.screenshot_outlined; + static const clear = Icons.clear_outlined; + static const clipboard = Icons.content_copy_outlined; + static const convert = Icons.transform_outlined; + static const convertToStillImage = MdiIcons.movieRemoveOutline; + static const copy = Icons.file_copy_outlined; + static const debug = Icons.whatshot_outlined; + static const delete = Icons.delete_outlined; + static const edit = Icons.edit_outlined; + static const emptyBin = Icons.delete_sweep_outlined; + static const export = Icons.open_with_outlined; + static const fileExport = MdiIcons.fileExportOutline; + static const fileImport = MdiIcons.fileImportOutline; + static const flip = Icons.flip_outlined; + static const favourite = Icons.favorite_border; + static const favouriteActive = Icons.favorite; + static const filter = MdiIcons.filterOutline; + static const filterOff = MdiIcons.filterOffOutline; + static const geoBounds = Icons.public_outlined; + static const goUp = Icons.arrow_upward_outlined; + static const hide = Icons.visibility_off_outlined; + static const info = Icons.info_outlined; + static const layers = Icons.layers_outlined; + static const map = Icons.map_outlined; + static const move = MdiIcons.fileMoveOutline; + static const mute = Icons.volume_off_outlined; + static const unmute = Icons.volume_up_outlined; + static const name = Icons.abc_outlined; + static const newTier = Icons.fiber_new_outlined; + static const openOutside = Icons.open_in_new_outlined; + static const openVideo = MdiIcons.moviePlayOutline; + static const pin = Icons.push_pin_outlined; + static const unpin = MdiIcons.pinOffOutline; + static const play = Icons.play_arrow; + static const pause = Icons.pause; + static const print = Icons.print_outlined; + static const refresh = Icons.refresh_outlined; + static const replay10 = Icons.replay_10_outlined; + static const reverse = Icons.invert_colors_outlined; + static const skip10 = Icons.forward_10_outlined; + static const reset = Icons.restart_alt_outlined; + static const restore = Icons.restore_outlined; + static const rotateLeft = Icons.rotate_left_outlined; + static const rotateRight = Icons.rotate_right_outlined; + static const rotateScreen = Icons.screen_rotation_outlined; + static const search = Icons.search_outlined; + static const select = Icons.select_all_outlined; + static const setAs = Icons.wallpaper_outlined; + static const setCover = MdiIcons.imageEditOutline; + static const share = Icons.share_outlined; + static const show = Icons.visibility_outlined; + static const showFullscreen = MdiIcons.arrowExpand; + static const slideshow = Icons.slideshow_outlined; + static const speed = Icons.speed_outlined; + static const stats = Icons.donut_small_outlined; + static const streams = Icons.translate_outlined; + static const streamVideo = Icons.movie_outlined; + static const streamAudio = Icons.audiotrack_outlined; + static const streamText = Icons.closed_caption_outlined; + static const vaultLock = Icons.lock_outline; + static const vaultAdd = Icons.enhanced_encryption_outlined; + static const vaultConfigure = MdiIcons.shieldLockOutline; + static const videoSettings = Icons.video_settings_outlined; + static const view = Icons.grid_view_outlined; + static const viewerLock = Icons.lock_outline; + static const viewerUnlock = Icons.lock_open_outlined; + static const zoomIn = Icons.add_outlined; + static const zoomOut = Icons.remove_outlined; + static const collapse = Icons.expand_less_outlined; + static const expand = Icons.expand_more_outlined; + static const previous = Icons.chevron_left_outlined; + static const next = Icons.chevron_right_outlined; + + // editor + static const transform = Icons.crop_rotate_outlined; + static const aspectRatioFree = Icons.crop_free_outlined; + static const aspectRatioOriginal = Icons.crop_original_outlined; + static const aspectRatioSquare = Icons.crop_square_outlined; + static const aspectRatio_16_9 = Icons.crop_16_9_outlined; + static const aspectRatio_4_3 = Icons.crop_landscape_outlined; // albums - static const IconData album = Icons.photo_album_outlined; - static const IconData cameraAlbum = Icons.photo_camera_outlined; - static const IconData downloadAlbum = Icons.file_download; - static const IconData screenshotAlbum = Icons.screenshot_outlined; - static const IconData recordingAlbum = Icons.smartphone_outlined; - static const IconData locked = Icons.lock_outline; - static const IconData unlocked = Icons.lock_open_outlined; + static const album = Icons.photo_album_outlined; + static const cameraAlbum = Icons.photo_camera_outlined; + static const downloadAlbum = Icons.file_download; + static const screenshotAlbum = Icons.screenshot_outlined; + static const recordingAlbum = Icons.smartphone_outlined; + static const locked = Icons.lock_outline; + static const unlocked = Icons.lock_open_outlined; // thumbnail overlay - static const IconData animated = Icons.slideshow; - static const IconData geo = Icons.language_outlined; - static const IconData motionPhoto = Icons.motion_photos_on_outlined; - static const IconData multiPage = Icons.burst_mode_outlined; - static const IconData panorama = Icons.vrpano_outlined; - static const IconData sphericalVideo = Icons.threesixty_outlined; - static const IconData videoThumb = Icons.play_circle_outline; - static const IconData selected = Icons.check_circle_outline; - static const IconData unselected = Icons.radio_button_unchecked; + static const animated = Icons.slideshow; + static const geo = Icons.language_outlined; + static const motionPhoto = Icons.motion_photos_on_outlined; + static const multiPage = Icons.burst_mode_outlined; + static const panorama = Icons.vrpano_outlined; + static const sphericalVideo = Icons.threesixty_outlined; + static const videoThumb = Icons.play_circle_outline; + static const selected = Icons.check_circle_outline; + static const unselected = Icons.radio_button_unchecked; - static const IconData github = MdiIcons.github; - static const IconData legal = MdiIcons.scaleBalance; + static const github = MdiIcons.github; + static const legal = MdiIcons.scaleBalance; } diff --git a/lib/utils/math_utils.dart b/lib/utils/math_utils.dart index 89a0e6ed5..e882db4aa 100644 --- a/lib/utils/math_utils.dart +++ b/lib/utils/math_utils.dart @@ -1,7 +1,48 @@ import 'dart:math'; +import 'dart:ui'; + +import 'package:tuple/tuple.dart'; int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / ln2).floor()).toInt(); int smallestPowerOf2(num x) => x < 1 ? 1 : pow(2, (log(x) / ln2).ceil()).toInt(); double roundToPrecision(final double value, {required final int decimals}) => (value * pow(10, decimals)).round() / pow(10, decimals); + +// cf https://en.wikipedia.org/wiki/Intersection_(geometry)#Two_line_segments +Offset? segmentIntersection(Tuple2 s1, Tuple2 s2) { + final x1 = s1.item1.dx; + final y1 = s1.item1.dy; + final x2 = s1.item2.dx; + final y2 = s1.item2.dy; + + final x3 = s2.item1.dx; + final y3 = s2.item1.dy; + final x4 = s2.item2.dx; + final y4 = s2.item2.dy; + + final a1 = x2 - x1; + final b1 = -(x4 - x3); + final c1 = x3 - x1; + final a2 = y2 - y1; + final b2 = -(y4 - y3); + final c2 = y3 - y1; + + final denom = a1 * b2 - a2 * b1; + if (denom == 0) { + // lines are parallel + return null; + } + + final s0 = (c1 * b2 - c2 * b1) / denom; + final t0 = (a1 * c2 - a2 * c1) / denom; + + if (!(0 <= s0 && s0 <= 1 && 0 <= t0 && t0 <= 1)) { + // segments do not intersect + return null; + } + + final x0 = x1 + s0 * (x2 - x1); + final y0 = y1 + s0 * (y2 - y1); + return Offset(x0, y0); +} diff --git a/lib/view/src/editor/enums.dart b/lib/view/src/editor/enums.dart new file mode 100644 index 000000000..43b9fe95d --- /dev/null +++ b/lib/view/src/editor/enums.dart @@ -0,0 +1,57 @@ +import 'package:aves/ref/unicode.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:flutter/widgets.dart'; + +extension ExtraEditorActionView on EditorAction { + String getText(BuildContext context) { + switch (this) { + case EditorAction.transform: + return context.l10n.editorActionTransform; + } + } + + Widget getIcon() => Icon(_getIconData()); + + IconData _getIconData() { + switch (this) { + case EditorAction.transform: + return AIcons.transform; + } + } +} + +extension ExtraCropAspectRatioView on CropAspectRatio { + String getText(BuildContext context) { + switch (this) { + case CropAspectRatio.free: + return context.l10n.cropAspectRatioFree; + case CropAspectRatio.original: + return context.l10n.cropAspectRatioOriginal; + case CropAspectRatio.square: + return context.l10n.cropAspectRatioSquare; + case CropAspectRatio.ar_16_9: + return '16${UniChars.ratio}9'; + case CropAspectRatio.ar_4_3: + return '4${UniChars.ratio}3'; + } + } + + Widget getIcon() => Icon(_getIconData()); + + IconData _getIconData() { + switch (this) { + case CropAspectRatio.free: + return AIcons.aspectRatioFree; + case CropAspectRatio.original: + return AIcons.aspectRatioOriginal; + case CropAspectRatio.square: + return AIcons.aspectRatioSquare; + case CropAspectRatio.ar_16_9: + return AIcons.aspectRatio_16_9; + case CropAspectRatio.ar_4_3: + return AIcons.aspectRatio_4_3; + } + } +} diff --git a/lib/view/view.dart b/lib/view/view.dart index 76eec0d8c..fd3cfd0ff 100644 --- a/lib/view/view.dart +++ b/lib/view/view.dart @@ -6,6 +6,7 @@ export 'src/actions/map.dart'; export 'src/actions/map_cluster.dart'; export 'src/actions/share.dart'; export 'src/actions/slideshow.dart'; +export 'src/editor/enums.dart'; export 'src/metadata/date_edit_action.dart'; export 'src/metadata/date_field_source.dart'; export 'src/metadata/fields.dart'; diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index d34dfd3a4..1c3ff2854 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -54,6 +54,7 @@ import 'package:url_launcher/url_launcher.dart' as ul; class AvesApp extends StatefulWidget { final AppFlavor flavor; + final Map? debugIntentData; // temporary exclude locales not ready yet for prime time // `ckb`: add `flutter_ckb_localization` and necessary app localization delegates when ready @@ -85,6 +86,7 @@ class AvesApp extends StatefulWidget { const AvesApp({ super.key, required this.flavor, + this.debugIntentData, }); @override @@ -227,7 +229,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { AvesApp.showSystemUI(); } final home = initialized - ? _getFirstPage() + ? _getFirstPage(intentData: widget.debugIntentData) : AvesScaffold( body: snapshot.hasError ? _buildError(snapshot.error!) : const SizedBox(), ); @@ -390,6 +392,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { case AppMode.setWallpaper: case AppMode.slideshow: case AppMode.view: + case AppMode.edit: break; } case AppLifecycleState.resumed: diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 0c3a1ac0e..93bc20b49 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -208,6 +208,7 @@ class _CollectionPageState extends State { case AppMode.setWallpaper: case AppMode.slideshow: case AppMode.view: + case AppMode.edit: return null; } } diff --git a/lib/widgets/collection/grid/tile.dart b/lib/widgets/collection/grid/tile.dart index a4e132d0f..2a8cc56fc 100644 --- a/lib/widgets/collection/grid/tile.dart +++ b/lib/widgets/collection/grid/tile.dart @@ -54,6 +54,7 @@ class InteractiveTile extends StatelessWidget { case AppMode.setWallpaper: case AppMode.slideshow: case AppMode.view: + case AppMode.edit: break; } }, diff --git a/lib/widgets/common/basic/multi_cross_fader.dart b/lib/widgets/common/basic/multi_cross_fader.dart index 17a07ad9b..df65a57af 100644 --- a/lib/widgets/common/basic/multi_cross_fader.dart +++ b/lib/widgets/common/basic/multi_cross_fader.dart @@ -4,6 +4,7 @@ class MultiCrossFader extends StatefulWidget { final Duration duration; final Curve fadeCurve, sizeCurve; final AlignmentGeometry alignment; + final AnimatedCrossFadeBuilder layoutBuilder; final Widget child; const MultiCrossFader({ @@ -12,6 +13,7 @@ class MultiCrossFader extends StatefulWidget { this.fadeCurve = Curves.linear, this.sizeCurve = Curves.linear, this.alignment = Alignment.topCenter, + this.layoutBuilder = AnimatedCrossFade.defaultLayoutBuilder, required this.child, }); @@ -53,6 +55,8 @@ class _MultiCrossFaderState extends State { alignment: widget.alignment, crossFadeState: _fadeState, duration: widget.duration, + reverseDuration: widget.duration, + layoutBuilder: widget.layoutBuilder, ); } } diff --git a/lib/widgets/common/extensions/geometry.dart b/lib/widgets/common/extensions/geometry.dart new file mode 100644 index 000000000..3979c4ab3 --- /dev/null +++ b/lib/widgets/common/extensions/geometry.dart @@ -0,0 +1,7 @@ +import 'dart:ui'; + +extension ExtraRect on Rect { + bool containsIncludingBottomRight(Offset offset, {double tolerance = 0}) { + return offset.dx >= left - tolerance && offset.dx <= right + tolerance && offset.dy >= top - tolerance && offset.dy <= bottom + tolerance; + } +} diff --git a/lib/widgets/common/fx/checkered_decoration.dart b/lib/widgets/common/fx/checkered_decoration.dart index ad2de6e62..b7fb249fd 100644 --- a/lib/widgets/common/fx/checkered_decoration.dart +++ b/lib/widgets/common/fx/checkered_decoration.dart @@ -15,7 +15,7 @@ class CheckeredPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - final background = Rect.fromLTWH(0, 0, size.width, size.height); + final background = Offset.zero & size; canvas.drawRect(background, lightPaint); final dx = offset.dx % (checkSize * 2); diff --git a/lib/widgets/common/fx/dashed_path_painter.dart b/lib/widgets/common/fx/dashed_path_painter.dart new file mode 100644 index 000000000..5d0c36d56 --- /dev/null +++ b/lib/widgets/common/fx/dashed_path_painter.dart @@ -0,0 +1,142 @@ +import 'dart:ui' as ui; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +// from https://stackoverflow.com/a/71099304/786656 +class DashedPathPainter extends CustomPainter { + final Path originalPath; + final Color pathColor; + final double strokeWidth; + final double dashGapLength; + final double dashLength; + late DashedPathProperties _dashedPathProperties; + + DashedPathPainter({ + required this.originalPath, + required this.pathColor, + this.strokeWidth = 3.0, + this.dashGapLength = 5.0, + this.dashLength = 10.0, + }); + + @override + void paint(Canvas canvas, Size size) { + _dashedPathProperties = DashedPathProperties( + path: Path(), + dashLength: dashLength, + dashGapLength: dashGapLength, + ); + final dashedPath = _getDashedPath(originalPath, dashLength, dashGapLength); + canvas.drawPath( + dashedPath, + Paint() + ..style = PaintingStyle.stroke + ..color = pathColor + ..strokeWidth = strokeWidth, + ); + } + + @override + bool shouldRepaint(DashedPathPainter oldDelegate) => oldDelegate.originalPath != originalPath || oldDelegate.pathColor != pathColor || oldDelegate.strokeWidth != strokeWidth || oldDelegate.dashGapLength != dashGapLength || oldDelegate.dashLength != dashLength; + + Path _getDashedPath( + Path originalPath, + double dashLength, + double dashGapLength, + ) { + final metricsIterator = originalPath.computeMetrics().iterator; + while (metricsIterator.moveNext()) { + final metric = metricsIterator.current; + _dashedPathProperties.extractedPathLength = 0.0; + while (_dashedPathProperties.extractedPathLength < metric.length) { + if (_dashedPathProperties.addDashNext) { + _dashedPathProperties.addDash(metric, dashLength); + } else { + _dashedPathProperties.addDashGap(metric, dashGapLength); + } + } + } + return _dashedPathProperties.path; + } +} + +class DashedPathProperties { + double extractedPathLength; + Path path; + + final double _dashLength; + double _remainingDashLength; + double _remainingDashGapLength; + bool _previousWasDash; + + DashedPathProperties({ + required this.path, + required double dashLength, + required double dashGapLength, + }) : assert(dashLength > 0.0, 'dashLength must be > 0.0'), + assert(dashGapLength > 0.0, 'dashGapLength must be > 0.0'), + _dashLength = dashLength, + _remainingDashLength = dashLength, + _remainingDashGapLength = dashGapLength, + _previousWasDash = false, + extractedPathLength = 0.0; + + bool get addDashNext { + if (!_previousWasDash || _remainingDashLength != _dashLength) { + return true; + } + return false; + } + + void addDash(ui.PathMetric metric, double dashLength) { + // Calculate lengths (actual + available) + final end = _calculateLength(metric, _remainingDashLength); + final availableEnd = _calculateLength(metric, dashLength); + // Add path + final pathSegment = metric.extractPath(extractedPathLength, end); + path.addPath(pathSegment, Offset.zero); + // Update + final delta = _remainingDashLength - (end - extractedPathLength); + _remainingDashLength = _updateRemainingLength( + delta: delta, + end: end, + availableEnd: availableEnd, + initialLength: dashLength, + ); + extractedPathLength = end; + _previousWasDash = true; + } + + void addDashGap(ui.PathMetric metric, double dashGapLength) { + // Calculate lengths (actual + available) + final end = _calculateLength(metric, _remainingDashGapLength); + final availableEnd = _calculateLength(metric, dashGapLength); + // Move path's end point + ui.Tangent tangent = metric.getTangentForOffset(end)!; + path.moveTo(tangent.position.dx, tangent.position.dy); + // Update + final delta = end - extractedPathLength; + _remainingDashGapLength = _updateRemainingLength( + delta: delta, + end: end, + availableEnd: availableEnd, + initialLength: dashGapLength, + ); + extractedPathLength = end; + _previousWasDash = false; + } + + double _calculateLength(ui.PathMetric metric, double addedLength) { + return math.min(extractedPathLength + addedLength, metric.length); + } + + double _updateRemainingLength({ + required double delta, + required double end, + required double availableEnd, + required double initialLength, + }) { + return (delta > 0 && availableEnd == end) ? delta : initialLength; + } +} diff --git a/lib/widgets/common/fx/transition_image.dart b/lib/widgets/common/fx/transition_image.dart index f9aa1284c..abd417506 100644 --- a/lib/widgets/common/fx/transition_image.dart +++ b/lib/widgets/common/fx/transition_image.dart @@ -343,10 +343,11 @@ class _TransitionImagePainter extends CustomPainter { ..filterQuality = FilterQuality.low; const alignment = Alignment.center; - final rect = ui.Rect.fromLTWH(0, 0, size.width, size.height); + final rect = Offset.zero & size; if (rect.isEmpty) { return; } + final outputSize = rect.size; final inputSize = Size(image!.width.toDouble(), image!.height.toDouble()); diff --git a/lib/widgets/common/grid/item_tracker.dart b/lib/widgets/common/grid/item_tracker.dart index 74cd97101..4cd88a32f 100644 --- a/lib/widgets/common/grid/item_tracker.dart +++ b/lib/widgets/common/grid/item_tracker.dart @@ -101,7 +101,7 @@ class _GridItemTrackerState extends State> with WidgetsBin final tileRect = sectionedListLayout.getTileRect(event.item); if (tileRect == null) return; - final viewportRect = Rect.fromLTWH(0, scrollController.offset, scrollableSize.width, scrollableSize.height); + final viewportRect = Offset(0, scrollController.offset) & scrollableSize; final itemVisibility = max(0, tileRect.intersect(viewportRect).height) / tileRect.height; if (!event.predicate(itemVisibility)) return; diff --git a/lib/widgets/editor/control_panel.dart b/lib/widgets/editor/control_panel.dart new file mode 100644 index 000000000..2a3dc2711 --- /dev/null +++ b/lib/widgets/editor/control_panel.dart @@ -0,0 +1,137 @@ +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/view/view.dart'; +import 'package:aves/widgets/common/basic/multi_cross_fader.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/buttons/overlay_button.dart'; +import 'package:aves/widgets/editor/transform/control_panel.dart'; +import 'package:aves/widgets/editor/transform/controller.dart'; +import 'package:aves/widgets/viewer/overlay/viewer_buttons.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class EditorControlPanel extends StatelessWidget { + final AvesEntry entry; + final ValueNotifier actionNotifier; + + static const padding = ViewerButtonRowContent.padding; + static const actions = [ + EditorAction.transform, + ]; + + const EditorControlPanel({ + super.key, + required this.entry, + required this.actionNotifier, + }); + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () { + if (actionNotifier.value != null) { + _cancelAction(context); + return SynchronousFuture(false); + } + return SynchronousFuture(true); + }, + child: Padding( + padding: const EdgeInsets.all(padding), + child: TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: ValueListenableBuilder( + valueListenable: actionNotifier, + builder: (context, action, child) { + return MultiCrossFader( + duration: context.select((v) => v.formTransition), + alignment: Alignment.bottomCenter, + layoutBuilder: (topChild, topChildKey, bottomChild, bottomChildKey) { + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + key: bottomChildKey, + left: 0.0, + bottom: 0.0, + right: 0.0, + child: bottomChild, + ), + Positioned( + key: topChildKey, + child: topChild, + ), + ], + ); + }, + child: action == null ? _buildTopLevelPanel(context) : _buildActionPanel(context, action), + ); + }, + ), + ), + ), + ); + } + + Widget _buildTopLevelPanel(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...actions.map( + (action) => Padding( + padding: const EdgeInsetsDirectional.symmetric(horizontal: padding / 2), + child: OverlayButton( + child: IconButton( + icon: action.getIcon(), + onPressed: () => actionNotifier.value = action, + tooltip: action.getText(context), + ), + ), + ), + ), + ], + ), + const SizedBox(height: padding), + Row( + children: [ + const OverlayButton( + child: CloseButton(), + ), + const Spacer(), + OverlayTextButton( + onPressed: () {}, + child: Text(context.l10n.saveCopyButtonLabel), + ), + ], + ), + ], + ); + } + + Widget _buildActionPanel(BuildContext context, EditorAction action) { + switch (action) { + case EditorAction.transform: + return TransformControlPanel( + entry: entry, + onCancel: () => _cancelAction(context), + onApply: (transformation) => _applyAction(context), + ); + } + } + + void _cancelAction(BuildContext context) { + actionNotifier.value = null; + context.read().reset(); + } + + void _applyAction(BuildContext context) { + actionNotifier.value = null; + context.read().reset(); + } +} diff --git a/lib/widgets/editor/entry_editor_page.dart b/lib/widgets/editor/entry_editor_page.dart new file mode 100644 index 000000000..f375aeb27 --- /dev/null +++ b/lib/widgets/editor/entry_editor_page.dart @@ -0,0 +1,136 @@ +import 'dart:async'; + +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/view_state.dart'; +import 'package:aves/widgets/editor/control_panel.dart'; +import 'package:aves/widgets/editor/image.dart'; +import 'package:aves/widgets/editor/transform/controller.dart'; +import 'package:aves/widgets/editor/transform/cropper.dart'; +import 'package:aves/widgets/viewer/overlay/minimap.dart'; +import 'package:aves_magnifier/aves_magnifier.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ImageEditorPage extends StatefulWidget { + static const routeName = '/image_editor'; + + final AvesEntry entry; + + const ImageEditorPage({ + super.key, + required this.entry, + }); + + @override + State createState() => _ImageEditorPageState(); +} + +class _ImageEditorPageState extends State { + final List _subscriptions = []; + final ValueNotifier _actionNotifier = ValueNotifier(null); + final ValueNotifier _paddingNotifier = ValueNotifier(EdgeInsets.zero); + final ValueNotifier _viewStateNotifier = ValueNotifier(ViewState.zero); + final AvesMagnifierController _magnifierController = AvesMagnifierController(); + late final TransformController _transformController; + + @override + void initState() { + super.initState(); + _transformController = TransformController(widget.entry.displaySize); + _actionNotifier.addListener(_onActionChanged); + _subscriptions.add(_transformController.transformationStream.map((v) => v.matrix).distinct().listen(_onTransformationMatrixChanged)); + } + + @override + void dispose() { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + _actionNotifier.dispose(); + _paddingNotifier.dispose(); + _viewStateNotifier.dispose(); + _magnifierController.dispose(); + _transformController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Provider.value( + value: _transformController, + child: SafeArea( + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + ClipRect( + child: EditorImage( + magnifierController: _magnifierController, + transformController: _transformController, + actionNotifier: _actionNotifier, + paddingNotifier: _paddingNotifier, + viewStateNotifier: _viewStateNotifier, + entry: widget.entry, + ), + ), + if (settings.showOverlayMinimap) + PositionedDirectional( + start: 8, + bottom: 8, + child: Minimap(viewStateNotifier: _viewStateNotifier), + ), + ValueListenableBuilder( + valueListenable: _actionNotifier, + builder: (context, action, child) { + switch (action) { + case EditorAction.transform: + return Cropper( + magnifierController: _magnifierController, + transformController: _transformController, + paddingNotifier: _paddingNotifier, + ); + case null: + return const SizedBox(); + } + }, + ), + ], + ), + ), + EditorControlPanel( + entry: widget.entry, + actionNotifier: _actionNotifier, + ), + ], + ), + ), + ), + resizeToAvoidBottomInset: false, + ); + } + + void _onActionChanged() => _updateImagePadding(); + + void _updateImagePadding() { + if (_actionNotifier.value == EditorAction.transform) { + _paddingNotifier.value = Cropper.imagePadding; + } else { + _paddingNotifier.value = EdgeInsets.zero; + } + } + + void _onTransformationMatrixChanged(Matrix4 transformationMatrix) { + final boundaries = _magnifierController.scaleBoundaries; + if (boundaries != null) { + _magnifierController.setScaleBoundaries( + boundaries.copyWith( + externalTransform: transformationMatrix, + ), + ); + } + } +} diff --git a/lib/widgets/editor/image.dart b/lib/widgets/editor/image.dart new file mode 100644 index 000000000..2b2f703ea --- /dev/null +++ b/lib/widgets/editor/image.dart @@ -0,0 +1,224 @@ +import 'dart:async'; + +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/model/view_state.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/editor/transform/controller.dart'; +import 'package:aves/widgets/editor/transform/painter.dart'; +import 'package:aves/widgets/editor/transform/transformation.dart'; +import 'package:aves/widgets/viewer/visual/error.dart'; +import 'package:aves/widgets/viewer/visual/raster.dart'; +import 'package:aves_magnifier/aves_magnifier.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class EditorImage extends StatefulWidget { + final AvesMagnifierController magnifierController; + final TransformController transformController; + final ValueNotifier actionNotifier; + final ValueNotifier paddingNotifier; + final ValueNotifier viewStateNotifier; + final AvesEntry entry; + + const EditorImage({ + super.key, + required this.magnifierController, + required this.transformController, + required this.actionNotifier, + required this.paddingNotifier, + required this.viewStateNotifier, + required this.entry, + }); + + @override + State createState() => _EditorImageState(); +} + +class _EditorImageState extends State { + final List _subscriptions = []; + final ValueNotifier _scrimOpacityNotifier = ValueNotifier(0); + + AvesEntry get entry => widget.entry; + + TransformController get transformController => widget.transformController; + + ValueNotifier get viewStateNotifier => widget.viewStateNotifier; + + @override + void initState() { + super.initState(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(covariant EditorImage oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + _unregisterWidget(widget); + super.dispose(); + } + + void _registerWidget(EditorImage widget) { + widget.actionNotifier.addListener(_onActionChanged); + _subscriptions.add(widget.magnifierController.stateStream.listen(_onViewStateChanged)); + _subscriptions.add(widget.magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged)); + _subscriptions.add(widget.transformController.eventStream.listen(_onTransformEvent)); + } + + void _unregisterWidget(EditorImage widget) { + widget.actionNotifier.removeListener(_onActionChanged); + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + } + + @override + Widget build(BuildContext context) { + return MagnifierGestureDetectorScope( + axis: const [Axis.horizontal, Axis.vertical], + child: StreamBuilder( + stream: transformController.transformationStream, + builder: (context, snapshot) { + final transformation = (snapshot.data ?? Transformation.zero); + final highlightRegionCorners = transformation.region.corners; + final imageToUserMatrix = transformation.matrix; + + final mediaSize = entry.displaySize; + final canvasSize = MatrixUtils.transformRect(imageToUserMatrix, Offset.zero & mediaSize).size; + + return ValueListenableBuilder( + valueListenable: widget.paddingNotifier, + builder: (context, padding, child) { + return Transform( + alignment: Alignment.center, + transform: imageToUserMatrix, + child: ValueListenableBuilder( + valueListenable: widget.actionNotifier, + builder: (context, action, child) { + return LayoutBuilder( + builder: (context, constraints) { + final viewportSize = padding.deflateSize(constraints.biggest); + final minScale = ScaleLevel(factor: ScaleLevel.scaleForContained(viewportSize, canvasSize)); + return AvesMagnifier( + key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedSecs}'), + controller: widget.magnifierController, + viewportPadding: padding, + contentSize: mediaSize, + allowOriginalScaleBeyondRange: false, + allowGestureScaleBeyondRange: false, + panInertia: _getActionPanInertia(action), + minScale: minScale, + maxScale: const ScaleLevel(factor: 1), + initialScale: minScale, + scaleStateCycle: defaultScaleStateCycle, + applyScale: false, + onScaleStart: (details, doubleTap, boundaries) { + transformController.activity = TransformActivity.pan; + }, + onScaleEnd: (details) { + transformController.activity = TransformActivity.none; + }, + child: child!, + ); + }, + ); + }, + child: Stack( + children: [ + RasterImageView( + entry: entry, + viewStateNotifier: viewStateNotifier, + errorBuilder: (context, error, stackTrace) => ErrorView( + entry: entry, + onTap: () {}, + ), + ), + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: viewStateNotifier, + builder: (context, viewState, child) { + final scale = viewState.scale ?? 1; + final highlightRegionPath = Path()..addPolygon(highlightRegionCorners.map((v) => v * scale).toList(), true); + return ValueListenableBuilder( + valueListenable: _scrimOpacityNotifier, + builder: (context, opacity, child) { + return AnimatedOpacity( + opacity: opacity, + duration: context.read().viewerOverlayAnimation, + child: CustomPaint( + painter: ScrimPainter( + excludePath: highlightRegionPath, + opacity: opacity, + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ); + } + + void _onViewStateChanged(MagnifierState v) { + viewStateNotifier.value = viewStateNotifier.value.copyWith( + position: v.position, + scale: v.scale, + ); + } + + void _onViewScaleBoundariesChanged(ScaleBoundaries v) { + viewStateNotifier.value = viewStateNotifier.value.copyWith( + viewportSize: v.viewportSize, + contentSize: v.contentSize, + ); + } + + void _onActionChanged() => _updateScrim(); + + void _onTransformEvent(TransformEvent event) => _updateScrim(); + + void _updateScrim() => _scrimOpacityNotifier.value = _getActionScrimOpacity(widget.actionNotifier.value, transformController.activity); + + static double _getActionPanInertia(EditorAction? action) { + switch (action) { + case EditorAction.transform: + return 0; + case null: + return AvesMagnifier.defaultPanInertia; + } + } + + static double _getActionScrimOpacity(EditorAction? action, TransformActivity activity) { + switch (action) { + case EditorAction.transform: + switch (activity) { + case TransformActivity.none: + return .9; + case TransformActivity.pan: + case TransformActivity.resize: + case TransformActivity.straighten: + return .6; + } + case null: + return 0; + } + } +} diff --git a/lib/widgets/editor/transform/control_panel.dart b/lib/widgets/editor/transform/control_panel.dart new file mode 100644 index 000000000..4615563bd --- /dev/null +++ b/lib/widgets/editor/transform/control_panel.dart @@ -0,0 +1,196 @@ +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/view/view.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/buttons/captioned_button.dart'; +import 'package:aves/widgets/common/identity/buttons/overlay_button.dart'; +import 'package:aves/widgets/editor/control_panel.dart'; +import 'package:aves/widgets/editor/transform/controller.dart'; +import 'package:aves/widgets/editor/transform/transformation.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; + +class TransformControlPanel extends StatefulWidget { + final AvesEntry entry; + final VoidCallback onCancel; + final void Function(Transformation transformation) onApply; + + const TransformControlPanel({ + super.key, + required this.entry, + required this.onCancel, + required this.onApply, + }); + + @override + State createState() => _TransformControlPanelState(); +} + +class _TransformControlPanelState extends State with TickerProviderStateMixin { + late final List> _tabs; + late final TabController _tabController; + + static const padding = EditorControlPanel.padding; + + @override + void initState() { + super.initState(); + _tabs = [ + Tuple2( + (context) => Tab(text: context.l10n.editorTransformCrop), + (context) => const CropControlPanel(), + ), + Tuple2( + (context) => Tab(text: context.l10n.editorTransformRotate), + (context) => const RotationControlPanel(), + ), + ]; + _tabController = TabController( + length: _tabs.length, + vsync: this, + ); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final transformController = context.watch(); + return Column( + children: [ + SizedBox( + height: CropControlPanel.preferredHeight(context), + child: AnimatedBuilder( + animation: _tabController, + builder: (context, child) { + return AnimatedSwitcher( + duration: context.select((v) => v.formTransition), + child: _tabs[_tabController.index].item2(context), + ); + }, + ), + ), + const SizedBox(height: padding), + Row( + children: [ + const OverlayButton( + child: BackButton(), + ), + Expanded( + child: TabBar( + tabs: _tabs.map((v) => v.item1(context)).toList(), + controller: _tabController, + padding: const EdgeInsets.symmetric(horizontal: padding), + indicatorSize: TabBarIndicatorSize.label, + ), + ), + OverlayButton( + child: StreamBuilder( + stream: transformController.transformationStream, + builder: (context, snapshot) { + return IconButton( + icon: const Icon(AIcons.apply), + onPressed: transformController.modified ? () => widget.onApply(transformController.transformation) : null, + tooltip: context.l10n.applyTooltip, + ); + }, + ), + ), + ], + ), + ], + ); + } +} + +class CropControlPanel extends StatelessWidget { + const CropControlPanel({super.key}); + + static double preferredHeight(BuildContext context) => CropAspectRatio.values.map((v) { + return CaptionedButton.getSize(context, v.getText(context), showCaption: true).height; + }).max; + + @override + Widget build(BuildContext context) { + final aspectRatioNotifier = context.select>((v) => v.aspectRatioNotifier); + return ListView.builder( + scrollDirection: Axis.horizontal, + shrinkWrap: true, + itemBuilder: (context, index) { + final ratio = CropAspectRatio.values[index]; + void setAspectRatio() => aspectRatioNotifier.value = ratio; + return CaptionedButton( + iconButtonBuilder: (context, focusNode) { + return ValueListenableBuilder( + valueListenable: aspectRatioNotifier, + builder: (context, selectedRatio, child) { + return IconButton( + color: ratio == selectedRatio ? Theme.of(context).colorScheme.primary : null, + onPressed: setAspectRatio, + focusNode: focusNode, + icon: ratio.getIcon(), + ); + }, + ); + }, + caption: ratio.getText(context), + onPressed: setAspectRatio, + ); + }, + itemCount: CropAspectRatio.values.length, + ); + } +} + +class RotationControlPanel extends StatelessWidget { + const RotationControlPanel({super.key}); + + @override + Widget build(BuildContext context) { + final controller = context.watch(); + + return Row( + children: [ + _buildButton(context, EntryAction.flip, controller.flipHorizontally), + Expanded( + child: StreamBuilder( + stream: controller.transformationStream, + builder: (context, snapshot) { + final transformation = snapshot.data ?? Transformation.zero; + return Slider( + value: transformation.straightenDegrees, + min: TransformController.straightenDegreesMin, + max: TransformController.straightenDegreesMax, + divisions: 18, + onChangeStart: (v) => controller.activity = TransformActivity.straighten, + onChangeEnd: (v) => controller.activity = TransformActivity.none, + label: NumberFormat('0.0°', context.l10n.localeName).format(transformation.straightenDegrees), + onChanged: (v) => controller.straightenDegrees = v, + ); + }, + ), + ), + _buildButton(context, EntryAction.rotateCW, controller.rotateClockwise), + ], + ); + } + + Widget _buildButton(BuildContext context, EntryAction action, VoidCallback onPressed) { + return OverlayButton( + child: IconButton( + icon: action.getIcon(), + onPressed: onPressed, + tooltip: action.getText(context), + ), + ); + } +} diff --git a/lib/widgets/editor/transform/controller.dart b/lib/widgets/editor/transform/controller.dart new file mode 100644 index 000000000..0c414a326 --- /dev/null +++ b/lib/widgets/editor/transform/controller.dart @@ -0,0 +1,88 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:aves/widgets/editor/transform/crop_region.dart'; +import 'package:aves/widgets/editor/transform/transformation.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:flutter/foundation.dart'; + +class TransformController { + ValueNotifier aspectRatioNotifier = ValueNotifier(CropAspectRatio.free); + + TransformActivity _activity = TransformActivity.none; + + TransformActivity get activity => _activity; + + Transformation _transformation = Transformation.zero; + + Transformation get transformation => _transformation; + + bool get modified => _transformation != Transformation.zero; + + final StreamController _transformationStreamController = StreamController.broadcast(); + + Stream get transformationStream => _transformationStreamController.stream; + + final StreamController _eventStreamController = StreamController.broadcast(); + + Stream get eventStream => _eventStreamController.stream; + + static const double straightenDegreesMin = -45; + static const double straightenDegreesMax = 45; + + final Size displaySize; + + TransformController(this.displaySize) { + reset(); + aspectRatioNotifier.addListener(_onAspectRatioChanged); + } + + void dispose() { + aspectRatioNotifier.dispose(); + } + + void reset() { + _transformation = Transformation.zero.copyWith( + region: CropRegion.fromRect(Offset.zero & displaySize), + ); + _transformationStreamController.add(_transformation); + } + + void flipHorizontally() { + _transformation = _transformation.copyWith( + orientation: _transformation.orientation.flipHorizontally(), + straightenDegrees: -transformation.straightenDegrees, + ); + _transformationStreamController.add(_transformation); + } + + void rotateClockwise() { + _transformation = _transformation.copyWith( + orientation: _transformation.orientation.rotateClockwise(), + ); + _transformationStreamController.add(_transformation); + } + + set straightenDegrees(double straightenDegrees) { + _transformation = _transformation.copyWith( + straightenDegrees: straightenDegrees.clamp(straightenDegreesMin, straightenDegreesMax), + ); + _transformationStreamController.add(_transformation); + } + + set cropRegion(CropRegion region) { + _transformation = _transformation.copyWith( + region: region, + ); + _transformationStreamController.add(_transformation); + } + + set activity(TransformActivity activity) { + _activity = activity; + _eventStreamController.add(TransformEvent(activity: _activity)); + } + + void _onAspectRatioChanged() { + // TODO TLAD [crop] apply + } +} diff --git a/lib/widgets/editor/transform/crop_region.dart b/lib/widgets/editor/transform/crop_region.dart new file mode 100644 index 000000000..013a12c9a --- /dev/null +++ b/lib/widgets/editor/transform/crop_region.dart @@ -0,0 +1,48 @@ +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class CropRegion extends Equatable { + final Offset topLeft, topRight, bottomRight, bottomLeft; + + List get corners => [topLeft, topRight, bottomRight, bottomLeft]; + + Offset get center => (topLeft + bottomRight) / 2; + + Rect get outsideRect { + final xMin = corners.map((v) => v.dx).min; + final xMax = corners.map((v) => v.dx).max; + final yMin = corners.map((v) => v.dy).min; + final yMax = corners.map((v) => v.dy).max; + return Rect.fromPoints(Offset(xMin, yMin), Offset(xMax, yMax)); + } + + @override + List get props => [topLeft, topRight, bottomRight, bottomLeft]; + + const CropRegion({ + required this.topLeft, + required this.topRight, + required this.bottomRight, + required this.bottomLeft, + }); + + static const CropRegion zero = CropRegion( + topLeft: Offset.zero, + topRight: Offset.zero, + bottomRight: Offset.zero, + bottomLeft: Offset.zero, + ); + + factory CropRegion.fromRect(Rect rect) { + return CropRegion( + topLeft: rect.topLeft, + topRight: rect.topRight, + bottomRight: rect.bottomRight, + bottomLeft: rect.bottomLeft, + ); + } +} diff --git a/lib/widgets/editor/transform/cropper.dart b/lib/widgets/editor/transform/cropper.dart new file mode 100644 index 000000000..df31cc44b --- /dev/null +++ b/lib/widgets/editor/transform/cropper.dart @@ -0,0 +1,734 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:aves/model/view_state.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/extensions/geometry.dart'; +import 'package:aves/widgets/common/fx/dashed_path_painter.dart'; +import 'package:aves/widgets/editor/transform/controller.dart'; +import 'package:aves/widgets/editor/transform/crop_region.dart'; +import 'package:aves/widgets/editor/transform/handles.dart'; +import 'package:aves/widgets/editor/transform/painter.dart'; +import 'package:aves/widgets/editor/transform/transformation.dart'; +import 'package:aves_magnifier/aves_magnifier.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:aves_utils/aves_utils.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class Cropper extends StatefulWidget { + final AvesMagnifierController magnifierController; + final TransformController transformController; + final ValueNotifier paddingNotifier; + + static const double handleDimension = kMinInteractiveDimension; + static const EdgeInsets imagePadding = EdgeInsets.all(kMinInteractiveDimension); + + const Cropper({ + super.key, + required this.magnifierController, + required this.transformController, + required this.paddingNotifier, + }); + + @override + State createState() => _CropperState(); +} + +class _CropperState extends State with SingleTickerProviderStateMixin { + final List _subscriptions = []; + final ValueNotifier _viewportSizeNotifier = ValueNotifier(Size.zero); + final ValueNotifier _outlineNotifier = ValueNotifier(Rect.zero); + final ValueNotifier _gridDivisionNotifier = ValueNotifier(0); + late AnimationController _gridAnimationController; + late Animation _gridOpacity; + + static const double minDimension = Cropper.handleDimension; + static const int panResizeGridDivision = 3; + static const int straightenGridDivision = 9; + static const double overOutlineFactor = .25; + + AvesMagnifierController get magnifierController => widget.magnifierController; + + TransformController get transformController => widget.transformController; + + Transformation get transformation => transformController.transformation; + + CropAspectRatio get cropAspectRatio => transformController.aspectRatioNotifier.value; + + @override + void initState() { + super.initState(); + final initialRegion = transformation.region; + _viewportSizeNotifier.addListener(() => _initOutline(initialRegion)); + _gridAnimationController = AnimationController( + duration: context.read().viewerOverlayAnimation, + vsync: this, + ); + _gridOpacity = CurvedAnimation( + parent: _gridAnimationController, + curve: Curves.easeOutQuad, + ); + _registerWidget(widget); + _initOutline(initialRegion); + } + + @override + void didUpdateWidget(covariant Cropper oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _viewportSizeNotifier.dispose(); + _outlineNotifier.dispose(); + _gridDivisionNotifier.dispose(); + _gridAnimationController.dispose(); + _unregisterWidget(widget); + super.dispose(); + } + + void _registerWidget(Cropper widget) { + _subscriptions.add(widget.magnifierController.stateStream.listen(_onViewStateChanged)); + _subscriptions.add(widget.magnifierController.scaleBoundariesStream.listen(_onViewBoundariesChanged)); + _subscriptions.add(widget.transformController.eventStream.listen(_onTransformEvent)); + _subscriptions.add(widget.transformController.transformationStream.map((v) => v.orientation).distinct().listen(_onOrientationChanged)); + _subscriptions.add(widget.transformController.transformationStream.map((v) => v.straightenDegrees).distinct().listen(_onStraightenDegreesChanged)); + widget.transformController.aspectRatioNotifier.addListener(_onCropAspectRatioChanged); + } + + void _unregisterWidget(Cropper widget) { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + widget.transformController.aspectRatioNotifier.removeListener(_onCropAspectRatioChanged); + } + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: ValueListenableBuilder( + valueListenable: widget.paddingNotifier, + builder: (context, padding, child) { + return ValueListenableBuilder( + valueListenable: _outlineNotifier, + builder: (context, outline, child) { + if (outline.isEmpty) return const SizedBox(); + + final outlineVisualRect = outline.translate(padding.left, padding.top); + return Stack( + children: [ + Positioned.fill( + child: IgnorePointer( + child: Stack( + children: [ + _buildDashLine([outlineVisualRect.topLeft, outlineVisualRect.topRight]), + _buildDashLine([outlineVisualRect.bottomLeft, outlineVisualRect.bottomRight]), + _buildDashLine([outlineVisualRect.topLeft, outlineVisualRect.bottomLeft]), + _buildDashLine([outlineVisualRect.topRight, outlineVisualRect.bottomRight]), + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: _gridDivisionNotifier, + builder: (context, gridDivision, child) { + return ValueListenableBuilder( + valueListenable: _gridOpacity, + builder: (context, gridOpacity, child) { + return CustomPaint( + painter: CropperPainter( + rect: outlineVisualRect, + gridOpacity: gridOpacity, + gridDivision: gridDivision, + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ), + _buildVertexHandle( + padding: padding, + getPosition: () => outline.topLeft, + setPosition: (v) => _handleOutline( + topLeft: Offset(min(outline.right - minDimension, v.dx), min(outline.bottom - minDimension, v.dy)), + ), + ), + _buildVertexHandle( + padding: padding, + getPosition: () => outline.topRight, + setPosition: (v) => _handleOutline( + topRight: Offset(max(outline.left + minDimension, v.dx), min(outline.bottom - minDimension, v.dy)), + ), + ), + _buildVertexHandle( + padding: padding, + getPosition: () => outline.bottomRight, + setPosition: (v) => _handleOutline( + bottomRight: Offset(max(outline.left + minDimension, v.dx), max(outline.top + minDimension, v.dy)), + ), + ), + _buildVertexHandle( + padding: padding, + getPosition: () => outline.bottomLeft, + setPosition: (v) => _handleOutline( + bottomLeft: Offset(min(outline.right - minDimension, v.dx), max(outline.top + minDimension, v.dy)), + ), + ), + _buildEdgeHandle( + padding: padding, + getEdge: () => Rect.fromPoints(outline.bottomLeft, outline.topLeft), + setEdge: (v) { + final left = min(outline.right - minDimension, v.left); + return _handleOutline( + topLeft: Offset(left, outline.top), + bottomLeft: Offset(left, outline.bottom), + ); + }, + ), + _buildEdgeHandle( + padding: padding, + getEdge: () => Rect.fromPoints(outline.topLeft, outline.topRight), + setEdge: (v) { + final top = min(outline.bottom - minDimension, v.top); + return _handleOutline( + topLeft: Offset(outline.left, top), + topRight: Offset(outline.right, top), + ); + }, + ), + _buildEdgeHandle( + padding: padding, + getEdge: () => Rect.fromPoints(outline.bottomRight, outline.topRight), + setEdge: (v) { + final right = max(outline.left + minDimension, v.right); + return _handleOutline( + topRight: Offset(right, outline.top), + bottomRight: Offset(right, outline.bottom), + ); + }, + ), + _buildEdgeHandle( + padding: padding, + getEdge: () => Rect.fromPoints(outline.bottomLeft, outline.bottomRight), + setEdge: (v) { + final bottom = max(outline.top + minDimension, v.bottom); + return _handleOutline( + bottomLeft: Offset(outline.left, bottom), + bottomRight: Offset(outline.right, bottom), + ); + }, + ), + ], + ); + }, + ); + }, + ), + ); + } + + // use 1 painter per line so that the dashes of one line + // do not get offset depending on the previous line length + Widget _buildDashLine(List points) => CustomPaint( + painter: DashedPathPainter( + originalPath: Path()..addPolygon(points, false), + pathColor: CropperPainter.borderColor, + strokeWidth: CropperPainter.borderWidth, + ), + ); + + void _handleOutline({ + Offset? topLeft, + Offset? topRight, + Offset? bottomRight, + Offset? bottomLeft, + }) { + final currentOutline = _outlineNotifier.value; + var targetOutline = Rect.fromLTRB( + topLeft?.dx ?? bottomLeft?.dx ?? currentOutline.left, + topLeft?.dy ?? topRight?.dy ?? currentOutline.top, + topRight?.dx ?? bottomRight?.dx ?? currentOutline.right, + bottomLeft?.dy ?? bottomRight?.dy ?? currentOutline.bottom, + ); + + _RatioStrategy? ratioStrategy; + if (topLeft != null && topRight != null) { + ratioStrategy = _RatioStrategy.pinBottom; + } else if (topRight != null && bottomRight != null) { + ratioStrategy = _RatioStrategy.pinLeft; + } else if (bottomLeft != null && bottomRight != null) { + ratioStrategy = _RatioStrategy.pinTop; + } else if (topLeft != null && bottomLeft != null) { + ratioStrategy = _RatioStrategy.pinRight; + } else if (topLeft != null) { + ratioStrategy = _RatioStrategy.pinBottomRight; + } else if (topRight != null) { + ratioStrategy = _RatioStrategy.pinBottomLeft; + } else if (bottomRight != null) { + ratioStrategy = _RatioStrategy.pinTopLeft; + } else if (bottomLeft != null) { + ratioStrategy = _RatioStrategy.pinTopRight; + } + if (ratioStrategy != null) { + targetOutline = _applyCropRatioToOutline(targetOutline, ratioStrategy); + } + + // do not try to coerce outline handled outside tilted image + if (transformation.straightenDegrees != 0 && !_isOutlineContained(targetOutline)) return; + + // dismiss if we could not honour aspect ratio + if (cropAspectRatio != CropAspectRatio.free && !_isOutlineContained(targetOutline)) return; + + final currentState = _getViewState(); + final boundaries = magnifierController.scaleBoundaries; + if (currentState == null || boundaries == null) return; + + final gestureRegion = _regionFromOutline(currentState, targetOutline); + final viewportSize = boundaries.viewportSize; + + final gestureOutline = _regionToContainedOutline(currentState, gestureRegion); + final clampedOutline = Rect.fromLTRB( + max(gestureOutline.left, 0), + max(gestureOutline.top, 0), + min(gestureOutline.right, viewportSize.width), + min(gestureOutline.bottom, viewportSize.height), + ); + _setOutline(clampedOutline); + _updateCropRegion(); + + // zoom out when user gesture reaches outer edges + + if (gestureOutline.width - clampedOutline.width > precisionErrorTolerance || gestureOutline.height - clampedOutline.height > precisionErrorTolerance) { + final targetOutline = Rect.lerp(clampedOutline, gestureOutline, overOutlineFactor)!; + final targetRegion = _regionFromOutline(currentState, targetOutline); + + final nextState = _viewStateForContainedRegion(boundaries, targetRegion); + if (nextState != currentState) { + magnifierController.update( + position: nextState.position, + scale: nextState.scale, + source: ChangeSource.animation, + ); + _setOutline(_regionToContainedOutline(nextState, targetRegion)); + } + } + } + + bool _isOutlineContained(Rect outline) { + final currentState = _getViewState(); + final boundaries = magnifierController.scaleBoundaries; + if (currentState == null || boundaries == null) return false; + + final regionToOutlineMatrix = _getRegionToOutlineMatrix(currentState); + final outlineToRegionMatrix = Matrix4.inverted(regionToOutlineMatrix); + final regionCorners = { + outline.topLeft, + outline.topRight, + outline.bottomRight, + outline.bottomLeft, + }.map(outlineToRegionMatrix.transformOffset).toSet(); + + final contentRect = Offset.zero & boundaries.contentSize; + return regionCorners.every((v) => contentRect.containsIncludingBottomRight(v, tolerance: precisionErrorTolerance)); + } + + VertexHandle _buildVertexHandle({ + required EdgeInsets padding, + required ValueGetter getPosition, + required ValueSetter setPosition, + }) { + return VertexHandle( + padding: padding, + getPosition: getPosition, + setPosition: setPosition, + onDragStart: _onDragStart, + onDragEnd: _onDragEnd, + ); + } + + EdgeHandle _buildEdgeHandle({ + required EdgeInsets padding, + required ValueGetter getEdge, + required ValueSetter setEdge, + }) { + return EdgeHandle( + padding: padding, + getEdge: getEdge, + setEdge: setEdge, + onDragStart: _onDragStart, + onDragEnd: _onDragEnd, + ); + } + + void _onDragStart() { + transformController.activity = TransformActivity.resize; + } + + void _onDragEnd() { + transformController.activity = TransformActivity.none; + _showRegion(); + } + + void _showRegion() { + final boundaries = magnifierController.scaleBoundaries; + if (boundaries == null) return; + + final region = transformation.region; + final nextState = _viewStateForContainedRegion(boundaries, region); + + magnifierController.update( + position: nextState.position, + scale: nextState.scale, + source: ChangeSource.animation, + ); + _setOutline(_regionToContainedOutline(nextState, region)); + } + + ViewState _viewStateForContainedRegion(ScaleBoundaries boundaries, CropRegion region) { + final regionSize = MatrixUtils.transformRect(transformation.matrix, region.outsideRect).size; + final nextScale = boundaries.clampScale(ScaleLevel.scaleForContained(boundaries.viewportSize, regionSize)); + final nextPosition = boundaries.clampPosition( + position: boundaries.contentToStatePosition(nextScale, region.center), + scale: nextScale, + ); + return ViewState( + position: nextPosition, + scale: nextScale, + viewportSize: boundaries.viewportSize, + contentSize: boundaries.contentSize, + ); + } + + void _onTransformEvent(TransformEvent event) { + final activity = event.activity; + switch (activity) { + case TransformActivity.none: + break; + case TransformActivity.pan: + case TransformActivity.resize: + _gridDivisionNotifier.value = panResizeGridDivision; + break; + case TransformActivity.straighten: + _gridDivisionNotifier.value = straightenGridDivision; + break; + } + if (activity == TransformActivity.none) { + _gridAnimationController.reverse(); + } else { + _gridAnimationController.forward(); + } + } + + void _onOrientationChanged(TransformOrientation orientation) { + _showRegion(); + } + + void _onStraightenDegreesChanged(double degrees) { + _updateCropRegion(); + } + + void _onCropAspectRatioChanged() { + final viewState = _getViewState(); + if (viewState == null) return; + + var targetOutline = _applyCropRatioToOutline(_outlineNotifier.value, _RatioStrategy.keepArea); + if (!_isOutlineContained(targetOutline)) { + targetOutline = _applyCropRatioToOutline(_outlineNotifier.value, _RatioStrategy.contain); + } + transformController.cropRegion = _regionFromOutline(viewState, targetOutline); + _showRegion(); + } + + void _onViewStateChanged(MagnifierState state) { + final currentOutline = _outlineNotifier.value; + switch (state.source) { + case ChangeSource.internal: + case ChangeSource.animation: + _setOutline(currentOutline); + break; + case ChangeSource.gesture: + // TODO TLAD [crop] use other strat + _setOutline(_applyCropRatioToOutline(currentOutline, _RatioStrategy.contain)); + _updateCropRegion(); + break; + } + } + + void _onViewBoundariesChanged(ScaleBoundaries scaleBoundaries) { + _viewportSizeNotifier.value = scaleBoundaries.viewportSize; + } + + ViewState? _getViewState() { + final scaleBoundaries = magnifierController.scaleBoundaries; + if (scaleBoundaries == null) return null; + + final state = magnifierController.currentState; + return ViewState( + position: state.position, + scale: state.scale, + viewportSize: scaleBoundaries.viewportSize, + contentSize: scaleBoundaries.contentSize, + ); + } + + void _initOutline(CropRegion region) { + final viewState = _getViewState(); + if (viewState != null) { + _setOutline(_regionToContainedOutline(viewState, region)); + _updateCropRegion(); + } + } + + void _setOutline(Rect targetOutline) { + final viewState = _getViewState(); + final viewportSize = viewState?.viewportSize; + if (targetOutline.isEmpty || viewState == null || viewportSize == null) return; + + // ensure outline is within content + final targetRegion = _regionFromOutline(viewState, targetOutline); + var newOutline = _regionToContainedOutline(viewState, targetRegion); + + // ensure outline is large enough to be handled + newOutline = Rect.fromLTWH( + newOutline.left, + newOutline.top, + max(newOutline.width, minDimension), + max(newOutline.height, minDimension), + ); + + // ensure outline is within viewport + newOutline = Rect.fromLTRB( + max(newOutline.left, 0), + max(newOutline.top, 0), + min(newOutline.right, viewportSize.width), + min(newOutline.bottom, viewportSize.height), + ); + + _outlineNotifier.value = newOutline; + } + + void _updateCropRegion() { + final viewState = _getViewState(); + final outline = _outlineNotifier.value; + if (viewState != null && !outline.isEmpty) { + transformController.cropRegion = _regionFromOutline(viewState, outline); + } + } + + Matrix4 _getRegionToOutlineMatrix(ViewState viewState) { + final magnifierMatrix = viewState.matrix; + + final viewportCenter = viewState.viewportSize!.center(Offset.zero); + final transformOrigin = Matrix4.inverted(magnifierMatrix).transformOffset(viewportCenter); + final transformMatrix = Matrix4.identity() + ..translate(transformOrigin.dx, transformOrigin.dy) + ..multiply(transformation.matrix) + ..translate(-transformOrigin.dx, -transformOrigin.dy); + + return magnifierMatrix..multiply(transformMatrix); + } + + CropRegion _regionFromOutline(ViewState viewState, Rect outline) { + final regionToOutlineMatrix = _getRegionToOutlineMatrix(viewState); + final outlineToRegionMatrix = regionToOutlineMatrix..invert(); + + final region = CropRegion( + topLeft: outlineToRegionMatrix.transformOffset(outline.topLeft), + topRight: outlineToRegionMatrix.transformOffset(outline.topRight), + bottomRight: outlineToRegionMatrix.transformOffset(outline.bottomRight), + bottomLeft: outlineToRegionMatrix.transformOffset(outline.bottomLeft), + ); + + final rect = Offset.zero & viewState.contentSize!; + double clampX(double dx) => dx.clamp(rect.left, rect.right); + double clampY(double dy) => dy.clamp(rect.top, rect.bottom); + Offset clampPoint(Offset v) => Offset(clampX(v.dx), clampY(v.dy)); + final clampedRegion = CropRegion( + topLeft: clampPoint(region.topLeft), + topRight: clampPoint(region.topRight), + bottomRight: clampPoint(region.bottomRight), + bottomLeft: clampPoint(region.bottomLeft), + ); + return clampedRegion; + } + + Rect _regionToContainedOutline(ViewState viewState, CropRegion region) { + final matrix = _getRegionToOutlineMatrix(viewState); + final points = region.corners.map(matrix.transformOffset).toSet(); + final sortedX = points.map((v) => v.dx).toList()..sort(); + final sortedY = points.map((v) => v.dy).toList()..sort(); + final topLeft = Offset(sortedX[1], sortedY[1]); + final bottomRight = Offset(sortedX[2], sortedY[2]); + return Rect.fromPoints(topLeft, bottomRight); + } + + Rect _applyCropRatioToOutline(Rect outline, _RatioStrategy strategy) { + final currentState = _getViewState(); + final boundaries = magnifierController.scaleBoundaries; + if (currentState == null || boundaries == null) return outline; + + final contentSize = boundaries.contentSize; + + late int longCoef; + late int shortCoef; + switch (cropAspectRatio) { + case CropAspectRatio.free: + return outline; + case CropAspectRatio.original: + longCoef = contentSize.longestSide.round(); + shortCoef = contentSize.shortestSide.round(); + break; + case CropAspectRatio.square: + longCoef = 1; + shortCoef = 1; + break; + case CropAspectRatio.ar_16_9: + longCoef = 16; + shortCoef = 9; + break; + case CropAspectRatio.ar_4_3: + longCoef = 4; + shortCoef = 3; + break; + } + + final contentRect = Offset.zero & contentSize; + final isLandscape = (outline.width - outline.height).abs() > precisionErrorTolerance ? outline.width > outline.height : contentSize.width > contentSize.height; + final newRatio = isLandscape ? longCoef / shortCoef : shortCoef / longCoef; + + Size sizeToKeepArea() { + final f = (outline.longestSide + outline.shortestSide) / (longCoef + shortCoef); + final newLongest = f * longCoef; + final newShortest = f * shortCoef; + return isLandscape ? Size(newLongest, newShortest) : Size(newShortest, newLongest); + } + + final regionToOutlineMatrix = _getRegionToOutlineMatrix(currentState); + final outlineToRegionMatrix = Matrix4.inverted(regionToOutlineMatrix); + + Rect pinnedRect(Rect Function(Size targetSize) forSize) { + final targetSize = sizeToKeepArea(); + final rect = forSize(targetSize); + + // do not try to coerce outline handled outside tilted image + if (transformation.straightenDegrees != 0) return rect; + + final regionCorners = { + rect.topLeft, + rect.topRight, + rect.bottomRight, + rect.bottomLeft, + }.map(outlineToRegionMatrix.transformOffset).toSet(); + + if (regionCorners.every((v) => contentRect.containsIncludingBottomRight(v, tolerance: precisionErrorTolerance))) return rect; + + final clampedOutlineCorners = regionCorners.map((v) => regionToOutlineMatrix.transformOffset(Offset(v.dx.clamp(0, contentSize.width), v.dy.clamp(0, contentSize.height)))).toSet(); + final minX = clampedOutlineCorners.map((v) => v.dx).min; + final maxX = clampedOutlineCorners.map((v) => v.dx).max; + final minY = clampedOutlineCorners.map((v) => v.dy).min; + final maxY = clampedOutlineCorners.map((v) => v.dy).max; + + var width = rect.width; + var height = rect.height; + if (rect.left < minX - precisionErrorTolerance) { + width = rect.right - minX; + height = width / newRatio; + } else if (rect.top < minY - precisionErrorTolerance) { + height = rect.bottom - minY; + width = height * newRatio; + } else if (rect.right > maxX + precisionErrorTolerance) { + width = maxX - rect.left; + height = width / newRatio; + } else if (rect.bottom > maxY + precisionErrorTolerance) { + height = maxY - rect.top; + width = height * newRatio; + } + final clampedSize = Size(width, height); + return clampedSize < targetSize ? forSize(clampedSize) : rect; + } + + switch (strategy) { + case _RatioStrategy.keepArea: + final targetSize = sizeToKeepArea(); + return Rect.fromCenter( + center: outline.center, + width: targetSize.width, + height: targetSize.height, + ); + case _RatioStrategy.contain: + final currentRatio = outline.width / outline.height; + if ((newRatio - currentRatio).abs() < precisionErrorTolerance) { + return outline; + } else { + late final Size targetSize; + if (newRatio > currentRatio) { + targetSize = Size(outline.width, outline.width / newRatio); + } else { + targetSize = Size(outline.height * newRatio, outline.height); + } + return Rect.fromCenter( + center: outline.center, + width: targetSize.width, + height: targetSize.height, + ); + } + case _RatioStrategy.pinTopLeft: + return pinnedRect((targetSize) => Rect.fromPoints( + outline.topLeft, + outline.topLeft.translate(targetSize.width, targetSize.height), + )); + case _RatioStrategy.pinTopRight: + return pinnedRect((targetSize) => Rect.fromPoints( + outline.topRight, + outline.topRight.translate(-targetSize.width, targetSize.height), + )); + case _RatioStrategy.pinBottomRight: + return pinnedRect((targetSize) => Rect.fromPoints( + outline.bottomRight, + outline.bottomRight.translate(-targetSize.width, -targetSize.height), + )); + case _RatioStrategy.pinBottomLeft: + return pinnedRect((targetSize) => Rect.fromPoints( + outline.bottomLeft, + outline.bottomLeft.translate(targetSize.width, -targetSize.height), + )); + case _RatioStrategy.pinLeft: + return pinnedRect((targetSize) => Rect.fromLTRB( + outline.left, + outline.center.dy - targetSize.height / 2, + outline.left + targetSize.width, + outline.center.dy + targetSize.height / 2, + )); + case _RatioStrategy.pinTop: + return pinnedRect((targetSize) => Rect.fromLTRB( + outline.center.dx - targetSize.width / 2, + outline.top, + outline.center.dx + targetSize.width / 2, + outline.top + targetSize.height, + )); + case _RatioStrategy.pinRight: + return pinnedRect((targetSize) => Rect.fromLTRB( + outline.right - targetSize.width, + outline.center.dy - targetSize.height / 2, + outline.right, + outline.center.dy + targetSize.height / 2, + )); + case _RatioStrategy.pinBottom: + return pinnedRect((targetSize) => Rect.fromLTRB( + outline.center.dx - targetSize.width / 2, + outline.bottom - targetSize.height, + outline.center.dx + targetSize.width / 2, + outline.bottom, + )); + } + } +} + +enum _RatioStrategy { keepArea, contain, pinTopLeft, pinTopRight, pinBottomRight, pinBottomLeft, pinLeft, pinTop, pinRight, pinBottom } diff --git a/lib/widgets/editor/transform/handles.dart b/lib/widgets/editor/transform/handles.dart new file mode 100644 index 000000000..9bcd87ad7 --- /dev/null +++ b/lib/widgets/editor/transform/handles.dart @@ -0,0 +1,120 @@ +import 'package:aves/widgets/editor/transform/cropper.dart'; +import 'package:flutter/material.dart'; + +class VertexHandle extends StatefulWidget { + final EdgeInsets padding; + final ValueGetter getPosition; + final ValueSetter setPosition; + final VoidCallback onDragStart, onDragEnd; + + const VertexHandle({ + super.key, + required this.padding, + required this.getPosition, + required this.setPosition, + required this.onDragStart, + required this.onDragEnd, + }); + + @override + State createState() => _VertexHandleState(); +} + +class _VertexHandleState extends State { + Offset _start = Offset.zero; + Offset _totalDelta = Offset.zero; + + static const double _handleDim = Cropper.handleDimension; + + EdgeInsets get padding => widget.padding; + + @override + Widget build(BuildContext context) { + return Positioned.fromRect( + rect: Rect.fromCenter( + center: widget.getPosition().translate(padding.left, padding.right), + width: _handleDim, + height: _handleDim, + ), + child: GestureDetector( + onPanStart: (details) { + _totalDelta = Offset.zero; + _start = widget.getPosition(); + widget.onDragStart(); + }, + onPanUpdate: (details) { + _totalDelta += details.delta; + widget.setPosition(_start + _totalDelta); + }, + onPanEnd: (details) { + widget.onDragEnd(); + }, + child: const ColoredBox( + color: Colors.transparent, + ), + ), + ); + } +} + +class EdgeHandle extends StatefulWidget { + final EdgeInsets padding; + final ValueGetter getEdge; + final ValueSetter setEdge; + final VoidCallback onDragStart, onDragEnd; + + const EdgeHandle({ + super.key, + required this.padding, + required this.getEdge, + required this.setEdge, + required this.onDragStart, + required this.onDragEnd, + }); + + @override + State createState() => _EdgeHandleState(); +} + +class _EdgeHandleState extends State { + Rect _start = Rect.zero; + Offset _totalDelta = Offset.zero; + + static const double _handleDim = Cropper.handleDimension; + + EdgeInsets get padding => widget.padding; + + @override + Widget build(BuildContext context) { + var edge = widget.getEdge(); + if (edge.width > _handleDim && edge.height == 0) { + // horizontal edge + edge = Rect.fromLTWH(edge.left + _handleDim / 2, edge.top - _handleDim / 2, edge.width - _handleDim, _handleDim); + } else if (edge.height > _handleDim && edge.width == 0) { + // vertical edge + edge = Rect.fromLTWH(edge.left - _handleDim / 2, edge.top + _handleDim / 2, _handleDim, edge.height - _handleDim); + } + edge = edge.translate(padding.left, padding.right); + + return Positioned.fromRect( + rect: edge, + child: GestureDetector( + onPanStart: (details) { + _totalDelta = Offset.zero; + _start = widget.getEdge(); + widget.onDragStart(); + }, + onPanUpdate: (details) { + _totalDelta += details.delta; + widget.setEdge(Rect.fromLTWH(_start.left + _totalDelta.dx, _start.top + _totalDelta.dy, _start.width, _start.height)); + }, + onPanEnd: (details) { + widget.onDragEnd(); + }, + child: const ColoredBox( + color: Colors.transparent, + ), + ), + ); + } +} diff --git a/lib/widgets/editor/transform/painter.dart b/lib/widgets/editor/transform/painter.dart new file mode 100644 index 000000000..90f82433c --- /dev/null +++ b/lib/widgets/editor/transform/painter.dart @@ -0,0 +1,131 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +class CropperPainter extends CustomPainter { + final Rect rect; + final double gridOpacity; + final int gridDivision; + + const CropperPainter({ + required this.rect, + required this.gridOpacity, + required this.gridDivision, + }); + + static const double handleLength = kMinInteractiveDimension / 3 - 4; + static const double handleWidth = 3; + static const double borderWidth = 1; + static const double gridWidth = 1; + + static const cornerColor = Colors.white; + static final borderColor = Colors.white.withOpacity(.5); + static final gridColor = Colors.white.withOpacity(.5); + + @override + void paint(Canvas canvas, Size size) { + final cornerPaint = Paint() + ..style = PaintingStyle.fill + ..strokeCap = StrokeCap.round + ..strokeWidth = handleWidth + ..color = cornerColor; + final gridPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = gridWidth + ..color = gridColor.withOpacity(gridColor.opacity * gridOpacity); + + final xLeft = rect.left; + final yTop = rect.top; + final xRight = rect.right; + final yBottom = rect.bottom; + + final gridLeft = xLeft + borderWidth / 2; + final gridRight = xRight - borderWidth / 2; + final yStep = (yBottom - yTop) / gridDivision; + for (var i = 1; i < gridDivision; i++) { + canvas.drawLine( + Offset(gridLeft, yTop + i * yStep), + Offset(gridRight, yTop + i * yStep), + gridPaint, + ); + } + final gridTop = yTop + borderWidth / 2; + final gridBottom = yBottom - borderWidth / 2; + final xStep = (xRight - xLeft) / gridDivision; + for (var i = 1; i < gridDivision; i++) { + canvas.drawLine( + Offset(xLeft + i * xStep, gridTop), + Offset(xLeft + i * xStep, gridBottom), + gridPaint, + ); + } + + canvas.drawPoints( + PointMode.polygon, + [ + rect.topLeft.translate(0, handleLength), + rect.topLeft, + rect.topLeft.translate(handleLength, 0), + ], + cornerPaint); + + canvas.drawPoints( + PointMode.polygon, + [ + rect.topRight.translate(-handleLength, 0), + rect.topRight, + rect.topRight.translate(0, handleLength), + ], + cornerPaint); + + canvas.drawPoints( + PointMode.polygon, + [ + rect.bottomRight.translate(0, -handleLength), + rect.bottomRight, + rect.bottomRight.translate(-handleLength, 0), + ], + cornerPaint); + + canvas.drawPoints( + PointMode.polygon, + [ + rect.bottomLeft.translate(handleLength, 0), + rect.bottomLeft, + rect.bottomLeft.translate(0, -handleLength), + ], + cornerPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} + +class ScrimPainter extends CustomPainter { + final Path excludePath; + final double opacity; + + const ScrimPainter({ + required this.excludePath, + required this.opacity, + }); + + static const double borderWidth = 1; + + static const scrimColor = Colors.black; + + @override + void paint(Canvas canvas, Size size) { + final scrimPaint = Paint() + ..style = PaintingStyle.fill + ..color = scrimColor.withOpacity(opacity); + + final outside = Path() + ..addRect(Rect.fromLTWH(0, 0, size.width, size.height).inflate(.5)) + ..close(); + canvas.drawPath(Path.combine(PathOperation.difference, outside, excludePath), scrimPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} diff --git a/lib/widgets/editor/transform/transformation.dart b/lib/widgets/editor/transform/transformation.dart new file mode 100644 index 000000000..455b62cb6 --- /dev/null +++ b/lib/widgets/editor/transform/transformation.dart @@ -0,0 +1,87 @@ +import 'dart:math' as math; + +import 'package:aves/widgets/editor/transform/crop_region.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:vector_math/vector_math_64.dart'; + +@immutable +class Transformation extends Equatable { + final TransformOrientation orientation; + final double straightenDegrees; + final CropRegion region; + + @override + List get props => [orientation, straightenDegrees, region]; + + static const zero = Transformation( + orientation: TransformOrientation.normal, + straightenDegrees: 0, + region: CropRegion.zero, + ); + + const Transformation({ + required this.orientation, + required this.straightenDegrees, + required this.region, + }); + + Transformation copyWith({ + TransformOrientation? orientation, + double? straightenDegrees, + CropRegion? region, + }) { + return Transformation( + orientation: orientation ?? this.orientation, + straightenDegrees: straightenDegrees ?? this.straightenDegrees, + region: region ?? this.region, + ); + } + + Matrix4 get matrix => _orientationMatrix..multiply(_straightenMatrix); + + Matrix4 get _orientationMatrix { + final matrix = Matrix4.identity(); + switch (orientation) { + case TransformOrientation.normal: + break; + case TransformOrientation.rotate90: + matrix.rotateZ(math.pi / 2); + break; + case TransformOrientation.rotate180: + matrix.rotateZ(math.pi); + break; + case TransformOrientation.rotate270: + matrix.rotateZ(3 * math.pi / 2); + break; + case TransformOrientation.transverse: + matrix.scale(-1.0, 1.0, 1.0); + matrix.rotateZ(-3 * math.pi / 2); + break; + case TransformOrientation.flipVertical: + matrix.scale(1.0, -1.0, 1.0); + break; + case TransformOrientation.transpose: + matrix.scale(-1.0, 1.0, 1.0); + matrix.rotateZ(-1 * math.pi / 2); + break; + case TransformOrientation.flipHorizontal: + matrix.scale(-1.0, 1.0, 1.0); + break; + } + return matrix; + } + + Matrix4 get _straightenMatrix => Matrix4.rotationZ(degToRadian((orientation.isFlipped ? -1 : 1) * straightenDegrees)); +} + +@immutable +class TransformEvent { + final TransformActivity activity; + + const TransformEvent({ + required this.activity, + }); +} diff --git a/lib/widgets/filter_grids/common/filter_tile.dart b/lib/widgets/filter_grids/common/filter_tile.dart index b9b1b64f4..fbb72546e 100644 --- a/lib/widgets/filter_grids/common/filter_tile.dart +++ b/lib/widgets/filter_grids/common/filter_tile.dart @@ -72,6 +72,7 @@ class _InteractiveFilterTileState extends State { String? _initialRouteName, _initialSearchQuery; Set? _initialFilters; - static const actionPickItems = 'pick_items'; - static const actionPickCollectionFilters = 'pick_collection_filters'; - static const actionScreenSaver = 'screen_saver'; - static const actionScreenSaverSettings = 'screen_saver_settings'; - static const actionSearch = 'search'; - static const actionSetWallpaper = 'set_wallpaper'; - static const actionView = 'view'; - static const actionWidgetOpen = 'widget_open'; - static const actionWidgetSettings = 'widget_settings'; - - static const intentDataKeyAction = 'action'; - static const intentDataKeyAllowMultiple = 'allowMultiple'; - static const intentDataKeyFilters = 'filters'; - static const intentDataKeyMimeType = 'mimeType'; - static const intentDataKeyPage = 'page'; - static const intentDataKeyQuery = 'query'; - static const intentDataKeySafeMode = 'safeMode'; - static const intentDataKeyUri = 'uri'; - static const intentDataKeyWidgetId = 'widgetId'; - static const allowedShortcutRoutes = [ CollectionPage.routeName, AlbumListPage.routeName, @@ -104,22 +86,27 @@ class _HomePageState extends State { var appMode = AppMode.main; final intentData = widget.intentData ?? await IntentService.getIntentData(); - final safeMode = intentData[intentDataKeySafeMode] ?? false; - final intentAction = intentData[intentDataKeyAction]; + final safeMode = intentData[IntentDataKeys.safeMode] ?? false; + final intentAction = intentData[IntentDataKeys.action]; _initialFilters = null; await androidFileUtils.init(); - if (!{actionScreenSaver, actionSetWallpaper}.contains(intentAction) && settings.isInstalledAppAccessAllowed) { + if (!{ + IntentActions.edit, + IntentActions.screenSaver, + IntentActions.setWallpaper, + }.contains(intentAction) && + settings.isInstalledAppAccessAllowed) { unawaited(appInventory.initAppNames()); } if (intentData.isNotEmpty) { await reportService.log('Intent data=$intentData'); switch (intentAction) { - case actionView: - case actionWidgetOpen: + case IntentActions.view: + case IntentActions.widgetOpen: String? uri, mimeType; - final widgetId = intentData[intentDataKeyWidgetId]; + final widgetId = intentData[IntentDataKeys.widgetId]; if (widgetId != null) { // widget settings may be modified in a different process after channel setup await settings.reload(); @@ -134,8 +121,8 @@ class _HomePageState extends State { } unawaited(WidgetService.update(widgetId)); } else { - uri = intentData[intentDataKeyUri]; - mimeType = intentData[intentDataKeyMimeType]; + uri = intentData[IntentDataKeys.uri]; + mimeType = intentData[IntentDataKeys.mimeType]; } if (uri != null) { _viewerEntry = await _initViewerEntry( @@ -146,41 +133,51 @@ class _HomePageState extends State { appMode = AppMode.view; } } - case actionPickItems: + case IntentActions.edit: + _viewerEntry = await _initViewerEntry( + uri: intentData[IntentDataKeys.uri], + mimeType: intentData[IntentDataKeys.mimeType], + ); + if (_viewerEntry != null) { + appMode = AppMode.edit; + } + case IntentActions.setWallpaper: + _viewerEntry = await _initViewerEntry( + uri: intentData[IntentDataKeys.uri], + mimeType: intentData[IntentDataKeys.mimeType], + ); + if (_viewerEntry != null) { + appMode = AppMode.setWallpaper; + } + case IntentActions.pickItems: // TODO TLAD apply pick mimetype(s) // some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?) - String? pickMimeTypes = intentData[intentDataKeyMimeType]; - final multiple = intentData[intentDataKeyAllowMultiple] ?? false; + String? pickMimeTypes = intentData[IntentDataKeys.mimeType]; + final multiple = intentData[IntentDataKeys.allowMultiple] ?? false; debugPrint('pick mimeType=$pickMimeTypes multiple=$multiple'); appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal; - case actionPickCollectionFilters: + case IntentActions.pickCollectionFilters: appMode = AppMode.pickCollectionFiltersExternal; - case actionScreenSaver: + case IntentActions.screenSaver: appMode = AppMode.screenSaver; _initialRouteName = ScreenSaverPage.routeName; - case actionScreenSaverSettings: + case IntentActions.screenSaverSettings: _initialRouteName = ScreenSaverSettingsPage.routeName; - case actionSearch: + case IntentActions.search: _initialRouteName = SearchPage.routeName; - _initialSearchQuery = intentData[intentDataKeyQuery]; - case actionSetWallpaper: - appMode = AppMode.setWallpaper; - _viewerEntry = await _initViewerEntry( - uri: intentData[intentDataKeyUri], - mimeType: intentData[intentDataKeyMimeType], - ); - case actionWidgetSettings: + _initialSearchQuery = intentData[IntentDataKeys.query]; + case IntentActions.widgetSettings: _initialRouteName = HomeWidgetSettingsPage.routeName; - _widgetId = intentData[intentDataKeyWidgetId] ?? 0; + _widgetId = intentData[IntentDataKeys.widgetId] ?? 0; default: // do not use 'route' as extra key, as the Flutter framework acts on it - final extraRoute = intentData[intentDataKeyPage]; + final extraRoute = intentData[IntentDataKeys.page]; if (allowedShortcutRoutes.contains(extraRoute)) { _initialRouteName = extraRoute; } } if (_initialFilters == null) { - final extraFilters = intentData[intentDataKeyFilters]; + final extraFilters = intentData[IntentDataKeys.filters]; _initialFilters = extraFilters != null ? (extraFilters as List).cast().map(CollectionFilter.fromJson).whereNotNull().toSet() : null; } } @@ -219,6 +216,7 @@ class _HomePageState extends State { } else { await _initViewerEssentials(); } + case AppMode.edit: case AppMode.setWallpaper: await _initViewerEssentials(); case AppMode.pickMediaInternal: @@ -258,75 +256,85 @@ class _HomePageState extends State { } Future _getRedirectRoute(AppMode appMode) async { - if (appMode == AppMode.setWallpaper) { - return DirectMaterialPageRoute( - settings: const RouteSettings(name: WallpaperPage.routeName), - builder: (_) { - return WallpaperPage( - entry: _viewerEntry, - ); - }, - ); - } - - if (appMode == AppMode.view) { - AvesEntry viewerEntry = _viewerEntry!; - CollectionLens? collection; - - final source = context.read(); - if (source.initState != SourceInitializationState.none) { - final album = viewerEntry.directory; - if (album != null) { - // wait for collection to pass the `loading` state - final completer = Completer(); - void _onSourceStateChanged() { - if (source.state != SourceState.loading) { - source.stateNotifier.removeListener(_onSourceStateChanged); - completer.complete(); - } - } - - source.stateNotifier.addListener(_onSourceStateChanged); - await completer.future; - - collection = CollectionLens( - source: source, - filters: {AlbumFilter(album, source.getAlbumDisplayName(context, album))}, - listenToSource: false, - // if we group bursts, opening a burst sub-entry should: - // - identify and select the containing main entry, - // - select the sub-entry in the Viewer page. - groupBursts: false, - ); - final viewerEntryPath = viewerEntry.path; - final collectionEntry = collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath); - if (collectionEntry != null) { - viewerEntry = collectionEntry; - } else { - debugPrint('collection does not contain viewerEntry=$viewerEntry'); - collection = null; - } - } - } - - return DirectMaterialPageRoute( - settings: const RouteSettings(name: EntryViewerPage.routeName), - builder: (_) { - return EntryViewerPage( - collection: collection, - initialEntry: viewerEntry, - ); - }, - ); - } - String routeName; Set? filters; switch (appMode) { case AppMode.pickSingleMediaExternal: case AppMode.pickMultipleMediaExternal: routeName = CollectionPage.routeName; - default: + case AppMode.setWallpaper: + return DirectMaterialPageRoute( + settings: const RouteSettings(name: WallpaperPage.routeName), + builder: (_) { + return WallpaperPage( + entry: _viewerEntry, + ); + }, + ); + case AppMode.view: + AvesEntry viewerEntry = _viewerEntry!; + CollectionLens? collection; + + final source = context.read(); + if (source.initState != SourceInitializationState.none) { + final album = viewerEntry.directory; + if (album != null) { + // wait for collection to pass the `loading` state + final completer = Completer(); + void _onSourceStateChanged() { + if (source.state != SourceState.loading) { + source.stateNotifier.removeListener(_onSourceStateChanged); + completer.complete(); + } + } + + source.stateNotifier.addListener(_onSourceStateChanged); + await completer.future; + + collection = CollectionLens( + source: source, + filters: {AlbumFilter(album, source.getAlbumDisplayName(context, album))}, + listenToSource: false, + // if we group bursts, opening a burst sub-entry should: + // - identify and select the containing main entry, + // - select the sub-entry in the Viewer page. + groupBursts: false, + ); + final viewerEntryPath = viewerEntry.path; + final collectionEntry = collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath); + if (collectionEntry != null) { + viewerEntry = collectionEntry; + } else { + debugPrint('collection does not contain viewerEntry=$viewerEntry'); + collection = null; + } + } + } + + return DirectMaterialPageRoute( + settings: const RouteSettings(name: EntryViewerPage.routeName), + builder: (_) { + return EntryViewerPage( + collection: collection, + initialEntry: viewerEntry, + ); + }, + ); + case AppMode.edit: + return DirectMaterialPageRoute( + settings: const RouteSettings(name: EntryViewerPage.routeName), + builder: (_) { + return ImageEditorPage( + entry: _viewerEntry!, + ); + }, + ); + case AppMode.main: + case AppMode.pickCollectionFiltersExternal: + case AppMode.pickMediaInternal: + case AppMode.pickFilterInternal: + case AppMode.screenSaver: + case AppMode.slideshow: routeName = _initialRouteName ?? settings.homePage.routeName; filters = _initialFilters ?? {}; } diff --git a/lib/widgets/home_widget.dart b/lib/widgets/home_widget.dart index 1e81d6e77..f01bce2ce 100644 --- a/lib/widgets/home_widget.dart +++ b/lib/widgets/home_widget.dart @@ -42,7 +42,7 @@ class HomeWidgetPainter { } final recorder = ui.PictureRecorder(); - final rect = Rect.fromLTWH(0, 0, widgetSizePx.width, widgetSizePx.height); + final rect = Offset.zero & widgetSizePx; final canvas = Canvas(recorder, rect); final path = shape.path(widgetSizePx, devicePixelRatio); canvas.clipPath(path); diff --git a/lib/widgets/intent.dart b/lib/widgets/intent.dart new file mode 100644 index 000000000..639cd5526 --- /dev/null +++ b/lib/widgets/intent.dart @@ -0,0 +1,24 @@ +class IntentActions { + static const edit = 'edit'; + static const pickItems = 'pick_items'; + static const pickCollectionFilters = 'pick_collection_filters'; + static const screenSaver = 'screen_saver'; + static const screenSaverSettings = 'screen_saver_settings'; + static const search = 'search'; + static const setWallpaper = 'set_wallpaper'; + static const view = 'view'; + static const widgetOpen = 'widget_open'; + static const widgetSettings = 'widget_settings'; +} + +class IntentDataKeys { + static const action = 'action'; + static const allowMultiple = 'allowMultiple'; + static const filters = 'filters'; + static const mimeType = 'mimeType'; + static const page = 'page'; + static const query = 'query'; + static const safeMode = 'safeMode'; + static const uri = 'uri'; + static const widgetId = 'widgetId'; +} diff --git a/lib/widgets/viewer/overlay/minimap.dart b/lib/widgets/viewer/overlay/minimap.dart index b3f5de899..59ca726f8 100644 --- a/lib/widgets/viewer/overlay/minimap.dart +++ b/lib/widgets/viewer/overlay/minimap.dart @@ -1,8 +1,12 @@ import 'dart:math'; -import 'package:aves/widgets/viewer/visual/state.dart'; +import 'package:aves/model/view_state.dart'; +import 'package:aves/widgets/editor/transform/controller.dart'; +import 'package:aves/widgets/editor/transform/transformation.dart'; +import 'package:aves_utils/aves_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class Minimap extends StatelessWidget { final ValueNotifier viewStateNotifier; @@ -23,16 +27,22 @@ class Minimap extends StatelessWidget { final viewportSize = viewState.viewportSize; final contentSize = viewState.contentSize; if (viewportSize == null || contentSize == null) return const SizedBox(); - return CustomPaint( - painter: MinimapPainter( - viewportSize: viewportSize, - contentSize: contentSize, - viewCenterOffset: viewState.position, - viewScale: viewState.scale!, - minimapBorderColor: Colors.white30, - ), - size: minimapSize, - ); + return StreamBuilder( + stream: context.select>((v) => v?.transformationStream ?? Stream.value(null)), + builder: (context, snapshot) { + final transformation = snapshot.data; + return CustomPaint( + painter: MinimapPainter( + viewportSize: viewportSize, + contentSize: contentSize, + viewCenterOffset: viewState.position, + viewScale: viewState.scale!, + transformation: transformation, + minimapBorderColor: Colors.white30, + ), + size: minimapSize, + ); + }); }, ), ); @@ -43,16 +53,30 @@ class MinimapPainter extends CustomPainter { final Size contentSize, viewportSize; final Offset viewCenterOffset; final double viewScale; + final Transformation? transformation; final Color minimapBorderColor, viewportBorderColor; - const MinimapPainter({ + late final Paint fill, minimapStroke, viewportStroke; + + MinimapPainter({ required this.viewportSize, required this.contentSize, required this.viewCenterOffset, required this.viewScale, + this.transformation, this.minimapBorderColor = Colors.white, this.viewportBorderColor = Colors.white, - }); + }) { + fill = Paint() + ..style = PaintingStyle.fill + ..color = const Color(0x33000000); + minimapStroke = Paint() + ..style = PaintingStyle.stroke + ..color = minimapBorderColor; + viewportStroke = Paint() + ..style = PaintingStyle.stroke + ..color = viewportBorderColor; + } @override void paint(Canvas canvas, Size size) { @@ -64,37 +88,56 @@ class MinimapPainter extends CustomPainter { // hide minimap when image is in full view if (viewportSize + const Offset(precisionErrorTolerance, precisionErrorTolerance) >= viewSize) return; + final canvasCenter = size.center(Offset.zero); final canvasScale = size.longestSide / viewSize.longestSide; final scaledContentSize = viewSize * canvasScale; final scaledViewportSize = viewportSize * canvasScale; final contentRect = Rect.fromCenter( - center: size.center(Offset.zero), + center: canvasCenter, width: scaledContentSize.width, height: scaledContentSize.height, ); final viewportRect = Rect.fromCenter( - center: size.center(Offset.zero) - viewCenterOffset * canvasScale, + center: canvasCenter - viewCenterOffset * canvasScale, width: min(scaledContentSize.width, scaledViewportSize.width), height: min(scaledContentSize.height, scaledViewportSize.height), ); - canvas.translate((contentRect.width - size.width) / 2, (contentRect.height - size.height) / 2); + Matrix4? transformMatrix; + if (transformation != null) { + final viewportCenter = viewportRect.center; + final transformOrigin = viewportCenter; + transformMatrix = Matrix4.identity() + ..translate(transformOrigin.dx, transformOrigin.dy) + ..multiply(transformation!.matrix) + ..translate(-transformOrigin.dx, -transformOrigin.dy); + final transViewportCenter = transformMatrix.transformOffset(viewportCenter); + final transContentCenter = transformMatrix.transformOffset(contentRect.center); - final fill = Paint() - ..style = PaintingStyle.fill - ..color = const Color(0x33000000); - final minimapStroke = Paint() - ..style = PaintingStyle.stroke - ..color = minimapBorderColor; - final viewportStroke = Paint() - ..style = PaintingStyle.stroke - ..color = viewportBorderColor; + final minimapTranslation = size / 2 + (transViewportCenter - transContentCenter - viewportCenter); + canvas.translate(minimapTranslation.width, minimapTranslation.height); + } else { + canvas.translate((contentRect.width - size.width) / 2, (contentRect.height - size.height) / 2); + } canvas.drawRect(viewportRect, fill); + + if (transformMatrix != null) { + canvas.transform(transformMatrix.storage); + _drawContentRect(canvas, contentRect); + transformMatrix.invert(); + canvas.transform(transformMatrix.storage); + } else { + _drawContentRect(canvas, contentRect); + } + + canvas.drawRect(viewportRect, viewportStroke); + } + + void _drawContentRect(Canvas canvas, Rect contentRect) { canvas.drawRect(contentRect, fill); canvas.drawRect(contentRect, minimapStroke); - canvas.drawRect(viewportRect, viewportStroke); } @override diff --git a/lib/widgets/viewer/overlay/wallpaper_buttons.dart b/lib/widgets/viewer/overlay/wallpaper_buttons.dart index fdec17399..f7e70483a 100644 --- a/lib/widgets/viewer/overlay/wallpaper_buttons.dart +++ b/lib/widgets/viewer/overlay/wallpaper_buttons.dart @@ -103,7 +103,7 @@ class WallpaperButtons extends StatelessWidget with FeedbackMixin { final center = (contentSize / 2 - viewState.position / scale) as Size; final regionSize = viewportSize / scale; final regionTopLeft = (center - regionSize / 2) as Offset; - return Rect.fromLTWH(regionTopLeft.dx, regionTopLeft.dy, regionSize.width, regionSize.height); + return regionTopLeft & regionSize; } Future _getBytes(BuildContext context, Rect displayRegion) async { diff --git a/lib/widgets/viewer/visual/conductor.dart b/lib/widgets/viewer/visual/conductor.dart index 9f07652e2..26f6a3b96 100644 --- a/lib/widgets/viewer/visual/conductor.dart +++ b/lib/widgets/viewer/visual/conductor.dart @@ -1,5 +1,5 @@ import 'package:aves/model/entry/entry.dart'; -import 'package:aves/widgets/viewer/visual/state.dart'; +import 'package:aves/model/view_state.dart'; import 'package:aves_magnifier/aves_magnifier.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -35,7 +35,7 @@ class ViewStateConductor { maxScale: initialScale, initialScale: initialScale, viewportSize: _viewportSize, - childSize: entry.displaySize, + contentSize: entry.displaySize, ).initialScale, viewportSize: _viewportSize, contentSize: entry.displaySize, diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index fd1cfe61f..12b216852 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -18,7 +18,7 @@ import 'package:aves/widgets/viewer/video/conductor.dart'; import 'package:aves/widgets/viewer/visual/conductor.dart'; import 'package:aves/widgets/viewer/visual/error.dart'; import 'package:aves/widgets/viewer/visual/raster.dart'; -import 'package:aves/widgets/viewer/visual/state.dart'; +import 'package:aves/model/view_state.dart'; import 'package:aves/widgets/viewer/visual/vector.dart'; import 'package:aves/widgets/viewer/visual/video/cover.dart'; import 'package:aves/widgets/viewer/visual/video/subtitle/subtitle.dart'; @@ -37,6 +37,8 @@ class EntryPageView extends StatefulWidget { final VoidCallback? onDisposed; static const decorationCheckSize = 20.0; + static const rasterMaxScale = ScaleLevel(factor: 5); + static const vectorMaxScale = ScaleLevel(factor: 25); const EntryPageView({ super.key, @@ -63,9 +65,6 @@ class _EntryPageViewState extends State with SingleTickerProvider ViewerController get viewerController => widget.viewerController; - static const rasterMaxScale = ScaleLevel(factor: 5); - static const vectorMaxScale = ScaleLevel(factor: 25); - @override void initState() { super.initState(); @@ -180,7 +179,7 @@ class _EntryPageViewState extends State with SingleTickerProvider Widget _buildSvgView() { return _buildMagnifier( - maxScale: vectorMaxScale, + maxScale: EntryPageView.vectorMaxScale, scaleStateCycle: _vectorScaleStateCycle, applyScale: false, child: VectorImageView( @@ -382,7 +381,7 @@ class _EntryPageViewState extends State with SingleTickerProvider Widget _buildMagnifier({ AvesMagnifierController? controller, Size? displaySize, - ScaleLevel maxScale = rasterMaxScale, + ScaleLevel maxScale = EntryPageView.rasterMaxScale, ScaleStateCycle scaleStateCycle = defaultScaleStateCycle, bool applyScale = true, MagnifierGestureScaleStartCallback? onScaleStart, @@ -398,7 +397,7 @@ class _EntryPageViewState extends State with SingleTickerProvider // key includes modified date to refresh when the image is modified by metadata (e.g. rotated) key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedSecs}'), controller: controller ?? _magnifierController, - childSize: displaySize ?? entry.displaySize, + contentSize: displaySize ?? entry.displaySize, allowOriginalScaleBeyondRange: !isWallpaperMode, minScale: minScale, maxScale: maxScale, @@ -477,7 +476,7 @@ class _EntryPageViewState extends State with SingleTickerProvider void _onViewScaleBoundariesChanged(ScaleBoundaries v) { _viewStateNotifier.value = _viewStateNotifier.value.copyWith( viewportSize: v.viewportSize, - contentSize: v.childSize, + contentSize: v.contentSize, ); } diff --git a/lib/widgets/viewer/visual/raster.dart b/lib/widgets/viewer/visual/raster.dart index 40704ae49..0386b9128 100644 --- a/lib/widgets/viewer/visual/raster.dart +++ b/lib/widgets/viewer/visual/raster.dart @@ -8,7 +8,7 @@ import 'package:aves/model/settings/enums/entry_background.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/fx/checkered_decoration.dart'; import 'package:aves/widgets/viewer/visual/entry_page_view.dart'; -import 'package:aves/widgets/viewer/visual/state.dart'; +import 'package:aves/model/view_state.dart'; import 'package:aves_model/aves_model.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/viewer/visual/vector.dart b/lib/widgets/viewer/visual/vector.dart index 19503373b..c1502a7d9 100644 --- a/lib/widgets/viewer/visual/vector.dart +++ b/lib/widgets/viewer/visual/vector.dart @@ -8,7 +8,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/utils/math_utils.dart'; import 'package:aves/widgets/common/fx/checkered_decoration.dart'; import 'package:aves/widgets/viewer/visual/entry_page_view.dart'; -import 'package:aves/widgets/viewer/visual/state.dart'; +import 'package:aves/model/view_state.dart'; import 'package:aves_model/aves_model.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/viewer/visual/video/cover.dart b/lib/widgets/viewer/visual/video/cover.dart index d3438159d..5017a4a3f 100644 --- a/lib/widgets/viewer/visual/video/cover.dart +++ b/lib/widgets/viewer/visual/video/cover.dart @@ -115,7 +115,7 @@ class _VideoCoverState extends State { if (boundaries != null) { magnifierController.setScaleBoundaries( boundaries.copyWith( - childSize: videoDisplaySize, + contentSize: videoDisplaySize, ), ); } diff --git a/lib/widgets/viewer/visual/video/subtitle/subtitle.dart b/lib/widgets/viewer/visual/video/subtitle/subtitle.dart index bbf86b845..131012944 100644 --- a/lib/widgets/viewer/visual/video/subtitle/subtitle.dart +++ b/lib/widgets/viewer/visual/video/subtitle/subtitle.dart @@ -2,9 +2,9 @@ import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/settings/enums/subtitle_position.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/view_state.dart'; import 'package:aves/widgets/common/basic/text/background_painter.dart'; import 'package:aves/widgets/common/basic/text/outlined.dart'; -import 'package:aves/widgets/viewer/visual/state.dart'; import 'package:aves/widgets/viewer/visual/video/subtitle/ass_parser.dart'; import 'package:aves/widgets/viewer/visual/video/subtitle/span.dart'; import 'package:aves/widgets/viewer/visual/video/subtitle/style.dart'; diff --git a/plugins/aves_magnifier/lib/aves_magnifier.dart b/plugins/aves_magnifier/lib/aves_magnifier.dart index dae50972d..7a387be0a 100644 --- a/plugins/aves_magnifier/lib/aves_magnifier.dart +++ b/plugins/aves_magnifier/lib/aves_magnifier.dart @@ -2,8 +2,8 @@ library aves_magnifier; export 'src/controller/controller.dart'; export 'src/controller/state.dart'; +export 'src/core/core.dart'; export 'src/core/scale_gesture_recognizer.dart'; -export 'src/magnifier.dart'; export 'src/pan/gesture_detector_scope.dart'; export 'src/pan/scroll_physics.dart'; export 'src/scale/scale_boundaries.dart'; diff --git a/plugins/aves_magnifier/lib/src/controller/controller.dart b/plugins/aves_magnifier/lib/src/controller/controller.dart index 89858643f..e57d35c10 100644 --- a/plugins/aves_magnifier/lib/src/controller/controller.dart +++ b/plugins/aves_magnifier/lib/src/controller/controller.dart @@ -116,17 +116,15 @@ class AvesMagnifierController { final boundaries = scaleBoundaries; if (boundaries == null) return null; - double _clamp(double scale) => scale.clamp(boundaries.minScale, boundaries.maxScale); - switch (scaleState) { case ScaleState.initial: case ScaleState.zoomedIn: case ScaleState.zoomedOut: - return _clamp(boundaries.initialScale); + return boundaries.clampScale(boundaries.initialScale); case ScaleState.covering: - return _clamp(ScaleLevel.scaleForCovering(boundaries.viewportSize, boundaries.childSize)); + return boundaries.clampScale(ScaleLevel.scaleForCovering(boundaries.viewportSize, boundaries.contentSize)); case ScaleState.originalSize: - return _clamp(boundaries.originalScale); + return boundaries.clampScale(boundaries.originalScale); default: return null; } diff --git a/plugins/aves_magnifier/lib/src/controller/controller_delegate.dart b/plugins/aves_magnifier/lib/src/controller/controller_delegate.dart index 21b373790..26955113c 100644 --- a/plugins/aves_magnifier/lib/src/controller/controller_delegate.dart +++ b/plugins/aves_magnifier/lib/src/controller/controller_delegate.dart @@ -10,14 +10,14 @@ import 'package:flutter/widgets.dart'; /// A class to hold internal layout logic to sync both controller states /// /// It reacts to layout changes (eg: enter landscape or widget resize) and syncs the two controllers. -mixin AvesMagnifierControllerDelegate on State { +mixin AvesMagnifierControllerDelegate on State { AvesMagnifierController get controller => widget.controller; ScaleBoundaries? get scaleBoundaries => controller.scaleBoundaries; ScaleStateCycle get scaleStateCycle => widget.scaleStateCycle; - Alignment get basePosition => Alignment.center; + Alignment get basePosition => ScaleBoundaries.basePosition; Function(double? prevScale, double? nextScale, Offset nextPosition)? _animateScale; @@ -26,12 +26,12 @@ mixin AvesMagnifierControllerDelegate on State { final List _subscriptions = []; - void registerDelegate(MagnifierCore widget) { + void registerDelegate(AvesMagnifier widget) { _subscriptions.add(widget.controller.stateStream.listen(_onMagnifierStateChange)); _subscriptions.add(widget.controller.scaleStateChangeStream.listen(_onScaleStateChange)); } - void unregisterDelegate(MagnifierCore oldWidget) { + void unregisterDelegate(AvesMagnifier oldWidget) { _animateScale = null; _subscriptions ..forEach((sub) => sub.cancel()) @@ -54,7 +54,7 @@ mixin AvesMagnifierControllerDelegate on State { final childFocalPoint = scaleStateChange.childFocalPoint; final boundaries = scaleBoundaries; if (childFocalPoint != null && boundaries != null) { - nextPosition = boundaries.childToStatePosition(nextScale!, childFocalPoint); + nextPosition = boundaries.contentToStatePosition(nextScale!, childFocalPoint); } } @@ -70,7 +70,7 @@ mixin AvesMagnifierControllerDelegate on State { final boundaries = scaleBoundaries; if (boundaries == null) return; - controller.update(position: clampPosition(), source: state.source); + controller.update(position: boundaries.clampPosition(position: position, scale: scale!), source: state.source); if (controller.scale == controller.previousState.scale) return; if (state.source == ChangeSource.internal || state.source == ChangeSource.animation) return; @@ -100,14 +100,6 @@ mixin AvesMagnifierControllerDelegate on State { void setScale(double? scale, ChangeSource source) => controller.update(scale: scale, source: source); - void updateMultiple({ - required Offset position, - required double scale, - required ChangeSource source, - }) { - controller.update(position: position, scale: scale, source: source); - } - void updateScaleStateFromNewScale(double newScale, ChangeSource source) { final boundaries = scaleBoundaries; if (boundaries == null) return; @@ -142,74 +134,4 @@ mixin AvesMagnifierControllerDelegate on State { if (originalScale == nextScale) return; controller.setScaleState(nextScaleState, source, childFocalPoint: childFocalPoint); } - - EdgeRange getXEdges({double? scale}) { - final boundaries = scaleBoundaries; - if (boundaries == null) return const EdgeRange(0, 0); - - final _scale = scale ?? this.scale!; - - final computedWidth = boundaries.childSize.width * _scale; - final screenWidth = boundaries.viewportSize.width; - - final positionX = basePosition.x; - final widthDiff = computedWidth - screenWidth; - - final minX = ((positionX - 1).abs() / 2) * widthDiff * -1; - final maxX = ((positionX + 1).abs() / 2) * widthDiff; - return EdgeRange(minX, maxX); - } - - EdgeRange getYEdges({double? scale}) { - final boundaries = scaleBoundaries; - if (boundaries == null) return const EdgeRange(0, 0); - - final _scale = scale ?? this.scale!; - - final computedHeight = boundaries.childSize.height * _scale; - final screenHeight = boundaries.viewportSize.height; - - final positionY = basePosition.y; - final heightDiff = computedHeight - screenHeight; - - final minY = ((positionY - 1).abs() / 2) * heightDiff * -1; - final maxY = ((positionY + 1).abs() / 2) * heightDiff; - return EdgeRange(minY, maxY); - } - - Offset clampPosition({Offset? position, double? scale}) { - final boundaries = scaleBoundaries; - if (boundaries == null) return Offset.zero; - - final _scale = scale ?? this.scale!; - final _position = position ?? this.position; - - final computedWidth = boundaries.childSize.width * _scale; - final computedHeight = boundaries.childSize.height * _scale; - - final screenWidth = boundaries.viewportSize.width; - final screenHeight = boundaries.viewportSize.height; - - var finalX = 0.0; - if (screenWidth < computedWidth) { - final range = getXEdges(scale: _scale); - finalX = _position.dx.clamp(range.min, range.max); - } - - var finalY = 0.0; - if (screenHeight < computedHeight) { - final range = getYEdges(scale: _scale); - finalY = _position.dy.clamp(range.min, range.max); - } - - return Offset(finalX, finalY); - } -} - -/// Simple class to store a min and a max value -class EdgeRange { - const EdgeRange(this.min, this.max); - - final double min; - final double max; } diff --git a/plugins/aves_magnifier/lib/src/controller/range.dart b/plugins/aves_magnifier/lib/src/controller/range.dart new file mode 100644 index 000000000..116ceef0d --- /dev/null +++ b/plugins/aves_magnifier/lib/src/controller/range.dart @@ -0,0 +1,15 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class EdgeRange extends Equatable { + final double min; + final double max; + + @override + List get props => [min, max]; + + const EdgeRange(this.min, this.max); + + static const EdgeRange zero = EdgeRange(0, 0); +} diff --git a/plugins/aves_magnifier/lib/src/core/core.dart b/plugins/aves_magnifier/lib/src/core/core.dart index d2f95bad9..53cb4da9a 100644 --- a/plugins/aves_magnifier/lib/src/core/core.dart +++ b/plugins/aves_magnifier/lib/src/core/core.dart @@ -4,22 +4,52 @@ import 'package:aves_magnifier/src/controller/controller.dart'; import 'package:aves_magnifier/src/controller/controller_delegate.dart'; import 'package:aves_magnifier/src/controller/state.dart'; import 'package:aves_magnifier/src/core/gesture_detector.dart'; -import 'package:aves_magnifier/src/magnifier.dart'; import 'package:aves_magnifier/src/pan/edge_hit_detector.dart'; import 'package:aves_magnifier/src/scale/scale_boundaries.dart'; +import 'package:aves_magnifier/src/scale/scale_level.dart'; import 'package:aves_magnifier/src/scale/state.dart'; +import 'package:aves_utils/aves_utils.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:tuple/tuple.dart'; -/// Internal widget in which controls all animations lifecycle, core responses -/// to user gestures, updates to the controller state and mounts the entire Layout -class MagnifierCore extends StatefulWidget { +/* + adapted from package `photo_view` v0.9.2: + - removed image related aspects to focus on a general purpose pan/scale viewer (à la `InteractiveViewer`) + - removed rotation and many customization parameters + - removed ignorable/ignoring partial notifiers + - formatted, renamed and reorganized + - fixed gesture recognizers when used inside a scrollable widget like `PageView` + - fixed corner hit detection when in containers scrollable in both axes + - fixed corner hit detection issues due to imprecise double comparisons + - added single & double tap position feedback + - fixed focus when scaling by double-tap/pinch + */ +class AvesMagnifier extends StatefulWidget { + static const double defaultPanInertia = .2; + final AvesMagnifierController controller; + final EdgeInsets viewportPadding; + + // The size of the custom [child]. This value is used to compute the relation between the child and the container's size to calculate the scale value. + final Size contentSize; + + final bool allowOriginalScaleBeyondRange; + final bool allowGestureScaleBeyondRange; + final double panInertia; + + // Defines the minimum size in which the image will be allowed to assume, it is proportional to the original image size. + final ScaleLevel minScale; + + // Defines the maximum size in which the image will be allowed to assume, it is proportional to the original image size. + final ScaleLevel maxScale; + + // Defines the size the image will assume when the component is initialized, it is proportional to the original image size. + final ScaleLevel initialScale; + final ScaleStateCycle scaleStateCycle; final bool applyScale; - final double panInertia; final MagnifierGestureScaleStartCallback? onScaleStart; final MagnifierGestureScaleUpdateCallback? onScaleUpdate; final MagnifierGestureScaleEndCallback? onScaleEnd; @@ -28,12 +58,19 @@ class MagnifierCore extends StatefulWidget { final MagnifierDoubleTapCallback? onDoubleTap; final Widget child; - const MagnifierCore({ + const AvesMagnifier({ super.key, required this.controller, - required this.scaleStateCycle, - required this.applyScale, - this.panInertia = .2, + required this.contentSize, + this.viewportPadding = EdgeInsets.zero, + this.allowOriginalScaleBeyondRange = true, + this.allowGestureScaleBeyondRange = true, + this.minScale = const ScaleLevel(factor: .0), + this.maxScale = const ScaleLevel(factor: double.infinity), + this.initialScale = const ScaleLevel(ref: ScaleReference.contained), + this.scaleStateCycle = defaultScaleStateCycle, + this.applyScale = true, + this.panInertia = defaultPanInertia, this.onScaleStart, this.onScaleUpdate, this.onScaleEnd, @@ -44,10 +81,10 @@ class MagnifierCore extends StatefulWidget { }); @override - State createState() => _MagnifierCoreState(); + State createState() => _AvesMagnifierState(); } -class _MagnifierCoreState extends State with TickerProviderStateMixin, AvesMagnifierControllerDelegate, EdgeHitDetector { +class _AvesMagnifierState extends State with TickerProviderStateMixin, AvesMagnifierControllerDelegate, EdgeHitDetector { Offset? _startFocalPoint, _lastViewportFocalPosition; double? _startScale, _quickScaleLastY, _quickScaleLastDistance; late bool _dropped, _doubleTap, _quickScaleMoved; @@ -77,13 +114,23 @@ class _MagnifierCoreState extends State with TickerProviderStateM } @override - void didUpdateWidget(covariant MagnifierCore oldWidget) { + void didUpdateWidget(covariant AvesMagnifier oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { _unregisterWidget(oldWidget); _registerWidget(widget); } + + if (oldWidget.allowOriginalScaleBeyondRange != widget.allowOriginalScaleBeyondRange || oldWidget.minScale != widget.minScale || oldWidget.maxScale != widget.maxScale || oldWidget.initialScale != widget.initialScale || oldWidget.contentSize != widget.contentSize) { + controller.setScaleBoundaries((controller.scaleBoundaries ?? ScaleBoundaries.zero).copyWith( + allowOriginalScaleBeyondRange: widget.allowOriginalScaleBeyondRange, + minScale: widget.minScale, + maxScale: widget.maxScale, + initialScale: widget.initialScale, + contentSize: widget.contentSize.isEmpty == false ? widget.contentSize : null, + )); + } } @override @@ -94,13 +141,13 @@ class _MagnifierCoreState extends State with TickerProviderStateM super.dispose(); } - void _registerWidget(MagnifierCore widget) { + void _registerWidget(AvesMagnifier widget) { registerDelegate(widget); cachedScaleBoundaries = widget.controller.scaleBoundaries; setScaleStateUpdateAnimation(animateOnScaleStateUpdate); } - void _unregisterWidget(MagnifierCore oldWidget) { + void _unregisterWidget(AvesMagnifier oldWidget) { unregisterDelegate(oldWidget); cachedScaleBoundaries = null; } @@ -170,18 +217,21 @@ class _MagnifierCoreState extends State with TickerProviderStateM } else { newScale = _startScale! * details.scale; } + if (!widget.allowGestureScaleBeyondRange) { + newScale = boundaries.clampScale(newScale); + } + newScale = max(0, newScale); final scaleFocalPoint = _doubleTap ? _startFocalPoint! : details.localFocalPoint; final panPositionDelta = scaleFocalPoint - _lastViewportFocalPosition!; final scalePositionDelta = boundaries.viewportToStatePosition(controller, scaleFocalPoint) * (scale! / newScale - 1); - final newPosition = position + panPositionDelta + scalePositionDelta; + final newPosition = boundaries.clampPosition( + position: position + panPositionDelta + scalePositionDelta, + scale: newScale, + ); updateScaleStateFromNewScale(newScale, ChangeSource.gesture); - updateMultiple( - scale: max(0, newScale), - position: newPosition, - source: ChangeSource.gesture, - ); + controller.update(position: newPosition, scale: newScale, source: ChangeSource.gesture); _lastViewportFocalPosition = scaleFocalPoint; } @@ -219,32 +269,40 @@ class _MagnifierCoreState extends State with TickerProviderStateM } } - final _position = controller.position; - final _scale = controller.scale!; - final maxScale = boundaries.maxScale; - final minScale = boundaries.minScale; + final currentPosition = controller.position; + final currentScale = controller.scale!; // animate back to min/max scale if gesture yielded a scale exceeding them - if (_scale > maxScale || _scale < minScale) { - final newScale = _scale.clamp(minScale, maxScale); - final newPosition = clampPosition(position: _position * newScale / _scale, scale: newScale); - animateScale(_scale, newScale); - animatePosition(_position, newPosition); + final newScale = boundaries.clampScale(currentScale); + if (currentScale != newScale) { + final newPosition = boundaries.clampPosition( + position: currentPosition * newScale / currentScale, + scale: newScale, + ); + animateScale(currentScale, newScale); + animatePosition(currentPosition, newPosition); return; } // The gesture recognizer triggers a new `onScaleStart` every time a pointer/finger is added or removed. // Following a pinch-to-zoom gesture, a new panning gesture may start if the user does not lift both fingers at the same time, // so we dismiss such panning gestures when it looks like it followed a scaling gesture. - final isPanning = _scale == _startScale && (DateTime.now().difference(_lastScaleGestureDate)).inMilliseconds > 100; + final isPanning = currentScale == _startScale && (DateTime.now().difference(_lastScaleGestureDate)).inMilliseconds > 100; // animate position only when panning without scaling if (isPanning) { - final pps = details.velocity.pixelsPerSecond; + var pps = details.velocity.pixelsPerSecond; if (pps != Offset.zero) { - final newPosition = clampPosition(position: _position + pps * widget.panInertia); - if (_position != newPosition) { - final tween = Tween(begin: _position, end: newPosition); + final externalTransform = boundaries.externalTransform; + if (externalTransform != null) { + pps = Matrix4.inverted(externalTransform).transformOffset(pps); + } + final newPosition = boundaries.clampPosition( + position: currentPosition + pps * widget.panInertia, + scale: currentScale, + ); + if (currentPosition != newPosition) { + final tween = Tween(begin: currentPosition, end: newPosition); const curve = Curves.easeOutCubic; _positionAnimation = tween.animate(CurvedAnimation(parent: _positionAnimationController, curve: curve)); _positionAnimationController @@ -254,7 +312,7 @@ class _MagnifierCoreState extends State with TickerProviderStateM } } - if (_scale != _startScale) { + if (currentScale != _startScale) { _lastScaleGestureDate = DateTime.now(); } } @@ -307,7 +365,7 @@ class _MagnifierCoreState extends State with TickerProviderStateM final viewportTapPosition = details.localPosition; final viewportSize = boundaries.viewportSize; final alignment = Alignment(viewportTapPosition.dx / viewportSize.width, viewportTapPosition.dy / viewportSize.height); - final childTapPosition = boundaries.viewportToChildPosition(controller, viewportTapPosition); + final childTapPosition = boundaries.viewportToContentPosition(controller, viewportTapPosition); onTap(context, controller.currentState, alignment, childTapPosition); } @@ -324,7 +382,7 @@ class _MagnifierCoreState extends State with TickerProviderStateM if (onDoubleTap(alignment) == true) return; } - final childTapPosition = boundaries.viewportToChildPosition(controller, viewportTapPosition); + final childTapPosition = boundaries.viewportToContentPosition(controller, viewportTapPosition); nextScaleState(ChangeSource.gesture, childFocalPoint: childTapPosition); } @@ -375,8 +433,7 @@ class _MagnifierCoreState extends State with TickerProviderStateM stream: controller.stateStream, initialData: controller.previousState, builder: (context, snapshot) { - final boundaries = scaleBoundaries; - if (!snapshot.hasData || boundaries == null) return const SizedBox(); + if (!snapshot.hasData) return const SizedBox(); final magnifierState = snapshot.data!; final position = magnifierState.position; @@ -384,17 +441,19 @@ class _MagnifierCoreState extends State with TickerProviderStateM Widget child = CustomSingleChildLayout( delegate: _CenterWithOriginalSizeDelegate( - boundaries.childSize, + widget.contentSize, basePosition, applyScale, ), child: widget.child, ); + // `Matrix4.scale` uses dynamic typing and can throw `UnimplementedError` on wrong types + final double effectiveScale = (applyScale ? scale : null) ?? 1.0; child = Transform( transform: Matrix4.identity() ..translate(position.dx, position.dy) - ..scale(applyScale ? scale : 1.0), + ..scale(effectiveScale), alignment: basePosition, child: child, ); @@ -406,7 +465,20 @@ class _MagnifierCoreState extends State with TickerProviderStateM onScaleEnd: onScaleEnd, onTapUp: widget.onTap == null ? null : onTap, onDoubleTap: onDoubleTap, - child: child, + child: Padding( + padding: widget.viewportPadding, + child: LayoutBuilder(builder: (context, constraints) { + controller.setScaleBoundaries((controller.scaleBoundaries ?? ScaleBoundaries.zero).copyWith( + allowOriginalScaleBeyondRange: widget.allowOriginalScaleBeyondRange, + minScale: widget.minScale, + maxScale: widget.maxScale, + initialScale: widget.initialScale, + viewportSize: constraints.biggest, + contentSize: widget.contentSize.isEmpty == false ? widget.contentSize : constraints.biggest, + )); + return child; + }), + ), ); }, ); @@ -451,3 +523,15 @@ class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate with Equ return oldDelegate != this; } } + +typedef MagnifierTapCallback = Function( + BuildContext context, + MagnifierState state, + Alignment alignment, + Offset childTapPosition, +); +typedef MagnifierDoubleTapCallback = bool Function(Alignment alignment); +typedef MagnifierGestureScaleStartCallback = void Function(ScaleStartDetails details, bool doubleTap, ScaleBoundaries boundaries); +typedef MagnifierGestureScaleUpdateCallback = bool Function(ScaleUpdateDetails details); +typedef MagnifierGestureScaleEndCallback = void Function(ScaleEndDetails details); +typedef MagnifierGestureFlingCallback = void Function(AxisDirection direction); diff --git a/plugins/aves_magnifier/lib/src/core/gesture_detector.dart b/plugins/aves_magnifier/lib/src/core/gesture_detector.dart index f28710d4b..eac8c8e18 100644 --- a/plugins/aves_magnifier/lib/src/core/gesture_detector.dart +++ b/plugins/aves_magnifier/lib/src/core/gesture_detector.dart @@ -58,12 +58,12 @@ class _MagnifierGestureDetectorState extends State { gestures[MagnifierGestureRecognizer] = GestureRecognizerFactoryWithHandlers( () => MagnifierGestureRecognizer( debugOwner: this, - hitDetector: widget.hitDetector, scope: scope, doubleTapDetails: doubleTapDetails, ), (instance) { instance + ..hitDetector = widget.hitDetector ..onStart = widget.onScaleStart != null ? (details) => widget.onScaleStart!(details, doubleTapDetails.value != null) : null ..onUpdate = widget.onScaleUpdate ..onEnd = widget.onScaleEnd diff --git a/plugins/aves_magnifier/lib/src/core/scale_gesture_recognizer.dart b/plugins/aves_magnifier/lib/src/core/scale_gesture_recognizer.dart index 9aa5ebca1..8fc3186da 100644 --- a/plugins/aves_magnifier/lib/src/core/scale_gesture_recognizer.dart +++ b/plugins/aves_magnifier/lib/src/core/scale_gesture_recognizer.dart @@ -6,13 +6,13 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; class MagnifierGestureRecognizer extends ScaleGestureRecognizer { - final EdgeHitDetector hitDetector; final MagnifierGestureDetectorScope scope; final ValueNotifier doubleTapDetails; + EdgeHitDetector? hitDetector; + MagnifierGestureRecognizer({ super.debugOwner, - required this.hitDetector, required this.scope, required this.doubleTapDetails, }); @@ -135,9 +135,9 @@ class MagnifierGestureRecognizer extends ScaleGestureRecognizer { return false; } - bool _canPanX() => hitDetector.shouldMoveX(move, scope.escapeByFling) && isXPan(move); + bool _canPanX() => hitDetector != null && hitDetector!.shouldMoveX(move, scope.escapeByFling) && isXPan(move); - bool _canPanY() => hitDetector.shouldMoveY(move, scope.escapeByFling) && isYPan(move); + bool _canPanY() => hitDetector != null && hitDetector!.shouldMoveY(move, scope.escapeByFling) && isYPan(move); bool _isOverSlop(PointerDeviceKind kind) { final spanDelta = (_currentSpan! - _initialSpan!).abs(); diff --git a/plugins/aves_magnifier/lib/src/magnifier.dart b/plugins/aves_magnifier/lib/src/magnifier.dart deleted file mode 100644 index 38f5e1d02..000000000 --- a/plugins/aves_magnifier/lib/src/magnifier.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:aves_magnifier/src/controller/controller.dart'; -import 'package:aves_magnifier/src/controller/state.dart'; -import 'package:aves_magnifier/src/core/core.dart'; -import 'package:aves_magnifier/src/scale/scale_boundaries.dart'; -import 'package:aves_magnifier/src/scale/scale_level.dart'; -import 'package:aves_magnifier/src/scale/state.dart'; -import 'package:flutter/material.dart'; - -/* - adapted from package `photo_view` v0.9.2: - - removed image related aspects to focus on a general purpose pan/scale viewer (à la `InteractiveViewer`) - - removed rotation and many customization parameters - - removed ignorable/ignoring partial notifiers - - formatted, renamed and reorganized - - fixed gesture recognizers when used inside a scrollable widget like `PageView` - - fixed corner hit detection when in containers scrollable in both axes - - fixed corner hit detection issues due to imprecise double comparisons - - added single & double tap position feedback - - fixed focus when scaling by double-tap/pinch - */ -class AvesMagnifier extends StatelessWidget { - const AvesMagnifier({ - super.key, - required this.controller, - required this.childSize, - this.allowOriginalScaleBeyondRange = true, - this.minScale = const ScaleLevel(factor: .0), - this.maxScale = const ScaleLevel(factor: double.infinity), - this.initialScale = const ScaleLevel(ref: ScaleReference.contained), - this.scaleStateCycle = defaultScaleStateCycle, - this.applyScale = true, - this.onScaleStart, - this.onScaleUpdate, - this.onScaleEnd, - this.onFling, - this.onTap, - this.onDoubleTap, - required this.child, - }); - - final AvesMagnifierController controller; - - // The size of the custom [child]. This value is used to compute the relation between the child and the container's size to calculate the scale value. - final Size childSize; - - final bool allowOriginalScaleBeyondRange; - - // Defines the minimum size in which the image will be allowed to assume, it is proportional to the original image size. - final ScaleLevel minScale; - - // Defines the maximum size in which the image will be allowed to assume, it is proportional to the original image size. - final ScaleLevel maxScale; - - // Defines the size the image will assume when the component is initialized, it is proportional to the original image size. - final ScaleLevel initialScale; - - final ScaleStateCycle scaleStateCycle; - final bool applyScale; - final MagnifierGestureScaleStartCallback? onScaleStart; - final MagnifierGestureScaleUpdateCallback? onScaleUpdate; - final MagnifierGestureScaleEndCallback? onScaleEnd; - final MagnifierGestureFlingCallback? onFling; - final MagnifierTapCallback? onTap; - final MagnifierDoubleTapCallback? onDoubleTap; - final Widget child; - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - controller.setScaleBoundaries(ScaleBoundaries( - allowOriginalScaleBeyondRange: allowOriginalScaleBeyondRange, - minScale: minScale, - maxScale: maxScale, - initialScale: initialScale, - viewportSize: constraints.biggest, - childSize: childSize.isEmpty == false ? childSize : constraints.biggest, - )); - - return MagnifierCore( - controller: controller, - scaleStateCycle: scaleStateCycle, - applyScale: applyScale, - onScaleStart: onScaleStart, - onScaleUpdate: onScaleUpdate, - onScaleEnd: onScaleEnd, - onFling: onFling, - onTap: onTap, - onDoubleTap: onDoubleTap, - child: child, - ); - }, - ); - } -} - -typedef MagnifierTapCallback = Function( - BuildContext context, - MagnifierState state, - Alignment alignment, - Offset childTapPosition, -); -typedef MagnifierDoubleTapCallback = bool Function(Alignment alignment); -typedef MagnifierGestureScaleStartCallback = void Function(ScaleStartDetails details, bool doubleTap, ScaleBoundaries boundaries); -typedef MagnifierGestureScaleUpdateCallback = bool Function(ScaleUpdateDetails details); -typedef MagnifierGestureScaleEndCallback = void Function(ScaleEndDetails details); -typedef MagnifierGestureFlingCallback = void Function(AxisDirection direction); diff --git a/plugins/aves_magnifier/lib/src/pan/edge_hit_detector.dart b/plugins/aves_magnifier/lib/src/pan/edge_hit_detector.dart index 551c7c0a9..418e7d790 100644 --- a/plugins/aves_magnifier/lib/src/pan/edge_hit_detector.dart +++ b/plugins/aves_magnifier/lib/src/pan/edge_hit_detector.dart @@ -1,38 +1,41 @@ import 'package:aves_magnifier/src/controller/controller_delegate.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; mixin EdgeHitDetector on AvesMagnifierControllerDelegate { - // the child width/height is not accurate for some image size & scale combos + // the content width/height is not accurate for some image size & scale combos // e.g. 3580.0 * 0.1005586592178771 yields 360.0 // but 4764.0 * 0.07556675062972293 yields 360.00000000000006 // so be sure to compare with `precisionErrorTolerance` EdgeHit getXEdgeHit() { - final boundaries = scaleBoundaries; - if (boundaries == null) return const EdgeHit(false, false); + final _boundaries = scaleBoundaries; + final _scale = scale; + if (_boundaries == null || _scale == null) return const EdgeHit(false, false); - final childWidth = boundaries.childSize.width * scale!; - final viewportWidth = boundaries.viewportSize.width; - if (viewportWidth + precisionErrorTolerance >= childWidth) { + final contentWidth = _boundaries.contentSize.width * _scale; + final viewportWidth = _boundaries.viewportSize.width; + if (viewportWidth + precisionErrorTolerance >= contentWidth) { return const EdgeHit(true, true); } final x = -position.dx; - final range = getXEdges(); + final range = _boundaries.getXEdges(scale: _scale); return EdgeHit(x <= range.min, x >= range.max); } EdgeHit getYEdgeHit() { - final boundaries = scaleBoundaries; - if (boundaries == null) return const EdgeHit(false, false); + final _boundaries = scaleBoundaries; + final _scale = scale; + if (_boundaries == null || _scale == null) return const EdgeHit(false, false); - final childHeight = boundaries.childSize.height * scale!; - final viewportHeight = boundaries.viewportSize.height; - if (viewportHeight + precisionErrorTolerance >= childHeight) { + final contentHeight = _boundaries.contentSize.height * _scale; + final viewportHeight = _boundaries.viewportSize.height; + if (viewportHeight + precisionErrorTolerance >= contentHeight) { return const EdgeHit(true, true); } final y = -position.dy; - final range = getYEdges(); + final range = _boundaries.getYEdges(scale: _scale); return EdgeHit(y <= range.min, y >= range.max); } @@ -56,12 +59,16 @@ mixin EdgeHitDetector on AvesMagnifierControllerDelegate { } } -class EdgeHit { - const EdgeHit(this.hasHitMin, this.hasHitMax); - +@immutable +class EdgeHit extends Equatable { final bool hasHitMin; final bool hasHitMax; + @override + List get props => [hasHitMin, hasHitMax]; + + const EdgeHit(this.hasHitMin, this.hasHitMax); + bool get hasHitAny => hasHitMin || hasHitMax; bool get hasHitBoth => hasHitMin && hasHitMax; diff --git a/plugins/aves_magnifier/lib/src/scale/scale_boundaries.dart b/plugins/aves_magnifier/lib/src/scale/scale_boundaries.dart index 256612d9f..f3c12f8f9 100644 --- a/plugins/aves_magnifier/lib/src/scale/scale_boundaries.dart +++ b/plugins/aves_magnifier/lib/src/scale/scale_boundaries.dart @@ -1,12 +1,13 @@ import 'dart:math'; import 'package:aves_magnifier/src/controller/controller.dart'; +import 'package:aves_magnifier/src/controller/range.dart'; import 'package:aves_magnifier/src/scale/scale_level.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/widgets.dart'; /// Internal class to wrap custom scale boundaries (min, max and initial) -/// Also, stores values regarding the two sizes: the container and the child. +/// Also, stores values regarding the two sizes: the container and the content. @immutable class ScaleBoundaries extends Equatable { final bool _allowOriginalScaleBeyondRange; @@ -14,10 +15,13 @@ class ScaleBoundaries extends Equatable { final ScaleLevel _maxScale; final ScaleLevel _initialScale; final Size viewportSize; - final Size childSize; + final Size contentSize; + final Matrix4? externalTransform; + + static const Alignment basePosition = Alignment.center; @override - List get props => [_allowOriginalScaleBeyondRange, _minScale, _maxScale, _initialScale, viewportSize, childSize]; + List get props => [_allowOriginalScaleBeyondRange, _minScale, _maxScale, _initialScale, viewportSize, contentSize, externalTransform]; const ScaleBoundaries({ required bool allowOriginalScaleBeyondRange, @@ -25,32 +29,54 @@ class ScaleBoundaries extends Equatable { required ScaleLevel maxScale, required ScaleLevel initialScale, required this.viewportSize, - required this.childSize, + required this.contentSize, + this.externalTransform, }) : _allowOriginalScaleBeyondRange = allowOriginalScaleBeyondRange, _minScale = minScale, _maxScale = maxScale, _initialScale = initialScale; + static const ScaleBoundaries zero = ScaleBoundaries( + allowOriginalScaleBeyondRange: true, + minScale: ScaleLevel(factor: .0), + maxScale: ScaleLevel(factor: double.infinity), + initialScale: ScaleLevel(ref: ScaleReference.contained), + viewportSize: Size.zero, + contentSize: Size.zero, + ); + ScaleBoundaries copyWith({ - Size? childSize, + bool? allowOriginalScaleBeyondRange, + ScaleLevel? minScale, + ScaleLevel? maxScale, + ScaleLevel? initialScale, + Size? viewportSize, + Size? contentSize, + Matrix4? externalTransform, }) { return ScaleBoundaries( - allowOriginalScaleBeyondRange: _allowOriginalScaleBeyondRange, - minScale: _minScale, - maxScale: _maxScale, - initialScale: _initialScale, - viewportSize: viewportSize, - childSize: childSize ?? this.childSize, + allowOriginalScaleBeyondRange: allowOriginalScaleBeyondRange ?? _allowOriginalScaleBeyondRange, + minScale: minScale ?? _minScale, + maxScale: maxScale ?? _maxScale, + initialScale: initialScale ?? _initialScale, + viewportSize: viewportSize ?? this.viewportSize, + contentSize: contentSize ?? this.contentSize, + externalTransform: externalTransform ?? this.externalTransform, ); } + Size get _transformedViewportSize { + final matrix = externalTransform; + return matrix != null ? MatrixUtils.transformRect(Matrix4.inverted(matrix), Offset.zero & viewportSize).size : viewportSize; + } + double scaleForLevel(ScaleLevel level) { final factor = level.factor; switch (level.ref) { case ScaleReference.contained: - return factor * ScaleLevel.scaleForContained(viewportSize, childSize); + return factor * ScaleLevel.scaleForContained(viewportSize, contentSize); case ScaleReference.covered: - return factor * ScaleLevel.scaleForCovering(viewportSize, childSize); + return factor * ScaleLevel.scaleForCovering(viewportSize, contentSize); case ScaleReference.absolute: default: return factor; @@ -62,33 +88,83 @@ class ScaleBoundaries extends Equatable { return 1.0 / (view?.devicePixelRatio ?? 1.0); } - double get minScale => { - scaleForLevel(_minScale), - _allowOriginalScaleBeyondRange ? originalScale : double.infinity, - initialScale, - }.fold(double.infinity, min); - - double get maxScale => { - scaleForLevel(_maxScale), - _allowOriginalScaleBeyondRange ? originalScale : double.negativeInfinity, - initialScale, - }.fold(0, max); - double get initialScale => scaleForLevel(_initialScale); Offset get _viewportCenter => viewportSize.center(Offset.zero); - Offset get _childCenter => childSize.center(Offset.zero); + Offset get _contentCenter => contentSize.center(Offset.zero); Offset viewportToStatePosition(AvesMagnifierController controller, Offset viewportPosition) { return viewportPosition - _viewportCenter - controller.position; } - Offset viewportToChildPosition(AvesMagnifierController controller, Offset viewportPosition) { - return viewportToStatePosition(controller, viewportPosition) / controller.scale! + _childCenter; + Offset viewportToContentPosition(AvesMagnifierController controller, Offset viewportPosition) { + return viewportToStatePosition(controller, viewportPosition) / controller.scale! + _contentCenter; } - Offset childToStatePosition(double scale, Offset childPosition) { - return (_childCenter - childPosition) * scale; + Offset contentToStatePosition(double scale, Offset contentPosition) { + return (_contentCenter - contentPosition) * scale; + } + + EdgeRange getXEdges({required double scale}) { + final computedWidth = contentSize.width * scale; + final viewportWidth = _transformedViewportSize.width; + + final positionX = basePosition.x; + final widthDiff = computedWidth - viewportWidth; + + final minX = ((positionX - 1).abs() / 2) * widthDiff * -1; + final maxX = ((positionX + 1).abs() / 2) * widthDiff; + return EdgeRange(minX, maxX); + } + + EdgeRange getYEdges({required double scale}) { + final computedHeight = contentSize.height * scale; + final viewportHeight = _transformedViewportSize.height; + + final positionY = basePosition.y; + final heightDiff = computedHeight - viewportHeight; + + final minY = ((positionY - 1).abs() / 2) * heightDiff * -1; + final maxY = ((positionY + 1).abs() / 2) * heightDiff; + return EdgeRange(minY, maxY); + } + + double clampScale(double scale) { + final minScale = { + scaleForLevel(_minScale), + _allowOriginalScaleBeyondRange ? originalScale : double.infinity, + initialScale, + }.fold(double.infinity, min); + + final maxScale = { + scaleForLevel(_maxScale), + _allowOriginalScaleBeyondRange ? originalScale : double.negativeInfinity, + initialScale, + }.fold(.0, max); + + return scale.clamp(minScale, maxScale); + } + + Offset clampPosition({required Offset position, required double scale}) { + final computedWidth = contentSize.width * scale; + final computedHeight = contentSize.height * scale; + + final viewportWidth = _transformedViewportSize.width; + final viewportHeight = _transformedViewportSize.height; + + var finalX = 0.0; + if (viewportWidth < computedWidth) { + final range = getXEdges(scale: scale); + finalX = position.dx.clamp(range.min, range.max); + } + + var finalY = 0.0; + if (viewportHeight < computedHeight) { + final range = getYEdges(scale: scale); + finalY = position.dy.clamp(range.min, range.max); + } + + return Offset(finalX, finalY); } } diff --git a/plugins/aves_magnifier/lib/src/scale/scale_level.dart b/plugins/aves_magnifier/lib/src/scale/scale_level.dart index a076181ae..b186684e0 100644 --- a/plugins/aves_magnifier/lib/src/scale/scale_level.dart +++ b/plugins/aves_magnifier/lib/src/scale/scale_level.dart @@ -17,9 +17,9 @@ class ScaleLevel extends Equatable { this.factor = 1.0, }); - static double scaleForContained(Size containerSize, Size childSize) => min(containerSize.width / childSize.width, containerSize.height / childSize.height); + static double scaleForContained(Size viewportSize, Size contentSize) => min(viewportSize.width / contentSize.width, viewportSize.height / contentSize.height); - static double scaleForCovering(Size containerSize, Size childSize) => max(containerSize.width / childSize.width, containerSize.height / childSize.height); + static double scaleForCovering(Size viewportSize, Size contentSize) => max(viewportSize.width / contentSize.width, viewportSize.height / contentSize.height); } enum ScaleReference { absolute, contained, covered } diff --git a/plugins/aves_magnifier/lib/src/scale/state.dart b/plugins/aves_magnifier/lib/src/scale/state.dart index 44f5dd533..6cf44a351 100644 --- a/plugins/aves_magnifier/lib/src/scale/state.dart +++ b/plugins/aves_magnifier/lib/src/scale/state.dart @@ -33,12 +33,9 @@ ScaleState defaultScaleStateCycle(ScaleState actual) { case ScaleState.covering: return ScaleState.originalSize; case ScaleState.originalSize: - return ScaleState.initial; case ScaleState.zoomedIn: case ScaleState.zoomedOut: return ScaleState.initial; - default: - return ScaleState.initial; } } diff --git a/plugins/aves_magnifier/pubspec.lock b/plugins/aves_magnifier/pubspec.lock index 318c61e2c..7ae3ad656 100644 --- a/plugins/aves_magnifier/pubspec.lock +++ b/plugins/aves_magnifier/pubspec.lock @@ -1,6 +1,13 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + aves_utils: + dependency: "direct main" + description: + path: "../aves_utils" + relative: true + source: path + version: "0.0.1" characters: dependency: transitive description: diff --git a/plugins/aves_magnifier/pubspec.yaml b/plugins/aves_magnifier/pubspec.yaml index b60a8df8c..cb6f1a9c7 100644 --- a/plugins/aves_magnifier/pubspec.yaml +++ b/plugins/aves_magnifier/pubspec.yaml @@ -8,6 +8,8 @@ environment: dependencies: flutter: sdk: flutter + aves_utils: + path: ../aves_utils equatable: provider: tuple: diff --git a/plugins/aves_model/lib/aves_model.dart b/plugins/aves_model/lib/aves_model.dart index f03a09657..30d67e558 100644 --- a/plugins/aves_model/lib/aves_model.dart +++ b/plugins/aves_model/lib/aves_model.dart @@ -10,6 +10,7 @@ export 'src/actions/move_type.dart'; export 'src/actions/settings.dart'; export 'src/actions/share.dart'; export 'src/actions/slideshow.dart'; +export 'src/editor/enums.dart'; export 'src/entry/base.dart'; export 'src/metadata/enums.dart'; export 'src/metadata/fields.dart'; diff --git a/plugins/aves_model/lib/src/editor/enums.dart b/plugins/aves_model/lib/src/editor/enums.dart new file mode 100644 index 000000000..7d03820ac --- /dev/null +++ b/plugins/aves_model/lib/src/editor/enums.dart @@ -0,0 +1,66 @@ +enum EditorAction { transform } + +enum CropAspectRatio { free, original, square, ar_16_9, ar_4_3 } + +enum TransformActivity { none, pan, resize, straighten } + +enum TransformOrientation { normal, rotate90, rotate180, rotate270, transverse, flipVertical, transpose, flipHorizontal } + +extension ExtraTransformOrientation on TransformOrientation { + TransformOrientation flipHorizontally() { + switch (this) { + case TransformOrientation.normal: + return TransformOrientation.flipHorizontal; + case TransformOrientation.rotate90: + return TransformOrientation.transverse; + case TransformOrientation.rotate180: + return TransformOrientation.flipVertical; + case TransformOrientation.rotate270: + return TransformOrientation.transpose; + case TransformOrientation.transverse: + return TransformOrientation.rotate90; + case TransformOrientation.flipVertical: + return TransformOrientation.rotate180; + case TransformOrientation.transpose: + return TransformOrientation.rotate270; + case TransformOrientation.flipHorizontal: + return TransformOrientation.normal; + } + } + + bool get isFlipped { + switch (this) { + case TransformOrientation.normal: + case TransformOrientation.rotate90: + case TransformOrientation.rotate180: + case TransformOrientation.rotate270: + return false; + case TransformOrientation.transverse: + case TransformOrientation.flipVertical: + case TransformOrientation.transpose: + case TransformOrientation.flipHorizontal: + return true; + } + } + + TransformOrientation rotateClockwise() { + switch (this) { + case TransformOrientation.normal: + return TransformOrientation.rotate90; + case TransformOrientation.rotate90: + return TransformOrientation.rotate180; + case TransformOrientation.rotate180: + return TransformOrientation.rotate270; + case TransformOrientation.rotate270: + return TransformOrientation.normal; + case TransformOrientation.transverse: + return TransformOrientation.flipHorizontal; + case TransformOrientation.flipVertical: + return TransformOrientation.transverse; + case TransformOrientation.transpose: + return TransformOrientation.flipVertical; + case TransformOrientation.flipHorizontal: + return TransformOrientation.transpose; + } + } +} diff --git a/plugins/aves_utils/lib/aves_utils.dart b/plugins/aves_utils/lib/aves_utils.dart index 1921022e4..d41bcfd2b 100644 --- a/plugins/aves_utils/lib/aves_utils.dart +++ b/plugins/aves_utils/lib/aves_utils.dart @@ -2,3 +2,4 @@ library aves_utils; export 'src/change_notifier.dart'; export 'src/optional_event_channel.dart'; +export 'src/vector_utils.dart'; diff --git a/plugins/aves_utils/lib/src/change_notifier.dart b/plugins/aves_utils/lib/src/change_notifier.dart index 60ad5d939..1e0b0b9f5 100644 --- a/plugins/aves_utils/lib/src/change_notifier.dart +++ b/plugins/aves_utils/lib/src/change_notifier.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -// `ChangeNotifier` wrapper so that it can be used anywhere, not just as a mixin +// `ChangeNotifier` wrapper to call `notify` without constraint class AChangeNotifier extends ChangeNotifier { void notify() { // why is this protected? diff --git a/plugins/aves_utils/lib/src/vector_utils.dart b/plugins/aves_utils/lib/src/vector_utils.dart new file mode 100644 index 000000000..14f320784 --- /dev/null +++ b/plugins/aves_utils/lib/src/vector_utils.dart @@ -0,0 +1,15 @@ +import 'dart:ui'; + +import 'package:vector_math/vector_math_64.dart'; + +extension ExtraOffset on Offset { + Vector3 get toVector3 => Vector3(dx, dy, 0); +} + +extension ExtraVector3 on Vector3 { + Offset get toOffset => Offset(x, y); +} + +extension ExtraMatrix4 on Matrix4 { + Offset transformOffset(Offset v) => transform3(v.toVector3).toOffset; +} diff --git a/plugins/aves_utils/pubspec.lock b/plugins/aves_utils/pubspec.lock index 65a37f405..f496c7561 100644 --- a/plugins/aves_utils/pubspec.lock +++ b/plugins/aves_utils/pubspec.lock @@ -68,7 +68,7 @@ packages: source: sdk version: "0.0.99" vector_math: - dependency: transitive + dependency: "direct main" description: name: vector_math sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" diff --git a/plugins/aves_utils/pubspec.yaml b/plugins/aves_utils/pubspec.yaml index dd19f728c..52f2d1f3d 100644 --- a/plugins/aves_utils/pubspec.yaml +++ b/plugins/aves_utils/pubspec.yaml @@ -8,6 +8,7 @@ environment: dependencies: flutter: sdk: flutter + vector_math: dev_dependencies: flutter_lints: diff --git a/pubspec.lock b/pubspec.lock index b2210d693..dcb48c388 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1462,7 +1462,7 @@ packages: source: hosted version: "3.0.6" vector_math: - dependency: transitive + dependency: "direct main" description: name: vector_math sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" diff --git a/pubspec.yaml b/pubspec.yaml index 639a13adc..23676818c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -113,6 +113,7 @@ dependencies: transparent_image: tuple: url_launcher: + vector_math: volume_controller: xml: diff --git a/test/model/view_state_test.dart b/test/model/view_state_test.dart new file mode 100644 index 000000000..7da0dcb84 --- /dev/null +++ b/test/model/view_state_test.dart @@ -0,0 +1,90 @@ +import 'dart:ui'; + +import 'package:aves/model/view_state.dart'; +import 'package:aves_utils/aves_utils.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +void main() { + test('scene -> viewport, original scaleFit', () { + const viewport = Rect.fromLTWH(0, 0, 100, 200); + const content = Rect.fromLTWH(0, 0, 200, 400); + final state = ViewState(position: Offset.zero, scale: 1, viewportSize: viewport.size, contentSize: content.size); + + expect(_toViewportPoint(state, content.topLeft), const Offset(-50, -100)); + expect(_toViewportPoint(state, content.bottomRight), const Offset(150, 300)); + }); + + test('scene -> viewport, scaled to fit .5', () { + const viewport = Rect.fromLTWH(0, 0, 100, 200); + const content = Rect.fromLTWH(0, 0, 200, 400); + final state = ViewState(position: Offset.zero, scale: .5, viewportSize: viewport.size, contentSize: content.size); + + expect(_toViewportPoint(state, content.topLeft), viewport.topLeft); + expect(_toViewportPoint(state, content.center), viewport.center); + expect(_toViewportPoint(state, content.bottomRight), viewport.bottomRight); + }); + + test('scene -> viewport, scaled to fit .25', () { + const viewport = Rect.fromLTWH(0, 0, 50, 100); + const content = Rect.fromLTWH(0, 0, 200, 400); + final state = ViewState(position: Offset.zero, scale: .25, viewportSize: viewport.size, contentSize: content.size); + + expect(_toViewportPoint(state, content.topLeft), viewport.topLeft); + expect(_toViewportPoint(state, content.center), viewport.center); + expect(_toViewportPoint(state, content.bottomRight), viewport.bottomRight); + }); + + test('viewport -> scene, original scaleFit', () { + const viewport = Rect.fromLTWH(0, 0, 100, 200); + const content = Rect.fromLTWH(0, 0, 200, 400); + final state = ViewState(position: Offset.zero, scale: 1, viewportSize: viewport.size, contentSize: content.size); + + expect(_toContentPoint(state, viewport.topLeft), const Offset(50, 100)); + expect(_toContentPoint(state, viewport.bottomRight), const Offset(150, 300)); + }); + + test('viewport -> scene, scaled to fit', () { + const viewport = Rect.fromLTWH(0, 0, 100, 200); + const content = Rect.fromLTWH(0, 0, 200, 400); + final state = ViewState(position: Offset.zero, scale: .5, viewportSize: viewport.size, contentSize: content.size); + + expect(_toContentPoint(state, viewport.topLeft), content.topLeft); + expect(_toContentPoint(state, viewport.center), content.center); + expect(_toContentPoint(state, viewport.bottomRight), content.bottomRight); + }); + + test('viewport -> scene, translated', () { + const viewport = Rect.fromLTWH(0, 0, 100, 200); + const content = Rect.fromLTWH(0, 0, 200, 400); + final state = ViewState(position: const Offset(50, 50), scale: 1, viewportSize: viewport.size, contentSize: content.size); + + _toContentPoint(state, viewport.topLeft); + expect(_toContentPoint(state, viewport.topLeft), const Offset(0, 50)); + expect(_toContentPoint(state, viewport.bottomRight), const Offset(100, 250)); + }); + + test('scene -> viewport, scaled to fit, different ratios', () { + const viewport = Rect.fromLTWH(0, 0, 360, 521); + const content = Rect.fromLTWH(0, 0, 2268, 4032); + final scaleFit = viewport.height / content.height; + final state = ViewState(position: Offset.zero, scale: scaleFit, viewportSize: viewport.size, contentSize: content.size); + + final scaledContentLeft = (viewport.width - content.width * scaleFit) / 2; + final scaledContentRight = viewport.width - scaledContentLeft; + + expect(_toViewportPoint(state, content.topLeft), Offset(scaledContentLeft, 0)); + expect(_toViewportPoint(state, content.center), viewport.center); + expect(_toViewportPoint(state, content.bottomRight), Offset(scaledContentRight, viewport.bottom)); + }); +} + +// convenience methods + +Offset _toViewportPoint(ViewState state, Offset contentPoint) { + return state.matrix.transformOffset(contentPoint); +} + +Offset _toContentPoint(ViewState state, viewportPoint) { + return Matrix4.inverted(state.matrix).transformOffset(viewportPoint); +} diff --git a/test/utils/math_utils_test.dart b/test/utils/math_utils_test.dart index bb8f5cae2..8032d07c9 100644 --- a/test/utils/math_utils_test.dart +++ b/test/utils/math_utils_test.dart @@ -1,5 +1,8 @@ +import 'dart:ui'; + import 'package:aves/utils/math_utils.dart'; import 'package:test/test.dart'; +import 'package:tuple/tuple.dart'; void main() { test('highest power of 2 that is smaller than or equal to the number', () { @@ -24,4 +27,10 @@ void main() { expect(roundToPrecision(1.2345678, decimals: 3), 1.235); expect(roundToPrecision(0, decimals: 3), 0); }); + + test('segment intersection', () { + const s1 = Tuple2(Offset(1, 1), Offset(3, 2)); + const s2 = Tuple2(Offset(1, 4), Offset(2, -1)); + expect(segmentIntersection(s1, s2), const Offset(17 / 11, 14 / 11)); + }); } diff --git a/untranslated.json b/untranslated.json index d07633804..7e2beb5f6 100644 --- a/untranslated.json +++ b/untranslated.json @@ -6,6 +6,8 @@ "timeMinutes", "timeDays", "focalLength", + "saveCopyButtonLabel", + "applyTooltip", "pickTooltip", "sourceStateLoading", "sourceStateCataloguing", @@ -75,6 +77,12 @@ "entryInfoActionRemoveMetadata", "entryInfoActionExportMetadata", "entryInfoActionRemoveLocation", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterBinLabel", @@ -625,6 +633,8 @@ ], "ckb": [ + "saveCopyButtonLabel", + "applyTooltip", "chipActionGoToPlacePage", "chipActionShowCountryStates", "entryActionRotateCCW", @@ -654,6 +664,12 @@ "entryInfoActionRemoveMetadata", "entryInfoActionExportMetadata", "entryInfoActionRemoveLocation", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterBinLabel", @@ -1217,6 +1233,14 @@ ], "cs": [ + "saveCopyButtonLabel", + "applyTooltip", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "maxBrightnessNever", "maxBrightnessAlways", "videoResumptionModeNever", @@ -1231,6 +1255,14 @@ ], "de": [ + "saveCopyButtonLabel", + "applyTooltip", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "maxBrightnessNever", "maxBrightnessAlways", "videoResumptionModeNever", @@ -1244,7 +1276,42 @@ "tagEditorDiscardDialogMessage" ], + "el": [ + "saveCopyButtonLabel", + "applyTooltip", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare" + ], + + "es": [ + "saveCopyButtonLabel", + "applyTooltip", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare" + ], + + "eu": [ + "saveCopyButtonLabel", + "applyTooltip", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare" + ], + "fa": [ + "saveCopyButtonLabel", + "applyTooltip", "clearTooltip", "chipActionGoToPlacePage", "chipActionLock", @@ -1257,6 +1324,12 @@ "viewerActionLock", "viewerActionUnlock", "slideshowActionResume", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterLocatedLabel", @@ -1739,8 +1812,21 @@ "filePickerUseThisFolder" ], + "fr": [ + "saveCopyButtonLabel", + "applyTooltip", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare" + ], + "gl": [ "columnCount", + "saveCopyButtonLabel", + "applyTooltip", "chipActionGoToPlacePage", "chipActionLock", "chipActionShowCountryStates", @@ -1752,6 +1838,12 @@ "viewerActionUnlock", "entryInfoActionExportMetadata", "entryInfoActionRemoveLocation", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterNoAddressLabel", @@ -2279,6 +2371,8 @@ "showButtonLabel", "hideButtonLabel", "continueButtonLabel", + "saveCopyButtonLabel", + "applyTooltip", "cancelTooltip", "changeTooltip", "clearTooltip", @@ -2360,6 +2454,12 @@ "entryInfoActionRemoveMetadata", "entryInfoActionExportMetadata", "entryInfoActionRemoveLocation", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterBinLabel", @@ -2923,6 +3023,8 @@ ], "hi": [ + "saveCopyButtonLabel", + "applyTooltip", "resetTooltip", "saveTooltip", "pickTooltip", @@ -2996,6 +3098,12 @@ "entryInfoActionRemoveMetadata", "entryInfoActionExportMetadata", "entryInfoActionRemoveLocation", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterBinLabel", @@ -3558,13 +3666,54 @@ "filePickerUseThisFolder" ], + "hu": [ + "saveCopyButtonLabel", + "applyTooltip", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare" + ], + + "id": [ + "saveCopyButtonLabel", + "applyTooltip", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare" + ], + + "it": [ + "saveCopyButtonLabel", + "applyTooltip", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare" + ], + "ja": [ "columnCount", + "saveCopyButtonLabel", + "applyTooltip", "chipActionShowCountryStates", "chipActionCreateVault", "chipActionConfigureVault", "viewerActionLock", "viewerActionUnlock", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "filterLocatedLabel", "filterTaggedLabel", "albumTierVaults", @@ -3600,8 +3749,21 @@ "tagEditorDiscardDialogMessage" ], + "ko": [ + "saveCopyButtonLabel", + "applyTooltip", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare" + ], + "lt": [ "columnCount", + "saveCopyButtonLabel", + "applyTooltip", "chipActionGoToPlacePage", "chipActionLock", "chipActionShowCountryStates", @@ -3609,6 +3771,12 @@ "chipActionConfigureVault", "viewerActionLock", "viewerActionUnlock", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "filterLocatedLabel", "filterTaggedLabel", "albumTierVaults", @@ -3684,6 +3852,8 @@ "showButtonLabel", "hideButtonLabel", "continueButtonLabel", + "saveCopyButtonLabel", + "applyTooltip", "cancelTooltip", "changeTooltip", "clearTooltip", @@ -3765,6 +3935,12 @@ "entryInfoActionRemoveMetadata", "entryInfoActionExportMetadata", "entryInfoActionRemoveLocation", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterBinLabel", @@ -4328,9 +4504,17 @@ ], "nb": [ + "saveCopyButtonLabel", + "applyTooltip", "chipActionShowCountryStates", "viewerActionLock", "viewerActionUnlock", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "maxBrightnessNever", "maxBrightnessAlways", "vaultLockTypePattern", @@ -4359,6 +4543,8 @@ "nl": [ "columnCount", + "saveCopyButtonLabel", + "applyTooltip", "chipActionGoToPlacePage", "chipActionLock", "chipActionShowCountryStates", @@ -4366,6 +4552,12 @@ "entryActionShareVideoOnly", "viewerActionLock", "viewerActionUnlock", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "filterLocatedLabel", "albumTierVaults", "maxBrightnessNever", @@ -4425,6 +4617,8 @@ "nn": [ "columnCount", + "saveCopyButtonLabel", + "applyTooltip", "sourceStateCataloguing", "chipActionGoToPlacePage", "chipActionLock", @@ -4434,6 +4628,12 @@ "viewerActionLock", "viewerActionUnlock", "entryInfoActionRemoveLocation", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "filterLocatedLabel", "filterNoLocationLabel", "filterTaggedLabel", @@ -4778,6 +4978,8 @@ "deleteButtonLabel", "nextButtonLabel", "continueButtonLabel", + "saveCopyButtonLabel", + "applyTooltip", "cancelTooltip", "changeTooltip", "clearTooltip", @@ -4853,6 +5055,12 @@ "entryInfoActionRemoveMetadata", "entryInfoActionExportMetadata", "entryInfoActionRemoveLocation", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterBinLabel", @@ -5354,7 +5562,37 @@ "filePickerUseThisFolder" ], + "pl": [ + "saveCopyButtonLabel", + "applyTooltip", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare" + ], + + "pt": [ + "saveCopyButtonLabel", + "applyTooltip", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare" + ], + "ro": [ + "saveCopyButtonLabel", + "applyTooltip", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "maxBrightnessNever", "maxBrightnessAlways", "videoResumptionModeNever", @@ -5369,6 +5607,14 @@ ], "ru": [ + "saveCopyButtonLabel", + "applyTooltip", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "maxBrightnessNever", "maxBrightnessAlways", "videoResumptionModeNever", @@ -5393,6 +5639,8 @@ "itemCount", "columnCount", "timeSeconds", + "saveCopyButtonLabel", + "applyTooltip", "chipActionGoToPlacePage", "chipActionLock", "chipActionShowCountryStates", @@ -5400,6 +5648,12 @@ "chipActionConfigureVault", "viewerActionLock", "viewerActionUnlock", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "filterLocatedLabel", "filterNoLocationLabel", "albumTierVaults", @@ -5841,6 +6095,8 @@ "timeDays", "focalLength", "applyButtonLabel", + "saveCopyButtonLabel", + "applyTooltip", "chipActionGoToPlacePage", "chipActionLock", "chipActionShowCountryStates", @@ -5848,6 +6104,12 @@ "chipActionConfigureVault", "viewerActionLock", "viewerActionUnlock", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "albumTierVaults", "lengthUnitPixel", "lengthUnitPercent", @@ -6213,6 +6475,8 @@ ], "tr": [ + "saveCopyButtonLabel", + "applyTooltip", "chipActionGoToPlacePage", "chipActionLock", "chipActionShowCountryStates", @@ -6220,6 +6484,12 @@ "chipActionConfigureVault", "viewerActionLock", "viewerActionUnlock", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "albumTierVaults", "lengthUnitPixel", "lengthUnitPercent", @@ -6269,10 +6539,29 @@ "tagPlaceholderState" ], + "uk": [ + "saveCopyButtonLabel", + "applyTooltip", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare" + ], + "zh": [ + "saveCopyButtonLabel", + "applyTooltip", "chipActionGoToPlacePage", "viewerActionLock", "viewerActionUnlock", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "filterLocatedLabel", "filterTaggedLabel", "albumTierVaults", @@ -6332,6 +6621,8 @@ "zh_Hant": [ "columnCount", + "saveCopyButtonLabel", + "applyTooltip", "chipActionGoToPlacePage", "chipActionLock", "chipActionShowCountryStates", @@ -6339,6 +6630,12 @@ "chipActionConfigureVault", "viewerActionLock", "viewerActionUnlock", + "editorActionTransform", + "editorTransformCrop", + "editorTransformRotate", + "cropAspectRatioFree", + "cropAspectRatioOriginal", + "cropAspectRatioSquare", "filterLocatedLabel", "filterTaggedLabel", "albumTierVaults",