lab: transform

This commit is contained in:
Thibault Deckers 2023-05-14 12:50:08 +02:00
parent 28973ec322
commit b1920dbe1c
70 changed files with 3409 additions and 614 deletions

View file

@ -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" />

View file

@ -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"

View file

@ -9,6 +9,7 @@ enum AppMode {
setWallpaper,
slideshow,
view,
edit,
}
extension ExtraAppMode on AppMode {

View file

@ -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",

View file

@ -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));
}

View 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',
},
);

View file

@ -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,

View file

@ -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));

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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);
}

View 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;
}
}
}

View file

@ -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';

View file

@ -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:

View file

@ -208,6 +208,7 @@ class _CollectionPageState extends State<CollectionPage> {
case AppMode.setWallpaper:
case AppMode.slideshow:
case AppMode.view:
case AppMode.edit:
return null;
}
}

View file

@ -54,6 +54,7 @@ class InteractiveTile extends StatelessWidget {
case AppMode.setWallpaper:
case AppMode.slideshow:
case AppMode.view:
case AppMode.edit:
break;
}
},

View file

@ -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,
);
}
}

View 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;
}
}

View file

@ -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);

View 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;
}
}

View file

@ -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());

View file

@ -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;

View 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();
}
}

View 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,
),
);
}
}
}

View 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;
}
}
}

View 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),
),
);
}
}

View 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
}
}

View 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,
);
}
}

View 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 }

View 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,
),
),
);
}
}

View 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;
}

View 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,
});
}

View file

@ -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;
}

View file

@ -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 ?? {};
}

View file

@ -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
View 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';
}

View file

@ -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

View file

@ -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 {

View file

@ -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,

View file

@ -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,
);
}

View file

@ -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';

View file

@ -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';

View file

@ -115,7 +115,7 @@ class _VideoCoverState extends State<VideoCover> {
if (boundaries != null) {
magnifierController.setScaleBoundaries(
boundaries.copyWith(
childSize: videoDisplaySize,
contentSize: videoDisplaySize,
),
);
}

View file

@ -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';

View file

@ -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';

View file

@ -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;
}

View file

@ -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;
}

View 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);
}

View file

@ -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);

View file

@ -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

View file

@ -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();

View file

@ -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);

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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 }

View file

@ -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;
}
}

View file

@ -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:

View file

@ -8,6 +8,8 @@ environment:
dependencies:
flutter:
sdk: flutter
aves_utils:
path: ../aves_utils
equatable:
provider:
tuple:

View file

@ -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';

View 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;
}
}
}

View file

@ -2,3 +2,4 @@ library aves_utils;
export 'src/change_notifier.dart';
export 'src/optional_event_channel.dart';
export 'src/vector_utils.dart';

View file

@ -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?

View 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;
}

View file

@ -68,7 +68,7 @@ packages:
source: sdk
version: "0.0.99"
vector_math:
dependency: transitive
dependency: "direct main"
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"

View file

@ -8,6 +8,7 @@ environment:
dependencies:
flutter:
sdk: flutter
vector_math:
dev_dependencies:
flutter_lints:

View file

@ -1462,7 +1462,7 @@ packages:
source: hosted
version: "3.0.6"
vector_math:
dependency: transitive
dependency: "direct main"
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"

View file

@ -113,6 +113,7 @@ dependencies:
transparent_image:
tuple:
url_launcher:
vector_math:
volume_controller:
xml:

View 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);
}

View file

@ -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));
});
}

View file

@ -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",