lab: transform
This commit is contained in:
parent
28973ec322
commit
b1920dbe1c
70 changed files with 3409 additions and 614 deletions
|
@ -145,6 +145,15 @@ This change eventually prevents building the app with Flutter v3.7.11.
|
|||
<data android:mimeType="vnd.android.cursor.dir/image" />
|
||||
<data android:mimeType="vnd.android.cursor.dir/video" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.EDIT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="vnd.android.cursor.dir/image" />
|
||||
</intent-filter>
|
||||
-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.GET_CONTENT" />
|
||||
|
||||
|
|
|
@ -277,6 +277,18 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
Intent.ACTION_EDIT -> {
|
||||
(intent.data ?: intent.getParcelableExtraCompat<Uri>(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"
|
||||
|
|
|
@ -9,6 +9,7 @@ enum AppMode {
|
|||
setWallpaper,
|
||||
slideshow,
|
||||
view,
|
||||
edit,
|
||||
}
|
||||
|
||||
extension ExtraAppMode on AppMode {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
17
lib/main_play_test_editor.dart
Normal file
17
lib/main_play_test_editor.dart
Normal file
|
@ -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',
|
||||
},
|
||||
);
|
|
@ -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<Dependency> 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,
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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<Offset, Offset> s1, Tuple2<Offset, Offset> 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);
|
||||
}
|
||||
|
|
57
lib/view/src/editor/enums.dart
Normal file
57
lib/view/src/editor/enums.dart
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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<AvesApp> 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<AvesApp> with WidgetsBindingObserver {
|
|||
case AppMode.setWallpaper:
|
||||
case AppMode.slideshow:
|
||||
case AppMode.view:
|
||||
case AppMode.edit:
|
||||
break;
|
||||
}
|
||||
case AppLifecycleState.resumed:
|
||||
|
|
|
@ -208,6 +208,7 @@ class _CollectionPageState extends State<CollectionPage> {
|
|||
case AppMode.setWallpaper:
|
||||
case AppMode.slideshow:
|
||||
case AppMode.view:
|
||||
case AppMode.edit:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -54,6 +54,7 @@ class InteractiveTile extends StatelessWidget {
|
|||
case AppMode.setWallpaper:
|
||||
case AppMode.slideshow:
|
||||
case AppMode.view:
|
||||
case AppMode.edit:
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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<MultiCrossFader> {
|
|||
alignment: widget.alignment,
|
||||
crossFadeState: _fadeState,
|
||||
duration: widget.duration,
|
||||
reverseDuration: widget.duration,
|
||||
layoutBuilder: widget.layoutBuilder,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
7
lib/widgets/common/extensions/geometry.dart
Normal file
7
lib/widgets/common/extensions/geometry.dart
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
142
lib/widgets/common/fx/dashed_path_painter.dart
Normal file
142
lib/widgets/common/fx/dashed_path_painter.dart
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
|
||||
|
|
|
@ -101,7 +101,7 @@ class _GridItemTrackerState<T> extends State<GridItemTracker<T>> 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;
|
||||
|
||||
|
|
137
lib/widgets/editor/control_panel.dart
Normal file
137
lib/widgets/editor/control_panel.dart
Normal file
|
@ -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<EditorAction?> 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<EditorAction?>(
|
||||
valueListenable: actionNotifier,
|
||||
builder: (context, action, child) {
|
||||
return MultiCrossFader(
|
||||
duration: context.select<DurationsData, Duration>((v) => v.formTransition),
|
||||
alignment: Alignment.bottomCenter,
|
||||
layoutBuilder: (topChild, topChildKey, bottomChild, bottomChildKey) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: <Widget>[
|
||||
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<TransformController>().reset();
|
||||
}
|
||||
|
||||
void _applyAction(BuildContext context) {
|
||||
actionNotifier.value = null;
|
||||
context.read<TransformController>().reset();
|
||||
}
|
||||
}
|
136
lib/widgets/editor/entry_editor_page.dart
Normal file
136
lib/widgets/editor/entry_editor_page.dart
Normal file
|
@ -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<ImageEditorPage> createState() => _ImageEditorPageState();
|
||||
}
|
||||
|
||||
class _ImageEditorPageState extends State<ImageEditorPage> {
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
final ValueNotifier<EditorAction?> _actionNotifier = ValueNotifier(null);
|
||||
final ValueNotifier<EdgeInsets> _paddingNotifier = ValueNotifier(EdgeInsets.zero);
|
||||
final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier<ViewState>(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<TransformController>.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<EditorAction?>(
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
224
lib/widgets/editor/image.dart
Normal file
224
lib/widgets/editor/image.dart
Normal file
|
@ -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<EditorAction?> actionNotifier;
|
||||
final ValueNotifier<EdgeInsets> paddingNotifier;
|
||||
final ValueNotifier<ViewState> 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<EditorImage> createState() => _EditorImageState();
|
||||
}
|
||||
|
||||
class _EditorImageState extends State<EditorImage> {
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
final ValueNotifier<double> _scrimOpacityNotifier = ValueNotifier(0);
|
||||
|
||||
AvesEntry get entry => widget.entry;
|
||||
|
||||
TransformController get transformController => widget.transformController;
|
||||
|
||||
ValueNotifier<ViewState> 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<Transformation>(
|
||||
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<EdgeInsets>(
|
||||
valueListenable: widget.paddingNotifier,
|
||||
builder: (context, padding, child) {
|
||||
return Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: imageToUserMatrix,
|
||||
child: ValueListenableBuilder<EditorAction?>(
|
||||
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<ViewState>(
|
||||
valueListenable: viewStateNotifier,
|
||||
builder: (context, viewState, child) {
|
||||
final scale = viewState.scale ?? 1;
|
||||
final highlightRegionPath = Path()..addPolygon(highlightRegionCorners.map((v) => v * scale).toList(), true);
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: _scrimOpacityNotifier,
|
||||
builder: (context, opacity, child) {
|
||||
return AnimatedOpacity(
|
||||
opacity: opacity,
|
||||
duration: context.read<DurationsData>().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;
|
||||
}
|
||||
}
|
||||
}
|
196
lib/widgets/editor/transform/control_panel.dart
Normal file
196
lib/widgets/editor/transform/control_panel.dart
Normal file
|
@ -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<TransformControlPanel> createState() => _TransformControlPanelState();
|
||||
}
|
||||
|
||||
class _TransformControlPanelState extends State<TransformControlPanel> with TickerProviderStateMixin {
|
||||
late final List<Tuple2<WidgetBuilder, WidgetBuilder>> _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<TransformController>();
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: CropControlPanel.preferredHeight(context),
|
||||
child: AnimatedBuilder(
|
||||
animation: _tabController,
|
||||
builder: (context, child) {
|
||||
return AnimatedSwitcher(
|
||||
duration: context.select<DurationsData, Duration>((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<Transformation>(
|
||||
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<TransformController, ValueNotifier<CropAspectRatio>>((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<CropAspectRatio>(
|
||||
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<TransformController>();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
_buildButton(context, EntryAction.flip, controller.flipHorizontally),
|
||||
Expanded(
|
||||
child: StreamBuilder<Transformation>(
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
88
lib/widgets/editor/transform/controller.dart
Normal file
88
lib/widgets/editor/transform/controller.dart
Normal file
|
@ -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<CropAspectRatio> 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<Transformation> _transformationStreamController = StreamController.broadcast();
|
||||
|
||||
Stream<Transformation> get transformationStream => _transformationStreamController.stream;
|
||||
|
||||
final StreamController<TransformEvent> _eventStreamController = StreamController.broadcast();
|
||||
|
||||
Stream<TransformEvent> 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
|
||||
}
|
||||
}
|
48
lib/widgets/editor/transform/crop_region.dart
Normal file
48
lib/widgets/editor/transform/crop_region.dart
Normal file
|
@ -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<Offset> 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<Object?> 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,
|
||||
);
|
||||
}
|
||||
}
|
734
lib/widgets/editor/transform/cropper.dart
Normal file
734
lib/widgets/editor/transform/cropper.dart
Normal file
|
@ -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<EdgeInsets> 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<Cropper> createState() => _CropperState();
|
||||
}
|
||||
|
||||
class _CropperState extends State<Cropper> with SingleTickerProviderStateMixin {
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
final ValueNotifier<Size> _viewportSizeNotifier = ValueNotifier(Size.zero);
|
||||
final ValueNotifier<Rect> _outlineNotifier = ValueNotifier(Rect.zero);
|
||||
final ValueNotifier<int> _gridDivisionNotifier = ValueNotifier(0);
|
||||
late AnimationController _gridAnimationController;
|
||||
late Animation<double> _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<DurationsData>().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<EdgeInsets>(
|
||||
valueListenable: widget.paddingNotifier,
|
||||
builder: (context, padding, child) {
|
||||
return ValueListenableBuilder<Rect>(
|
||||
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<int>(
|
||||
valueListenable: _gridDivisionNotifier,
|
||||
builder: (context, gridDivision, child) {
|
||||
return ValueListenableBuilder<double>(
|
||||
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<Offset> 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<Offset> getPosition,
|
||||
required ValueSetter<Offset> setPosition,
|
||||
}) {
|
||||
return VertexHandle(
|
||||
padding: padding,
|
||||
getPosition: getPosition,
|
||||
setPosition: setPosition,
|
||||
onDragStart: _onDragStart,
|
||||
onDragEnd: _onDragEnd,
|
||||
);
|
||||
}
|
||||
|
||||
EdgeHandle _buildEdgeHandle({
|
||||
required EdgeInsets padding,
|
||||
required ValueGetter<Rect> getEdge,
|
||||
required ValueSetter<Rect> 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 }
|
120
lib/widgets/editor/transform/handles.dart
Normal file
120
lib/widgets/editor/transform/handles.dart
Normal file
|
@ -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<Offset> getPosition;
|
||||
final ValueSetter<Offset> 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<VertexHandle> createState() => _VertexHandleState();
|
||||
}
|
||||
|
||||
class _VertexHandleState extends State<VertexHandle> {
|
||||
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<Rect> getEdge;
|
||||
final ValueSetter<Rect> 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<EdgeHandle> createState() => _EdgeHandleState();
|
||||
}
|
||||
|
||||
class _EdgeHandleState extends State<EdgeHandle> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
131
lib/widgets/editor/transform/painter.dart
Normal file
131
lib/widgets/editor/transform/painter.dart
Normal file
|
@ -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;
|
||||
}
|
87
lib/widgets/editor/transform/transformation.dart
Normal file
87
lib/widgets/editor/transform/transformation.dart
Normal file
|
@ -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<Object?> 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,
|
||||
});
|
||||
}
|
|
@ -72,6 +72,7 @@ class _InteractiveFilterTileState<T extends CollectionFilter> extends State<Inte
|
|||
case AppMode.setWallpaper:
|
||||
case AppMode.slideshow:
|
||||
case AppMode.view:
|
||||
case AppMode.edit:
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -23,8 +23,10 @@ import 'package:aves/widgets/common/behaviour/routes.dart';
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/search/page.dart';
|
||||
import 'package:aves/widgets/common/search/route.dart';
|
||||
import 'package:aves/widgets/editor/entry_editor_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/tags_page.dart';
|
||||
import 'package:aves/widgets/intent.dart';
|
||||
import 'package:aves/widgets/search/search_delegate.dart';
|
||||
import 'package:aves/widgets/settings/home_widget_settings_page.dart';
|
||||
import 'package:aves/widgets/settings/screen_saver_settings_page.dart';
|
||||
|
@ -58,26 +60,6 @@ class _HomePageState extends State<HomePage> {
|
|||
String? _initialRouteName, _initialSearchQuery;
|
||||
Set<CollectionFilter>? _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<HomePage> {
|
|||
|
||||
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<HomePage> {
|
|||
}
|
||||
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<HomePage> {
|
|||
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<String>().map(CollectionFilter.fromJson).whereNotNull().toSet() : null;
|
||||
}
|
||||
}
|
||||
|
@ -219,6 +216,7 @@ class _HomePageState extends State<HomePage> {
|
|||
} else {
|
||||
await _initViewerEssentials();
|
||||
}
|
||||
case AppMode.edit:
|
||||
case AppMode.setWallpaper:
|
||||
await _initViewerEssentials();
|
||||
case AppMode.pickMediaInternal:
|
||||
|
@ -258,7 +256,13 @@ class _HomePageState extends State<HomePage> {
|
|||
}
|
||||
|
||||
Future<Route> _getRedirectRoute(AppMode appMode) async {
|
||||
if (appMode == AppMode.setWallpaper) {
|
||||
String routeName;
|
||||
Set<CollectionFilter?>? filters;
|
||||
switch (appMode) {
|
||||
case AppMode.pickSingleMediaExternal:
|
||||
case AppMode.pickMultipleMediaExternal:
|
||||
routeName = CollectionPage.routeName;
|
||||
case AppMode.setWallpaper:
|
||||
return DirectMaterialPageRoute(
|
||||
settings: const RouteSettings(name: WallpaperPage.routeName),
|
||||
builder: (_) {
|
||||
|
@ -267,9 +271,7 @@ class _HomePageState extends State<HomePage> {
|
|||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (appMode == AppMode.view) {
|
||||
case AppMode.view:
|
||||
AvesEntry viewerEntry = _viewerEntry!;
|
||||
CollectionLens? collection;
|
||||
|
||||
|
@ -318,15 +320,21 @@ class _HomePageState extends State<HomePage> {
|
|||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String routeName;
|
||||
Set<CollectionFilter?>? filters;
|
||||
switch (appMode) {
|
||||
case AppMode.pickSingleMediaExternal:
|
||||
case AppMode.pickMultipleMediaExternal:
|
||||
routeName = CollectionPage.routeName;
|
||||
default:
|
||||
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 ?? {};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
24
lib/widgets/intent.dart
Normal file
24
lib/widgets/intent.dart
Normal file
|
@ -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';
|
||||
}
|
|
@ -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<ViewState> 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 StreamBuilder<Transformation?>(
|
||||
stream: context.select<TransformController?, Stream<Transformation?>>((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
|
||||
|
|
|
@ -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<Uint8List?> _getBytes(BuildContext context, Rect displayRegion) async {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<EntryPageView> 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<EntryPageView> 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<EntryPageView> 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<EntryPageView> 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<EntryPageView> with SingleTickerProvider
|
|||
void _onViewScaleBoundariesChanged(ScaleBoundaries v) {
|
||||
_viewStateNotifier.value = _viewStateNotifier.value.copyWith(
|
||||
viewportSize: v.viewportSize,
|
||||
contentSize: v.childSize,
|
||||
contentSize: v.contentSize,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -115,7 +115,7 @@ class _VideoCoverState extends State<VideoCover> {
|
|||
if (boundaries != null) {
|
||||
magnifierController.setScaleBoundaries(
|
||||
boundaries.copyWith(
|
||||
childSize: videoDisplaySize,
|
||||
contentSize: videoDisplaySize,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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<MagnifierCore> {
|
||||
mixin AvesMagnifierControllerDelegate on State<AvesMagnifier> {
|
||||
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<MagnifierCore> {
|
|||
|
||||
final List<StreamSubscription> _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<MagnifierCore> {
|
|||
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<MagnifierCore> {
|
|||
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<MagnifierCore> {
|
|||
|
||||
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<MagnifierCore> {
|
|||
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;
|
||||
}
|
||||
|
|
15
plugins/aves_magnifier/lib/src/controller/range.dart
Normal file
15
plugins/aves_magnifier/lib/src/controller/range.dart
Normal file
|
@ -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<Object?> get props => [min, max];
|
||||
|
||||
const EdgeRange(this.min, this.max);
|
||||
|
||||
static const EdgeRange zero = EdgeRange(0, 0);
|
||||
}
|
|
@ -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<StatefulWidget> createState() => _MagnifierCoreState();
|
||||
State<StatefulWidget> createState() => _AvesMagnifierState();
|
||||
}
|
||||
|
||||
class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMixin, AvesMagnifierControllerDelegate, EdgeHitDetector {
|
||||
class _AvesMagnifierState extends State<AvesMagnifier> with TickerProviderStateMixin, AvesMagnifierControllerDelegate, EdgeHitDetector {
|
||||
Offset? _startFocalPoint, _lastViewportFocalPosition;
|
||||
double? _startScale, _quickScaleLastY, _quickScaleLastDistance;
|
||||
late bool _dropped, _doubleTap, _quickScaleMoved;
|
||||
|
@ -77,13 +114,23 @@ class _MagnifierCoreState extends State<MagnifierCore> 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<MagnifierCore> 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<MagnifierCore> 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<MagnifierCore> 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<Offset>(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<Offset>(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<MagnifierCore> with TickerProviderStateM
|
|||
}
|
||||
}
|
||||
|
||||
if (_scale != _startScale) {
|
||||
if (currentScale != _startScale) {
|
||||
_lastScaleGestureDate = DateTime.now();
|
||||
}
|
||||
}
|
||||
|
@ -307,7 +365,7 @@ class _MagnifierCoreState extends State<MagnifierCore> 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<MagnifierCore> 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<MagnifierCore> 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<MagnifierCore> 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<MagnifierCore> 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);
|
||||
|
|
|
@ -58,12 +58,12 @@ class _MagnifierGestureDetectorState extends State<MagnifierGestureDetector> {
|
|||
gestures[MagnifierGestureRecognizer] = GestureRecognizerFactoryWithHandlers<MagnifierGestureRecognizer>(
|
||||
() => 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
|
||||
|
|
|
@ -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<TapDownDetails?> 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();
|
||||
|
|
|
@ -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);
|
|
@ -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<Object?> get props => [hasHitMin, hasHitMax];
|
||||
|
||||
const EdgeHit(this.hasHitMin, this.hasHitMax);
|
||||
|
||||
bool get hasHitAny => hasHitMin || hasHitMax;
|
||||
|
||||
bool get hasHitBoth => hasHitMin && hasHitMax;
|
||||
|
|
|
@ -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<Object?> get props => [_allowOriginalScaleBeyondRange, _minScale, _maxScale, _initialScale, viewportSize, childSize];
|
||||
List<Object?> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -8,6 +8,8 @@ environment:
|
|||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
aves_utils:
|
||||
path: ../aves_utils
|
||||
equatable:
|
||||
provider:
|
||||
tuple:
|
||||
|
|
|
@ -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';
|
||||
|
|
66
plugins/aves_model/lib/src/editor/enums.dart
Normal file
66
plugins/aves_model/lib/src/editor/enums.dart
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,3 +2,4 @@ library aves_utils;
|
|||
|
||||
export 'src/change_notifier.dart';
|
||||
export 'src/optional_event_channel.dart';
|
||||
export 'src/vector_utils.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?
|
||||
|
|
15
plugins/aves_utils/lib/src/vector_utils.dart
Normal file
15
plugins/aves_utils/lib/src/vector_utils.dart
Normal file
|
@ -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;
|
||||
}
|
|
@ -68,7 +68,7 @@ packages:
|
|||
source: sdk
|
||||
version: "0.0.99"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
|
|
|
@ -8,6 +8,7 @@ environment:
|
|||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
vector_math:
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints:
|
||||
|
|
|
@ -1462,7 +1462,7 @@ packages:
|
|||
source: hosted
|
||||
version: "3.0.6"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
|
|
|
@ -113,6 +113,7 @@ dependencies:
|
|||
transparent_image:
|
||||
tuple:
|
||||
url_launcher:
|
||||
vector_math:
|
||||
volume_controller:
|
||||
xml:
|
||||
|
||||
|
|
90
test/model/view_state_test.dart
Normal file
90
test/model/view_state_test.dart
Normal file
|
@ -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);
|
||||
}
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue