diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index eec888a79..014a84516 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -145,6 +145,15 @@ This change eventually prevents building the app with Flutter v3.7.11.
+
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt
index 2e46d64d2..abab47814 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt
@@ -277,6 +277,18 @@ open class MainActivity : FlutterFragmentActivity() {
}
}
+ Intent.ACTION_EDIT -> {
+ (intent.data ?: intent.getParcelableExtraCompat(Intent.EXTRA_STREAM))?.let { uri ->
+ // MIME type is optional
+ val type = intent.type ?: intent.resolveType(this)
+ return hashMapOf(
+ INTENT_DATA_KEY_ACTION to INTENT_ACTION_EDIT,
+ INTENT_DATA_KEY_MIME_TYPE to type,
+ INTENT_DATA_KEY_URI to uri.toString(),
+ )
+ }
+ }
+
Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> {
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK_ITEMS,
@@ -433,6 +445,7 @@ open class MainActivity : FlutterFragmentActivity() {
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6
const val PICK_COLLECTION_FILTERS_REQUEST = 7
+ const val INTENT_ACTION_EDIT = "edit"
const val INTENT_ACTION_PICK_ITEMS = "pick_items"
const val INTENT_ACTION_PICK_COLLECTION_FILTERS = "pick_collection_filters"
const val INTENT_ACTION_SCREEN_SAVER = "screen_saver"
diff --git a/lib/app_mode.dart b/lib/app_mode.dart
index 99dd00ef4..8135170b4 100644
--- a/lib/app_mode.dart
+++ b/lib/app_mode.dart
@@ -9,6 +9,7 @@ enum AppMode {
setWallpaper,
slideshow,
view,
+ edit,
}
extension ExtraAppMode on AppMode {
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index b8c620c98..a83d54d89 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -50,7 +50,9 @@
"showButtonLabel": "SHOW",
"hideButtonLabel": "HIDE",
"continueButtonLabel": "CONTINUE",
+ "saveCopyButtonLabel": "SAVE COPY",
+ "applyTooltip": "Apply",
"cancelTooltip": "Cancel",
"changeTooltip": "Change",
"clearTooltip": "Clear",
@@ -141,6 +143,15 @@
"entryInfoActionExportMetadata": "Export metadata",
"entryInfoActionRemoveLocation": "Remove location",
+ "editorActionTransform": "Transform",
+
+ "editorTransformCrop": "Crop",
+ "editorTransformRotate": "Rotate",
+
+ "cropAspectRatioFree": "Free",
+ "cropAspectRatioOriginal": "Original",
+ "cropAspectRatioSquare": "Square",
+
"filterAspectRatioLandscapeLabel": "Landscape",
"filterAspectRatioPortraitLabel": "Portrait",
"filterBinLabel": "Recycle bin",
diff --git a/lib/main_common.dart b/lib/main_common.dart
index a02f671be..f525bab0d 100644
--- a/lib/main_common.dart
+++ b/lib/main_common.dart
@@ -5,7 +5,7 @@ import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/aves_app.dart';
import 'package:flutter/material.dart';
-void mainCommon(AppFlavor flavor) {
+void mainCommon(AppFlavor flavor, {Map? debugIntentData}) {
// HttpClient.enableTimelineLogging = true; // enable network traffic logging
// debugPrintGestureArenaDiagnostics = true;
@@ -35,5 +35,5 @@ void mainCommon(AppFlavor flavor) {
// ErrorWidget.builder = (details) => ErrorWidget(details.exception);
// cf https://docs.flutter.dev/testing/errors
- runApp(AvesApp(flavor: flavor));
+ runApp(AvesApp(flavor: flavor, debugIntentData: debugIntentData));
}
diff --git a/lib/main_play_test_editor.dart b/lib/main_play_test_editor.dart
new file mode 100644
index 000000000..95e5f4d6c
--- /dev/null
+++ b/lib/main_play_test_editor.dart
@@ -0,0 +1,17 @@
+import 'package:aves/app_flavor.dart';
+import 'package:aves/main_common.dart';
+import 'package:aves/widgets/intent.dart';
+
+// https://developer.android.com/studio/command-line/adb.html#IntentSpec
+// adb shell am start -n deckers.thibault.aves.debug/deckers.thibault.aves.MainActivity -a android.intent.action.EDIT -d content://media/external/images/media/183128 -t image/*
+
+@pragma('vm:entry-point')
+void main() => mainCommon(
+ AppFlavor.play,
+ debugIntentData: {
+ IntentDataKeys.action: IntentActions.edit,
+ IntentDataKeys.mimeType: 'image/*',
+ IntentDataKeys.uri: 'content://media/external/images/media/183128',
+ // IntentDataKeys.uri: 'content://media/external/images/media/183534',
+ },
+ );
diff --git a/lib/model/app/dependencies.dart b/lib/model/app/dependencies.dart
index 48fdf5e41..794029ea8 100644
--- a/lib/model/app/dependencies.dart
+++ b/lib/model/app/dependencies.dart
@@ -6,6 +6,7 @@ class Dependencies {
static const String bsd3 = 'BSD 3-Clause “Revised” License';
static const String eclipse1 = 'Eclipse Public License 1.0';
static const String mit = 'MIT License';
+ static const String zlib = 'zlib License';
static const List androidDependencies = [
Dependency(
@@ -369,6 +370,11 @@ class Dependencies {
license: bsd2,
sourceUrl: 'https://github.com/google/tuple.dart',
),
+ Dependency(
+ name: 'Vector Math',
+ license: '$zlib, $bsd3',
+ sourceUrl: 'https://github.com/google/vector_math.dart',
+ ),
Dependency(
name: 'XML',
license: mit,
diff --git a/lib/model/settings/enums/widget_shape.dart b/lib/model/settings/enums/widget_shape.dart
index adb2d6730..28a64f3ab 100644
--- a/lib/model/settings/enums/widget_shape.dart
+++ b/lib/model/settings/enums/widget_shape.dart
@@ -4,7 +4,7 @@ import 'package:flutter/painting.dart';
extension ExtraWidgetShape on WidgetShape {
Path path(Size widgetSize, double devicePixelRatio) {
- final rect = Rect.fromLTWH(0, 0, widgetSize.width, widgetSize.height);
+ final rect = Offset.zero & widgetSize;
switch (this) {
case WidgetShape.rrect:
return Path()..addRRect(BorderRadius.circular(24 * devicePixelRatio).toRRect(rect));
diff --git a/lib/widgets/viewer/visual/state.dart b/lib/model/view_state.dart
similarity index 66%
rename from lib/widgets/viewer/visual/state.dart
rename to lib/model/view_state.dart
index a523d7eb5..6e8c573dc 100644
--- a/lib/widgets/viewer/visual/state.dart
+++ b/lib/model/view_state.dart
@@ -37,4 +37,18 @@ class ViewState extends Equatable {
contentSize: contentSize ?? this.contentSize,
);
}
+
+ Matrix4 get matrix {
+ final _viewportSize = viewportSize ?? Size.zero;
+ final _contentSize = contentSize ?? Size.zero;
+ final _scale = scale ?? 1.0;
+
+ final scaledContentSize = _contentSize * _scale;
+ final viewOffset = _viewportSize.center(Offset.zero) - scaledContentSize.center(Offset.zero);
+
+ return Matrix4.identity()
+ ..translate(position.dx, position.dy)
+ ..translate(viewOffset.dx, viewOffset.dy)
+ ..scale(_scale, _scale, 1);
+ }
}
diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart
index 4c683e908..8f46027fa 100644
--- a/lib/theme/icons.dart
+++ b/lib/theme/icons.dart
@@ -2,172 +2,180 @@ import 'package:flutter/material.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
class AIcons {
- static const IconData allCollection = Icons.collections_outlined;
- static const IconData image = Icons.photo_outlined;
- static const IconData video = Icons.movie_outlined;
- static const IconData vector = Icons.code_outlined;
+ static const allCollection = Icons.collections_outlined;
+ static const image = Icons.photo_outlined;
+ static const video = Icons.movie_outlined;
+ static const vector = Icons.code_outlined;
- static const IconData accessibility = Icons.accessibility_new_outlined;
- static const IconData android = Icons.android;
- static const IconData app = Icons.apps_outlined;
- static const IconData apply = Icons.done_outlined;
- static const IconData aspectRatio = Icons.aspect_ratio_outlined;
- static const IconData bin = Icons.delete_outlined;
- static const IconData broken = Icons.broken_image_outlined;
- static const IconData brightnessMin = Icons.brightness_low_outlined;
- static const IconData brightnessMax = Icons.brightness_high_outlined;
- static const IconData checked = Icons.done_outlined;
- static const IconData count = MdiIcons.counter;
- static const IconData counter = Icons.plus_one_outlined;
- static const IconData date = Icons.calendar_today_outlined;
- static const IconData dateByDay = Icons.today_outlined;
- static const IconData dateByMonth = Icons.calendar_month_outlined;
- static const IconData dateRecent = Icons.today_outlined;
- static const IconData dateUndated = Icons.event_busy_outlined;
- static const IconData description = Icons.description_outlined;
- static const IconData descriptionUntitled = Icons.comments_disabled_outlined;
- static const IconData disc = Icons.fiber_manual_record;
- static const IconData display = Icons.light_mode_outlined;
- static const IconData error = Icons.error_outline;
- static const IconData folder = Icons.folder_outlined;
- static const IconData grid = Icons.grid_on_outlined;
- static const IconData home = Icons.home_outlined;
- static const IconData important = Icons.label_important_outline;
- static const IconData language = Icons.translate_outlined;
- static const IconData location = Icons.place_outlined;
- static const IconData locationUnlocated = Icons.location_off_outlined;
- static const IconData country = Icons.flag_outlined;
- static const IconData state = Icons.flag_outlined;
- static const IconData place = Icons.place_outlined;
- static const IconData mainStorage = Icons.smartphone_outlined;
- static const IconData mimeType = Icons.code_outlined;
- static const IconData opacity = Icons.opacity;
- static const IconData privacy = MdiIcons.shieldAccountOutline;
- static const IconData rating = Icons.star_border_outlined;
- static const IconData ratingFull = Icons.star;
- static const IconData ratingRejected = MdiIcons.starMinusOutline;
- static const IconData ratingUnrated = MdiIcons.starOffOutline;
- static const IconData raw = Icons.raw_on_outlined;
- static const IconData shooting = Icons.camera_outlined;
- static const IconData removableStorage = Icons.sd_storage_outlined;
- static const IconData sensorControlEnabled = Icons.explore_outlined;
- static const IconData sensorControlDisabled = Icons.explore_off_outlined;
- static const IconData settings = Icons.settings_outlined;
- static const IconData size = Icons.data_usage_outlined;
- static const IconData text = Icons.format_quote_outlined;
- static const IconData tag = Icons.local_offer_outlined;
- static const IconData tagUntagged = MdiIcons.tagOffOutline;
- static const IconData volumeMin = Icons.volume_mute_outlined;
- static const IconData volumeMax = Icons.volume_up_outlined;
+ static const accessibility = Icons.accessibility_new_outlined;
+ static const android = Icons.android;
+ static const app = Icons.apps_outlined;
+ static const apply = Icons.done_outlined;
+ static const aspectRatio = Icons.aspect_ratio_outlined;
+ static const bin = Icons.delete_outlined;
+ static const broken = Icons.broken_image_outlined;
+ static const brightnessMin = Icons.brightness_low_outlined;
+ static const brightnessMax = Icons.brightness_high_outlined;
+ static const checked = Icons.done_outlined;
+ static const count = MdiIcons.counter;
+ static const counter = Icons.plus_one_outlined;
+ static const date = Icons.calendar_today_outlined;
+ static const dateByDay = Icons.today_outlined;
+ static const dateByMonth = Icons.calendar_month_outlined;
+ static const dateRecent = Icons.today_outlined;
+ static const dateUndated = Icons.event_busy_outlined;
+ static const description = Icons.description_outlined;
+ static const descriptionUntitled = Icons.comments_disabled_outlined;
+ static const disc = Icons.fiber_manual_record;
+ static const display = Icons.light_mode_outlined;
+ static const error = Icons.error_outline;
+ static const folder = Icons.folder_outlined;
+ static const grid = Icons.grid_on_outlined;
+ static const home = Icons.home_outlined;
+ static const important = Icons.label_important_outline;
+ static const language = Icons.translate_outlined;
+ static const location = Icons.place_outlined;
+ static const locationUnlocated = Icons.location_off_outlined;
+ static const country = Icons.flag_outlined;
+ static const state = Icons.flag_outlined;
+ static const place = Icons.place_outlined;
+ static const mainStorage = Icons.smartphone_outlined;
+ static const mimeType = Icons.code_outlined;
+ static const opacity = Icons.opacity;
+ static const privacy = MdiIcons.shieldAccountOutline;
+ static const rating = Icons.star_border_outlined;
+ static const ratingFull = Icons.star;
+ static const ratingRejected = MdiIcons.starMinusOutline;
+ static const ratingUnrated = MdiIcons.starOffOutline;
+ static const raw = Icons.raw_on_outlined;
+ static const shooting = Icons.camera_outlined;
+ static const removableStorage = Icons.sd_storage_outlined;
+ static const sensorControlEnabled = Icons.explore_outlined;
+ static const sensorControlDisabled = Icons.explore_off_outlined;
+ static const settings = Icons.settings_outlined;
+ static const size = Icons.data_usage_outlined;
+ static const text = Icons.format_quote_outlined;
+ static const tag = Icons.local_offer_outlined;
+ static const tagUntagged = MdiIcons.tagOffOutline;
+ static const volumeMin = Icons.volume_mute_outlined;
+ static const volumeMax = Icons.volume_up_outlined;
// view
- static const IconData group = Icons.group_work_outlined;
- static const IconData layout = Icons.grid_view_outlined;
- static const IconData layoutMosaic = Icons.view_comfy_outlined;
- static const IconData layoutGrid = Icons.view_compact_outlined;
- static const IconData layoutList = Icons.list_outlined;
- static const IconData sort = Icons.sort_outlined;
- static const IconData sortOrder = Icons.swap_vert_outlined;
- static const IconData thumbnailLarge = Icons.photo_size_select_large_outlined;
- static const IconData thumbnailSmall = Icons.photo_size_select_small_outlined;
+ static const group = Icons.group_work_outlined;
+ static const layout = Icons.grid_view_outlined;
+ static const layoutMosaic = Icons.view_comfy_outlined;
+ static const layoutGrid = Icons.view_compact_outlined;
+ static const layoutList = Icons.list_outlined;
+ static const sort = Icons.sort_outlined;
+ static const sortOrder = Icons.swap_vert_outlined;
+ static const thumbnailLarge = Icons.photo_size_select_large_outlined;
+ static const thumbnailSmall = Icons.photo_size_select_small_outlined;
// actions
- static const IconData add = Icons.add_circle_outline;
- static const IconData addShortcut = Icons.add_to_home_screen_outlined;
- static const IconData cancel = Icons.cancel_outlined;
- static const IconData captureFrame = Icons.screenshot_outlined;
- static const IconData clear = Icons.clear_outlined;
- static const IconData clipboard = Icons.content_copy_outlined;
- static const IconData convert = Icons.transform_outlined;
- static const IconData convertToStillImage = MdiIcons.movieRemoveOutline;
- static const IconData copy = Icons.file_copy_outlined;
- static const IconData debug = Icons.whatshot_outlined;
- static const IconData delete = Icons.delete_outlined;
- static const IconData edit = Icons.edit_outlined;
- static const IconData emptyBin = Icons.delete_sweep_outlined;
- static const IconData export = Icons.open_with_outlined;
- static const IconData fileExport = MdiIcons.fileExportOutline;
- static const IconData fileImport = MdiIcons.fileImportOutline;
- static const IconData flip = Icons.flip_outlined;
- static const IconData favourite = Icons.favorite_border;
- static const IconData favouriteActive = Icons.favorite;
- static const IconData filter = MdiIcons.filterOutline;
- static const IconData filterOff = MdiIcons.filterOffOutline;
- static const IconData geoBounds = Icons.public_outlined;
- static const IconData goUp = Icons.arrow_upward_outlined;
- static const IconData hide = Icons.visibility_off_outlined;
- static const IconData info = Icons.info_outlined;
- static const IconData layers = Icons.layers_outlined;
- static const IconData map = Icons.map_outlined;
- static const IconData move = MdiIcons.fileMoveOutline;
- static const IconData mute = Icons.volume_off_outlined;
- static const IconData unmute = Icons.volume_up_outlined;
- static const IconData name = Icons.abc_outlined;
- static const IconData newTier = Icons.fiber_new_outlined;
- static const IconData openOutside = Icons.open_in_new_outlined;
- static const IconData openVideo = MdiIcons.moviePlayOutline;
- static const IconData pin = Icons.push_pin_outlined;
- static const IconData unpin = MdiIcons.pinOffOutline;
- static const IconData play = Icons.play_arrow;
- static const IconData pause = Icons.pause;
- static const IconData print = Icons.print_outlined;
- static const IconData refresh = Icons.refresh_outlined;
- static const IconData replay10 = Icons.replay_10_outlined;
- static const IconData reverse = Icons.invert_colors_outlined;
- static const IconData skip10 = Icons.forward_10_outlined;
- static const IconData reset = Icons.restart_alt_outlined;
- static const IconData restore = Icons.restore_outlined;
- static const IconData rotateLeft = Icons.rotate_left_outlined;
- static const IconData rotateRight = Icons.rotate_right_outlined;
- static const IconData rotateScreen = Icons.screen_rotation_outlined;
- static const IconData search = Icons.search_outlined;
- static const IconData select = Icons.select_all_outlined;
- static const IconData setAs = Icons.wallpaper_outlined;
- static const IconData setCover = MdiIcons.imageEditOutline;
- static const IconData share = Icons.share_outlined;
- static const IconData show = Icons.visibility_outlined;
- static const IconData showFullscreen = MdiIcons.arrowExpand;
- static const IconData slideshow = Icons.slideshow_outlined;
- static const IconData speed = Icons.speed_outlined;
- static const IconData stats = Icons.donut_small_outlined;
- static const IconData streams = Icons.translate_outlined;
- static const IconData streamVideo = Icons.movie_outlined;
- static const IconData streamAudio = Icons.audiotrack_outlined;
- static const IconData streamText = Icons.closed_caption_outlined;
- static const IconData vaultLock = Icons.lock_outline;
- static const IconData vaultAdd = Icons.enhanced_encryption_outlined;
- static const IconData vaultConfigure = MdiIcons.shieldLockOutline;
- static const IconData videoSettings = Icons.video_settings_outlined;
- static const IconData view = Icons.grid_view_outlined;
- static const IconData viewerLock = Icons.lock_outline;
- static const IconData viewerUnlock = Icons.lock_open_outlined;
- static const IconData zoomIn = Icons.add_outlined;
- static const IconData zoomOut = Icons.remove_outlined;
- static const IconData collapse = Icons.expand_less_outlined;
- static const IconData expand = Icons.expand_more_outlined;
- static const IconData previous = Icons.chevron_left_outlined;
- static const IconData next = Icons.chevron_right_outlined;
+ static const add = Icons.add_circle_outline;
+ static const addShortcut = Icons.add_to_home_screen_outlined;
+ static const cancel = Icons.cancel_outlined;
+ static const captureFrame = Icons.screenshot_outlined;
+ static const clear = Icons.clear_outlined;
+ static const clipboard = Icons.content_copy_outlined;
+ static const convert = Icons.transform_outlined;
+ static const convertToStillImage = MdiIcons.movieRemoveOutline;
+ static const copy = Icons.file_copy_outlined;
+ static const debug = Icons.whatshot_outlined;
+ static const delete = Icons.delete_outlined;
+ static const edit = Icons.edit_outlined;
+ static const emptyBin = Icons.delete_sweep_outlined;
+ static const export = Icons.open_with_outlined;
+ static const fileExport = MdiIcons.fileExportOutline;
+ static const fileImport = MdiIcons.fileImportOutline;
+ static const flip = Icons.flip_outlined;
+ static const favourite = Icons.favorite_border;
+ static const favouriteActive = Icons.favorite;
+ static const filter = MdiIcons.filterOutline;
+ static const filterOff = MdiIcons.filterOffOutline;
+ static const geoBounds = Icons.public_outlined;
+ static const goUp = Icons.arrow_upward_outlined;
+ static const hide = Icons.visibility_off_outlined;
+ static const info = Icons.info_outlined;
+ static const layers = Icons.layers_outlined;
+ static const map = Icons.map_outlined;
+ static const move = MdiIcons.fileMoveOutline;
+ static const mute = Icons.volume_off_outlined;
+ static const unmute = Icons.volume_up_outlined;
+ static const name = Icons.abc_outlined;
+ static const newTier = Icons.fiber_new_outlined;
+ static const openOutside = Icons.open_in_new_outlined;
+ static const openVideo = MdiIcons.moviePlayOutline;
+ static const pin = Icons.push_pin_outlined;
+ static const unpin = MdiIcons.pinOffOutline;
+ static const play = Icons.play_arrow;
+ static const pause = Icons.pause;
+ static const print = Icons.print_outlined;
+ static const refresh = Icons.refresh_outlined;
+ static const replay10 = Icons.replay_10_outlined;
+ static const reverse = Icons.invert_colors_outlined;
+ static const skip10 = Icons.forward_10_outlined;
+ static const reset = Icons.restart_alt_outlined;
+ static const restore = Icons.restore_outlined;
+ static const rotateLeft = Icons.rotate_left_outlined;
+ static const rotateRight = Icons.rotate_right_outlined;
+ static const rotateScreen = Icons.screen_rotation_outlined;
+ static const search = Icons.search_outlined;
+ static const select = Icons.select_all_outlined;
+ static const setAs = Icons.wallpaper_outlined;
+ static const setCover = MdiIcons.imageEditOutline;
+ static const share = Icons.share_outlined;
+ static const show = Icons.visibility_outlined;
+ static const showFullscreen = MdiIcons.arrowExpand;
+ static const slideshow = Icons.slideshow_outlined;
+ static const speed = Icons.speed_outlined;
+ static const stats = Icons.donut_small_outlined;
+ static const streams = Icons.translate_outlined;
+ static const streamVideo = Icons.movie_outlined;
+ static const streamAudio = Icons.audiotrack_outlined;
+ static const streamText = Icons.closed_caption_outlined;
+ static const vaultLock = Icons.lock_outline;
+ static const vaultAdd = Icons.enhanced_encryption_outlined;
+ static const vaultConfigure = MdiIcons.shieldLockOutline;
+ static const videoSettings = Icons.video_settings_outlined;
+ static const view = Icons.grid_view_outlined;
+ static const viewerLock = Icons.lock_outline;
+ static const viewerUnlock = Icons.lock_open_outlined;
+ static const zoomIn = Icons.add_outlined;
+ static const zoomOut = Icons.remove_outlined;
+ static const collapse = Icons.expand_less_outlined;
+ static const expand = Icons.expand_more_outlined;
+ static const previous = Icons.chevron_left_outlined;
+ static const next = Icons.chevron_right_outlined;
+
+ // editor
+ static const transform = Icons.crop_rotate_outlined;
+ static const aspectRatioFree = Icons.crop_free_outlined;
+ static const aspectRatioOriginal = Icons.crop_original_outlined;
+ static const aspectRatioSquare = Icons.crop_square_outlined;
+ static const aspectRatio_16_9 = Icons.crop_16_9_outlined;
+ static const aspectRatio_4_3 = Icons.crop_landscape_outlined;
// albums
- static const IconData album = Icons.photo_album_outlined;
- static const IconData cameraAlbum = Icons.photo_camera_outlined;
- static const IconData downloadAlbum = Icons.file_download;
- static const IconData screenshotAlbum = Icons.screenshot_outlined;
- static const IconData recordingAlbum = Icons.smartphone_outlined;
- static const IconData locked = Icons.lock_outline;
- static const IconData unlocked = Icons.lock_open_outlined;
+ static const album = Icons.photo_album_outlined;
+ static const cameraAlbum = Icons.photo_camera_outlined;
+ static const downloadAlbum = Icons.file_download;
+ static const screenshotAlbum = Icons.screenshot_outlined;
+ static const recordingAlbum = Icons.smartphone_outlined;
+ static const locked = Icons.lock_outline;
+ static const unlocked = Icons.lock_open_outlined;
// thumbnail overlay
- static const IconData animated = Icons.slideshow;
- static const IconData geo = Icons.language_outlined;
- static const IconData motionPhoto = Icons.motion_photos_on_outlined;
- static const IconData multiPage = Icons.burst_mode_outlined;
- static const IconData panorama = Icons.vrpano_outlined;
- static const IconData sphericalVideo = Icons.threesixty_outlined;
- static const IconData videoThumb = Icons.play_circle_outline;
- static const IconData selected = Icons.check_circle_outline;
- static const IconData unselected = Icons.radio_button_unchecked;
+ static const animated = Icons.slideshow;
+ static const geo = Icons.language_outlined;
+ static const motionPhoto = Icons.motion_photos_on_outlined;
+ static const multiPage = Icons.burst_mode_outlined;
+ static const panorama = Icons.vrpano_outlined;
+ static const sphericalVideo = Icons.threesixty_outlined;
+ static const videoThumb = Icons.play_circle_outline;
+ static const selected = Icons.check_circle_outline;
+ static const unselected = Icons.radio_button_unchecked;
- static const IconData github = MdiIcons.github;
- static const IconData legal = MdiIcons.scaleBalance;
+ static const github = MdiIcons.github;
+ static const legal = MdiIcons.scaleBalance;
}
diff --git a/lib/utils/math_utils.dart b/lib/utils/math_utils.dart
index 89a0e6ed5..e882db4aa 100644
--- a/lib/utils/math_utils.dart
+++ b/lib/utils/math_utils.dart
@@ -1,7 +1,48 @@
import 'dart:math';
+import 'dart:ui';
+
+import 'package:tuple/tuple.dart';
int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / ln2).floor()).toInt();
int smallestPowerOf2(num x) => x < 1 ? 1 : pow(2, (log(x) / ln2).ceil()).toInt();
double roundToPrecision(final double value, {required final int decimals}) => (value * pow(10, decimals)).round() / pow(10, decimals);
+
+// cf https://en.wikipedia.org/wiki/Intersection_(geometry)#Two_line_segments
+Offset? segmentIntersection(Tuple2 s1, Tuple2 s2) {
+ final x1 = s1.item1.dx;
+ final y1 = s1.item1.dy;
+ final x2 = s1.item2.dx;
+ final y2 = s1.item2.dy;
+
+ final x3 = s2.item1.dx;
+ final y3 = s2.item1.dy;
+ final x4 = s2.item2.dx;
+ final y4 = s2.item2.dy;
+
+ final a1 = x2 - x1;
+ final b1 = -(x4 - x3);
+ final c1 = x3 - x1;
+ final a2 = y2 - y1;
+ final b2 = -(y4 - y3);
+ final c2 = y3 - y1;
+
+ final denom = a1 * b2 - a2 * b1;
+ if (denom == 0) {
+ // lines are parallel
+ return null;
+ }
+
+ final s0 = (c1 * b2 - c2 * b1) / denom;
+ final t0 = (a1 * c2 - a2 * c1) / denom;
+
+ if (!(0 <= s0 && s0 <= 1 && 0 <= t0 && t0 <= 1)) {
+ // segments do not intersect
+ return null;
+ }
+
+ final x0 = x1 + s0 * (x2 - x1);
+ final y0 = y1 + s0 * (y2 - y1);
+ return Offset(x0, y0);
+}
diff --git a/lib/view/src/editor/enums.dart b/lib/view/src/editor/enums.dart
new file mode 100644
index 000000000..43b9fe95d
--- /dev/null
+++ b/lib/view/src/editor/enums.dart
@@ -0,0 +1,57 @@
+import 'package:aves/ref/unicode.dart';
+import 'package:aves/theme/icons.dart';
+import 'package:aves/widgets/common/extensions/build_context.dart';
+import 'package:aves_model/aves_model.dart';
+import 'package:flutter/widgets.dart';
+
+extension ExtraEditorActionView on EditorAction {
+ String getText(BuildContext context) {
+ switch (this) {
+ case EditorAction.transform:
+ return context.l10n.editorActionTransform;
+ }
+ }
+
+ Widget getIcon() => Icon(_getIconData());
+
+ IconData _getIconData() {
+ switch (this) {
+ case EditorAction.transform:
+ return AIcons.transform;
+ }
+ }
+}
+
+extension ExtraCropAspectRatioView on CropAspectRatio {
+ String getText(BuildContext context) {
+ switch (this) {
+ case CropAspectRatio.free:
+ return context.l10n.cropAspectRatioFree;
+ case CropAspectRatio.original:
+ return context.l10n.cropAspectRatioOriginal;
+ case CropAspectRatio.square:
+ return context.l10n.cropAspectRatioSquare;
+ case CropAspectRatio.ar_16_9:
+ return '16${UniChars.ratio}9';
+ case CropAspectRatio.ar_4_3:
+ return '4${UniChars.ratio}3';
+ }
+ }
+
+ Widget getIcon() => Icon(_getIconData());
+
+ IconData _getIconData() {
+ switch (this) {
+ case CropAspectRatio.free:
+ return AIcons.aspectRatioFree;
+ case CropAspectRatio.original:
+ return AIcons.aspectRatioOriginal;
+ case CropAspectRatio.square:
+ return AIcons.aspectRatioSquare;
+ case CropAspectRatio.ar_16_9:
+ return AIcons.aspectRatio_16_9;
+ case CropAspectRatio.ar_4_3:
+ return AIcons.aspectRatio_4_3;
+ }
+ }
+}
diff --git a/lib/view/view.dart b/lib/view/view.dart
index 76eec0d8c..fd3cfd0ff 100644
--- a/lib/view/view.dart
+++ b/lib/view/view.dart
@@ -6,6 +6,7 @@ export 'src/actions/map.dart';
export 'src/actions/map_cluster.dart';
export 'src/actions/share.dart';
export 'src/actions/slideshow.dart';
+export 'src/editor/enums.dart';
export 'src/metadata/date_edit_action.dart';
export 'src/metadata/date_field_source.dart';
export 'src/metadata/fields.dart';
diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart
index d34dfd3a4..1c3ff2854 100644
--- a/lib/widgets/aves_app.dart
+++ b/lib/widgets/aves_app.dart
@@ -54,6 +54,7 @@ import 'package:url_launcher/url_launcher.dart' as ul;
class AvesApp extends StatefulWidget {
final AppFlavor flavor;
+ final Map? debugIntentData;
// temporary exclude locales not ready yet for prime time
// `ckb`: add `flutter_ckb_localization` and necessary app localization delegates when ready
@@ -85,6 +86,7 @@ class AvesApp extends StatefulWidget {
const AvesApp({
super.key,
required this.flavor,
+ this.debugIntentData,
});
@override
@@ -227,7 +229,7 @@ class _AvesAppState extends State with WidgetsBindingObserver {
AvesApp.showSystemUI();
}
final home = initialized
- ? _getFirstPage()
+ ? _getFirstPage(intentData: widget.debugIntentData)
: AvesScaffold(
body: snapshot.hasError ? _buildError(snapshot.error!) : const SizedBox(),
);
@@ -390,6 +392,7 @@ class _AvesAppState extends State with WidgetsBindingObserver {
case AppMode.setWallpaper:
case AppMode.slideshow:
case AppMode.view:
+ case AppMode.edit:
break;
}
case AppLifecycleState.resumed:
diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart
index 0c3a1ac0e..93bc20b49 100644
--- a/lib/widgets/collection/collection_page.dart
+++ b/lib/widgets/collection/collection_page.dart
@@ -208,6 +208,7 @@ class _CollectionPageState extends State {
case AppMode.setWallpaper:
case AppMode.slideshow:
case AppMode.view:
+ case AppMode.edit:
return null;
}
}
diff --git a/lib/widgets/collection/grid/tile.dart b/lib/widgets/collection/grid/tile.dart
index a4e132d0f..2a8cc56fc 100644
--- a/lib/widgets/collection/grid/tile.dart
+++ b/lib/widgets/collection/grid/tile.dart
@@ -54,6 +54,7 @@ class InteractiveTile extends StatelessWidget {
case AppMode.setWallpaper:
case AppMode.slideshow:
case AppMode.view:
+ case AppMode.edit:
break;
}
},
diff --git a/lib/widgets/common/basic/multi_cross_fader.dart b/lib/widgets/common/basic/multi_cross_fader.dart
index 17a07ad9b..df65a57af 100644
--- a/lib/widgets/common/basic/multi_cross_fader.dart
+++ b/lib/widgets/common/basic/multi_cross_fader.dart
@@ -4,6 +4,7 @@ class MultiCrossFader extends StatefulWidget {
final Duration duration;
final Curve fadeCurve, sizeCurve;
final AlignmentGeometry alignment;
+ final AnimatedCrossFadeBuilder layoutBuilder;
final Widget child;
const MultiCrossFader({
@@ -12,6 +13,7 @@ class MultiCrossFader extends StatefulWidget {
this.fadeCurve = Curves.linear,
this.sizeCurve = Curves.linear,
this.alignment = Alignment.topCenter,
+ this.layoutBuilder = AnimatedCrossFade.defaultLayoutBuilder,
required this.child,
});
@@ -53,6 +55,8 @@ class _MultiCrossFaderState extends State {
alignment: widget.alignment,
crossFadeState: _fadeState,
duration: widget.duration,
+ reverseDuration: widget.duration,
+ layoutBuilder: widget.layoutBuilder,
);
}
}
diff --git a/lib/widgets/common/extensions/geometry.dart b/lib/widgets/common/extensions/geometry.dart
new file mode 100644
index 000000000..3979c4ab3
--- /dev/null
+++ b/lib/widgets/common/extensions/geometry.dart
@@ -0,0 +1,7 @@
+import 'dart:ui';
+
+extension ExtraRect on Rect {
+ bool containsIncludingBottomRight(Offset offset, {double tolerance = 0}) {
+ return offset.dx >= left - tolerance && offset.dx <= right + tolerance && offset.dy >= top - tolerance && offset.dy <= bottom + tolerance;
+ }
+}
diff --git a/lib/widgets/common/fx/checkered_decoration.dart b/lib/widgets/common/fx/checkered_decoration.dart
index ad2de6e62..b7fb249fd 100644
--- a/lib/widgets/common/fx/checkered_decoration.dart
+++ b/lib/widgets/common/fx/checkered_decoration.dart
@@ -15,7 +15,7 @@ class CheckeredPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
- final background = Rect.fromLTWH(0, 0, size.width, size.height);
+ final background = Offset.zero & size;
canvas.drawRect(background, lightPaint);
final dx = offset.dx % (checkSize * 2);
diff --git a/lib/widgets/common/fx/dashed_path_painter.dart b/lib/widgets/common/fx/dashed_path_painter.dart
new file mode 100644
index 000000000..5d0c36d56
--- /dev/null
+++ b/lib/widgets/common/fx/dashed_path_painter.dart
@@ -0,0 +1,142 @@
+import 'dart:ui' as ui;
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+
+// from https://stackoverflow.com/a/71099304/786656
+class DashedPathPainter extends CustomPainter {
+ final Path originalPath;
+ final Color pathColor;
+ final double strokeWidth;
+ final double dashGapLength;
+ final double dashLength;
+ late DashedPathProperties _dashedPathProperties;
+
+ DashedPathPainter({
+ required this.originalPath,
+ required this.pathColor,
+ this.strokeWidth = 3.0,
+ this.dashGapLength = 5.0,
+ this.dashLength = 10.0,
+ });
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ _dashedPathProperties = DashedPathProperties(
+ path: Path(),
+ dashLength: dashLength,
+ dashGapLength: dashGapLength,
+ );
+ final dashedPath = _getDashedPath(originalPath, dashLength, dashGapLength);
+ canvas.drawPath(
+ dashedPath,
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..color = pathColor
+ ..strokeWidth = strokeWidth,
+ );
+ }
+
+ @override
+ bool shouldRepaint(DashedPathPainter oldDelegate) => oldDelegate.originalPath != originalPath || oldDelegate.pathColor != pathColor || oldDelegate.strokeWidth != strokeWidth || oldDelegate.dashGapLength != dashGapLength || oldDelegate.dashLength != dashLength;
+
+ Path _getDashedPath(
+ Path originalPath,
+ double dashLength,
+ double dashGapLength,
+ ) {
+ final metricsIterator = originalPath.computeMetrics().iterator;
+ while (metricsIterator.moveNext()) {
+ final metric = metricsIterator.current;
+ _dashedPathProperties.extractedPathLength = 0.0;
+ while (_dashedPathProperties.extractedPathLength < metric.length) {
+ if (_dashedPathProperties.addDashNext) {
+ _dashedPathProperties.addDash(metric, dashLength);
+ } else {
+ _dashedPathProperties.addDashGap(metric, dashGapLength);
+ }
+ }
+ }
+ return _dashedPathProperties.path;
+ }
+}
+
+class DashedPathProperties {
+ double extractedPathLength;
+ Path path;
+
+ final double _dashLength;
+ double _remainingDashLength;
+ double _remainingDashGapLength;
+ bool _previousWasDash;
+
+ DashedPathProperties({
+ required this.path,
+ required double dashLength,
+ required double dashGapLength,
+ }) : assert(dashLength > 0.0, 'dashLength must be > 0.0'),
+ assert(dashGapLength > 0.0, 'dashGapLength must be > 0.0'),
+ _dashLength = dashLength,
+ _remainingDashLength = dashLength,
+ _remainingDashGapLength = dashGapLength,
+ _previousWasDash = false,
+ extractedPathLength = 0.0;
+
+ bool get addDashNext {
+ if (!_previousWasDash || _remainingDashLength != _dashLength) {
+ return true;
+ }
+ return false;
+ }
+
+ void addDash(ui.PathMetric metric, double dashLength) {
+ // Calculate lengths (actual + available)
+ final end = _calculateLength(metric, _remainingDashLength);
+ final availableEnd = _calculateLength(metric, dashLength);
+ // Add path
+ final pathSegment = metric.extractPath(extractedPathLength, end);
+ path.addPath(pathSegment, Offset.zero);
+ // Update
+ final delta = _remainingDashLength - (end - extractedPathLength);
+ _remainingDashLength = _updateRemainingLength(
+ delta: delta,
+ end: end,
+ availableEnd: availableEnd,
+ initialLength: dashLength,
+ );
+ extractedPathLength = end;
+ _previousWasDash = true;
+ }
+
+ void addDashGap(ui.PathMetric metric, double dashGapLength) {
+ // Calculate lengths (actual + available)
+ final end = _calculateLength(metric, _remainingDashGapLength);
+ final availableEnd = _calculateLength(metric, dashGapLength);
+ // Move path's end point
+ ui.Tangent tangent = metric.getTangentForOffset(end)!;
+ path.moveTo(tangent.position.dx, tangent.position.dy);
+ // Update
+ final delta = end - extractedPathLength;
+ _remainingDashGapLength = _updateRemainingLength(
+ delta: delta,
+ end: end,
+ availableEnd: availableEnd,
+ initialLength: dashGapLength,
+ );
+ extractedPathLength = end;
+ _previousWasDash = false;
+ }
+
+ double _calculateLength(ui.PathMetric metric, double addedLength) {
+ return math.min(extractedPathLength + addedLength, metric.length);
+ }
+
+ double _updateRemainingLength({
+ required double delta,
+ required double end,
+ required double availableEnd,
+ required double initialLength,
+ }) {
+ return (delta > 0 && availableEnd == end) ? delta : initialLength;
+ }
+}
diff --git a/lib/widgets/common/fx/transition_image.dart b/lib/widgets/common/fx/transition_image.dart
index f9aa1284c..abd417506 100644
--- a/lib/widgets/common/fx/transition_image.dart
+++ b/lib/widgets/common/fx/transition_image.dart
@@ -343,10 +343,11 @@ class _TransitionImagePainter extends CustomPainter {
..filterQuality = FilterQuality.low;
const alignment = Alignment.center;
- final rect = ui.Rect.fromLTWH(0, 0, size.width, size.height);
+ final rect = Offset.zero & size;
if (rect.isEmpty) {
return;
}
+
final outputSize = rect.size;
final inputSize = Size(image!.width.toDouble(), image!.height.toDouble());
diff --git a/lib/widgets/common/grid/item_tracker.dart b/lib/widgets/common/grid/item_tracker.dart
index 74cd97101..4cd88a32f 100644
--- a/lib/widgets/common/grid/item_tracker.dart
+++ b/lib/widgets/common/grid/item_tracker.dart
@@ -101,7 +101,7 @@ class _GridItemTrackerState extends State> with WidgetsBin
final tileRect = sectionedListLayout.getTileRect(event.item);
if (tileRect == null) return;
- final viewportRect = Rect.fromLTWH(0, scrollController.offset, scrollableSize.width, scrollableSize.height);
+ final viewportRect = Offset(0, scrollController.offset) & scrollableSize;
final itemVisibility = max(0, tileRect.intersect(viewportRect).height) / tileRect.height;
if (!event.predicate(itemVisibility)) return;
diff --git a/lib/widgets/editor/control_panel.dart b/lib/widgets/editor/control_panel.dart
new file mode 100644
index 000000000..2a3dc2711
--- /dev/null
+++ b/lib/widgets/editor/control_panel.dart
@@ -0,0 +1,137 @@
+import 'package:aves/model/entry/entry.dart';
+import 'package:aves/theme/durations.dart';
+import 'package:aves/view/view.dart';
+import 'package:aves/widgets/common/basic/multi_cross_fader.dart';
+import 'package:aves/widgets/common/extensions/build_context.dart';
+import 'package:aves/widgets/common/identity/buttons/overlay_button.dart';
+import 'package:aves/widgets/editor/transform/control_panel.dart';
+import 'package:aves/widgets/editor/transform/controller.dart';
+import 'package:aves/widgets/viewer/overlay/viewer_buttons.dart';
+import 'package:aves_model/aves_model.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+class EditorControlPanel extends StatelessWidget {
+ final AvesEntry entry;
+ final ValueNotifier actionNotifier;
+
+ static const padding = ViewerButtonRowContent.padding;
+ static const actions = [
+ EditorAction.transform,
+ ];
+
+ const EditorControlPanel({
+ super.key,
+ required this.entry,
+ required this.actionNotifier,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return WillPopScope(
+ onWillPop: () {
+ if (actionNotifier.value != null) {
+ _cancelAction(context);
+ return SynchronousFuture(false);
+ }
+ return SynchronousFuture(true);
+ },
+ child: Padding(
+ padding: const EdgeInsets.all(padding),
+ child: TooltipTheme(
+ data: TooltipTheme.of(context).copyWith(
+ preferBelow: false,
+ ),
+ child: ValueListenableBuilder(
+ valueListenable: actionNotifier,
+ builder: (context, action, child) {
+ return MultiCrossFader(
+ duration: context.select((v) => v.formTransition),
+ alignment: Alignment.bottomCenter,
+ layoutBuilder: (topChild, topChildKey, bottomChild, bottomChildKey) {
+ return Stack(
+ clipBehavior: Clip.none,
+ children: [
+ Positioned(
+ key: bottomChildKey,
+ left: 0.0,
+ bottom: 0.0,
+ right: 0.0,
+ child: bottomChild,
+ ),
+ Positioned(
+ key: topChildKey,
+ child: topChild,
+ ),
+ ],
+ );
+ },
+ child: action == null ? _buildTopLevelPanel(context) : _buildActionPanel(context, action),
+ );
+ },
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildTopLevelPanel(BuildContext context) {
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ...actions.map(
+ (action) => Padding(
+ padding: const EdgeInsetsDirectional.symmetric(horizontal: padding / 2),
+ child: OverlayButton(
+ child: IconButton(
+ icon: action.getIcon(),
+ onPressed: () => actionNotifier.value = action,
+ tooltip: action.getText(context),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: padding),
+ Row(
+ children: [
+ const OverlayButton(
+ child: CloseButton(),
+ ),
+ const Spacer(),
+ OverlayTextButton(
+ onPressed: () {},
+ child: Text(context.l10n.saveCopyButtonLabel),
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Widget _buildActionPanel(BuildContext context, EditorAction action) {
+ switch (action) {
+ case EditorAction.transform:
+ return TransformControlPanel(
+ entry: entry,
+ onCancel: () => _cancelAction(context),
+ onApply: (transformation) => _applyAction(context),
+ );
+ }
+ }
+
+ void _cancelAction(BuildContext context) {
+ actionNotifier.value = null;
+ context.read().reset();
+ }
+
+ void _applyAction(BuildContext context) {
+ actionNotifier.value = null;
+ context.read().reset();
+ }
+}
diff --git a/lib/widgets/editor/entry_editor_page.dart b/lib/widgets/editor/entry_editor_page.dart
new file mode 100644
index 000000000..f375aeb27
--- /dev/null
+++ b/lib/widgets/editor/entry_editor_page.dart
@@ -0,0 +1,136 @@
+import 'dart:async';
+
+import 'package:aves/model/entry/entry.dart';
+import 'package:aves/model/settings/settings.dart';
+import 'package:aves/model/view_state.dart';
+import 'package:aves/widgets/editor/control_panel.dart';
+import 'package:aves/widgets/editor/image.dart';
+import 'package:aves/widgets/editor/transform/controller.dart';
+import 'package:aves/widgets/editor/transform/cropper.dart';
+import 'package:aves/widgets/viewer/overlay/minimap.dart';
+import 'package:aves_magnifier/aves_magnifier.dart';
+import 'package:aves_model/aves_model.dart';
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+class ImageEditorPage extends StatefulWidget {
+ static const routeName = '/image_editor';
+
+ final AvesEntry entry;
+
+ const ImageEditorPage({
+ super.key,
+ required this.entry,
+ });
+
+ @override
+ State createState() => _ImageEditorPageState();
+}
+
+class _ImageEditorPageState extends State {
+ final List _subscriptions = [];
+ final ValueNotifier _actionNotifier = ValueNotifier(null);
+ final ValueNotifier _paddingNotifier = ValueNotifier(EdgeInsets.zero);
+ final ValueNotifier _viewStateNotifier = ValueNotifier(ViewState.zero);
+ final AvesMagnifierController _magnifierController = AvesMagnifierController();
+ late final TransformController _transformController;
+
+ @override
+ void initState() {
+ super.initState();
+ _transformController = TransformController(widget.entry.displaySize);
+ _actionNotifier.addListener(_onActionChanged);
+ _subscriptions.add(_transformController.transformationStream.map((v) => v.matrix).distinct().listen(_onTransformationMatrixChanged));
+ }
+
+ @override
+ void dispose() {
+ _subscriptions
+ ..forEach((sub) => sub.cancel())
+ ..clear();
+ _actionNotifier.dispose();
+ _paddingNotifier.dispose();
+ _viewStateNotifier.dispose();
+ _magnifierController.dispose();
+ _transformController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: Provider.value(
+ value: _transformController,
+ child: SafeArea(
+ child: Column(
+ children: [
+ Expanded(
+ child: Stack(
+ children: [
+ ClipRect(
+ child: EditorImage(
+ magnifierController: _magnifierController,
+ transformController: _transformController,
+ actionNotifier: _actionNotifier,
+ paddingNotifier: _paddingNotifier,
+ viewStateNotifier: _viewStateNotifier,
+ entry: widget.entry,
+ ),
+ ),
+ if (settings.showOverlayMinimap)
+ PositionedDirectional(
+ start: 8,
+ bottom: 8,
+ child: Minimap(viewStateNotifier: _viewStateNotifier),
+ ),
+ ValueListenableBuilder(
+ valueListenable: _actionNotifier,
+ builder: (context, action, child) {
+ switch (action) {
+ case EditorAction.transform:
+ return Cropper(
+ magnifierController: _magnifierController,
+ transformController: _transformController,
+ paddingNotifier: _paddingNotifier,
+ );
+ case null:
+ return const SizedBox();
+ }
+ },
+ ),
+ ],
+ ),
+ ),
+ EditorControlPanel(
+ entry: widget.entry,
+ actionNotifier: _actionNotifier,
+ ),
+ ],
+ ),
+ ),
+ ),
+ resizeToAvoidBottomInset: false,
+ );
+ }
+
+ void _onActionChanged() => _updateImagePadding();
+
+ void _updateImagePadding() {
+ if (_actionNotifier.value == EditorAction.transform) {
+ _paddingNotifier.value = Cropper.imagePadding;
+ } else {
+ _paddingNotifier.value = EdgeInsets.zero;
+ }
+ }
+
+ void _onTransformationMatrixChanged(Matrix4 transformationMatrix) {
+ final boundaries = _magnifierController.scaleBoundaries;
+ if (boundaries != null) {
+ _magnifierController.setScaleBoundaries(
+ boundaries.copyWith(
+ externalTransform: transformationMatrix,
+ ),
+ );
+ }
+ }
+}
diff --git a/lib/widgets/editor/image.dart b/lib/widgets/editor/image.dart
new file mode 100644
index 000000000..2b2f703ea
--- /dev/null
+++ b/lib/widgets/editor/image.dart
@@ -0,0 +1,224 @@
+import 'dart:async';
+
+import 'package:aves/model/entry/entry.dart';
+import 'package:aves/model/view_state.dart';
+import 'package:aves/theme/durations.dart';
+import 'package:aves/widgets/editor/transform/controller.dart';
+import 'package:aves/widgets/editor/transform/painter.dart';
+import 'package:aves/widgets/editor/transform/transformation.dart';
+import 'package:aves/widgets/viewer/visual/error.dart';
+import 'package:aves/widgets/viewer/visual/raster.dart';
+import 'package:aves_magnifier/aves_magnifier.dart';
+import 'package:aves_model/aves_model.dart';
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+class EditorImage extends StatefulWidget {
+ final AvesMagnifierController magnifierController;
+ final TransformController transformController;
+ final ValueNotifier actionNotifier;
+ final ValueNotifier paddingNotifier;
+ final ValueNotifier viewStateNotifier;
+ final AvesEntry entry;
+
+ const EditorImage({
+ super.key,
+ required this.magnifierController,
+ required this.transformController,
+ required this.actionNotifier,
+ required this.paddingNotifier,
+ required this.viewStateNotifier,
+ required this.entry,
+ });
+
+ @override
+ State createState() => _EditorImageState();
+}
+
+class _EditorImageState extends State {
+ final List _subscriptions = [];
+ final ValueNotifier _scrimOpacityNotifier = ValueNotifier(0);
+
+ AvesEntry get entry => widget.entry;
+
+ TransformController get transformController => widget.transformController;
+
+ ValueNotifier get viewStateNotifier => widget.viewStateNotifier;
+
+ @override
+ void initState() {
+ super.initState();
+ _registerWidget(widget);
+ }
+
+ @override
+ void didUpdateWidget(covariant EditorImage oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ _unregisterWidget(oldWidget);
+ _registerWidget(widget);
+ }
+
+ @override
+ void dispose() {
+ _subscriptions
+ ..forEach((sub) => sub.cancel())
+ ..clear();
+ _unregisterWidget(widget);
+ super.dispose();
+ }
+
+ void _registerWidget(EditorImage widget) {
+ widget.actionNotifier.addListener(_onActionChanged);
+ _subscriptions.add(widget.magnifierController.stateStream.listen(_onViewStateChanged));
+ _subscriptions.add(widget.magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged));
+ _subscriptions.add(widget.transformController.eventStream.listen(_onTransformEvent));
+ }
+
+ void _unregisterWidget(EditorImage widget) {
+ widget.actionNotifier.removeListener(_onActionChanged);
+ _subscriptions
+ ..forEach((sub) => sub.cancel())
+ ..clear();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return MagnifierGestureDetectorScope(
+ axis: const [Axis.horizontal, Axis.vertical],
+ child: StreamBuilder(
+ stream: transformController.transformationStream,
+ builder: (context, snapshot) {
+ final transformation = (snapshot.data ?? Transformation.zero);
+ final highlightRegionCorners = transformation.region.corners;
+ final imageToUserMatrix = transformation.matrix;
+
+ final mediaSize = entry.displaySize;
+ final canvasSize = MatrixUtils.transformRect(imageToUserMatrix, Offset.zero & mediaSize).size;
+
+ return ValueListenableBuilder(
+ valueListenable: widget.paddingNotifier,
+ builder: (context, padding, child) {
+ return Transform(
+ alignment: Alignment.center,
+ transform: imageToUserMatrix,
+ child: ValueListenableBuilder(
+ valueListenable: widget.actionNotifier,
+ builder: (context, action, child) {
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final viewportSize = padding.deflateSize(constraints.biggest);
+ final minScale = ScaleLevel(factor: ScaleLevel.scaleForContained(viewportSize, canvasSize));
+ return AvesMagnifier(
+ key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedSecs}'),
+ controller: widget.magnifierController,
+ viewportPadding: padding,
+ contentSize: mediaSize,
+ allowOriginalScaleBeyondRange: false,
+ allowGestureScaleBeyondRange: false,
+ panInertia: _getActionPanInertia(action),
+ minScale: minScale,
+ maxScale: const ScaleLevel(factor: 1),
+ initialScale: minScale,
+ scaleStateCycle: defaultScaleStateCycle,
+ applyScale: false,
+ onScaleStart: (details, doubleTap, boundaries) {
+ transformController.activity = TransformActivity.pan;
+ },
+ onScaleEnd: (details) {
+ transformController.activity = TransformActivity.none;
+ },
+ child: child!,
+ );
+ },
+ );
+ },
+ child: Stack(
+ children: [
+ RasterImageView(
+ entry: entry,
+ viewStateNotifier: viewStateNotifier,
+ errorBuilder: (context, error, stackTrace) => ErrorView(
+ entry: entry,
+ onTap: () {},
+ ),
+ ),
+ Positioned.fill(
+ child: ValueListenableBuilder(
+ valueListenable: viewStateNotifier,
+ builder: (context, viewState, child) {
+ final scale = viewState.scale ?? 1;
+ final highlightRegionPath = Path()..addPolygon(highlightRegionCorners.map((v) => v * scale).toList(), true);
+ return ValueListenableBuilder(
+ valueListenable: _scrimOpacityNotifier,
+ builder: (context, opacity, child) {
+ return AnimatedOpacity(
+ opacity: opacity,
+ duration: context.read().viewerOverlayAnimation,
+ child: CustomPaint(
+ painter: ScrimPainter(
+ excludePath: highlightRegionPath,
+ opacity: opacity,
+ ),
+ ),
+ );
+ },
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ },
+ ),
+ );
+ }
+
+ void _onViewStateChanged(MagnifierState v) {
+ viewStateNotifier.value = viewStateNotifier.value.copyWith(
+ position: v.position,
+ scale: v.scale,
+ );
+ }
+
+ void _onViewScaleBoundariesChanged(ScaleBoundaries v) {
+ viewStateNotifier.value = viewStateNotifier.value.copyWith(
+ viewportSize: v.viewportSize,
+ contentSize: v.contentSize,
+ );
+ }
+
+ void _onActionChanged() => _updateScrim();
+
+ void _onTransformEvent(TransformEvent event) => _updateScrim();
+
+ void _updateScrim() => _scrimOpacityNotifier.value = _getActionScrimOpacity(widget.actionNotifier.value, transformController.activity);
+
+ static double _getActionPanInertia(EditorAction? action) {
+ switch (action) {
+ case EditorAction.transform:
+ return 0;
+ case null:
+ return AvesMagnifier.defaultPanInertia;
+ }
+ }
+
+ static double _getActionScrimOpacity(EditorAction? action, TransformActivity activity) {
+ switch (action) {
+ case EditorAction.transform:
+ switch (activity) {
+ case TransformActivity.none:
+ return .9;
+ case TransformActivity.pan:
+ case TransformActivity.resize:
+ case TransformActivity.straighten:
+ return .6;
+ }
+ case null:
+ return 0;
+ }
+ }
+}
diff --git a/lib/widgets/editor/transform/control_panel.dart b/lib/widgets/editor/transform/control_panel.dart
new file mode 100644
index 000000000..4615563bd
--- /dev/null
+++ b/lib/widgets/editor/transform/control_panel.dart
@@ -0,0 +1,196 @@
+import 'package:aves/model/entry/entry.dart';
+import 'package:aves/theme/durations.dart';
+import 'package:aves/theme/icons.dart';
+import 'package:aves/view/view.dart';
+import 'package:aves/widgets/common/extensions/build_context.dart';
+import 'package:aves/widgets/common/identity/buttons/captioned_button.dart';
+import 'package:aves/widgets/common/identity/buttons/overlay_button.dart';
+import 'package:aves/widgets/editor/control_panel.dart';
+import 'package:aves/widgets/editor/transform/controller.dart';
+import 'package:aves/widgets/editor/transform/transformation.dart';
+import 'package:aves_model/aves_model.dart';
+import 'package:collection/collection.dart';
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+import 'package:provider/provider.dart';
+import 'package:tuple/tuple.dart';
+
+class TransformControlPanel extends StatefulWidget {
+ final AvesEntry entry;
+ final VoidCallback onCancel;
+ final void Function(Transformation transformation) onApply;
+
+ const TransformControlPanel({
+ super.key,
+ required this.entry,
+ required this.onCancel,
+ required this.onApply,
+ });
+
+ @override
+ State createState() => _TransformControlPanelState();
+}
+
+class _TransformControlPanelState extends State with TickerProviderStateMixin {
+ late final List> _tabs;
+ late final TabController _tabController;
+
+ static const padding = EditorControlPanel.padding;
+
+ @override
+ void initState() {
+ super.initState();
+ _tabs = [
+ Tuple2(
+ (context) => Tab(text: context.l10n.editorTransformCrop),
+ (context) => const CropControlPanel(),
+ ),
+ Tuple2(
+ (context) => Tab(text: context.l10n.editorTransformRotate),
+ (context) => const RotationControlPanel(),
+ ),
+ ];
+ _tabController = TabController(
+ length: _tabs.length,
+ vsync: this,
+ );
+ }
+
+ @override
+ void dispose() {
+ _tabController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final transformController = context.watch();
+ return Column(
+ children: [
+ SizedBox(
+ height: CropControlPanel.preferredHeight(context),
+ child: AnimatedBuilder(
+ animation: _tabController,
+ builder: (context, child) {
+ return AnimatedSwitcher(
+ duration: context.select((v) => v.formTransition),
+ child: _tabs[_tabController.index].item2(context),
+ );
+ },
+ ),
+ ),
+ const SizedBox(height: padding),
+ Row(
+ children: [
+ const OverlayButton(
+ child: BackButton(),
+ ),
+ Expanded(
+ child: TabBar(
+ tabs: _tabs.map((v) => v.item1(context)).toList(),
+ controller: _tabController,
+ padding: const EdgeInsets.symmetric(horizontal: padding),
+ indicatorSize: TabBarIndicatorSize.label,
+ ),
+ ),
+ OverlayButton(
+ child: StreamBuilder(
+ stream: transformController.transformationStream,
+ builder: (context, snapshot) {
+ return IconButton(
+ icon: const Icon(AIcons.apply),
+ onPressed: transformController.modified ? () => widget.onApply(transformController.transformation) : null,
+ tooltip: context.l10n.applyTooltip,
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+}
+
+class CropControlPanel extends StatelessWidget {
+ const CropControlPanel({super.key});
+
+ static double preferredHeight(BuildContext context) => CropAspectRatio.values.map((v) {
+ return CaptionedButton.getSize(context, v.getText(context), showCaption: true).height;
+ }).max;
+
+ @override
+ Widget build(BuildContext context) {
+ final aspectRatioNotifier = context.select>((v) => v.aspectRatioNotifier);
+ return ListView.builder(
+ scrollDirection: Axis.horizontal,
+ shrinkWrap: true,
+ itemBuilder: (context, index) {
+ final ratio = CropAspectRatio.values[index];
+ void setAspectRatio() => aspectRatioNotifier.value = ratio;
+ return CaptionedButton(
+ iconButtonBuilder: (context, focusNode) {
+ return ValueListenableBuilder(
+ valueListenable: aspectRatioNotifier,
+ builder: (context, selectedRatio, child) {
+ return IconButton(
+ color: ratio == selectedRatio ? Theme.of(context).colorScheme.primary : null,
+ onPressed: setAspectRatio,
+ focusNode: focusNode,
+ icon: ratio.getIcon(),
+ );
+ },
+ );
+ },
+ caption: ratio.getText(context),
+ onPressed: setAspectRatio,
+ );
+ },
+ itemCount: CropAspectRatio.values.length,
+ );
+ }
+}
+
+class RotationControlPanel extends StatelessWidget {
+ const RotationControlPanel({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final controller = context.watch();
+
+ return Row(
+ children: [
+ _buildButton(context, EntryAction.flip, controller.flipHorizontally),
+ Expanded(
+ child: StreamBuilder(
+ stream: controller.transformationStream,
+ builder: (context, snapshot) {
+ final transformation = snapshot.data ?? Transformation.zero;
+ return Slider(
+ value: transformation.straightenDegrees,
+ min: TransformController.straightenDegreesMin,
+ max: TransformController.straightenDegreesMax,
+ divisions: 18,
+ onChangeStart: (v) => controller.activity = TransformActivity.straighten,
+ onChangeEnd: (v) => controller.activity = TransformActivity.none,
+ label: NumberFormat('0.0°', context.l10n.localeName).format(transformation.straightenDegrees),
+ onChanged: (v) => controller.straightenDegrees = v,
+ );
+ },
+ ),
+ ),
+ _buildButton(context, EntryAction.rotateCW, controller.rotateClockwise),
+ ],
+ );
+ }
+
+ Widget _buildButton(BuildContext context, EntryAction action, VoidCallback onPressed) {
+ return OverlayButton(
+ child: IconButton(
+ icon: action.getIcon(),
+ onPressed: onPressed,
+ tooltip: action.getText(context),
+ ),
+ );
+ }
+}
diff --git a/lib/widgets/editor/transform/controller.dart b/lib/widgets/editor/transform/controller.dart
new file mode 100644
index 000000000..0c414a326
--- /dev/null
+++ b/lib/widgets/editor/transform/controller.dart
@@ -0,0 +1,88 @@
+import 'dart:async';
+import 'dart:ui';
+
+import 'package:aves/widgets/editor/transform/crop_region.dart';
+import 'package:aves/widgets/editor/transform/transformation.dart';
+import 'package:aves_model/aves_model.dart';
+import 'package:flutter/foundation.dart';
+
+class TransformController {
+ ValueNotifier aspectRatioNotifier = ValueNotifier(CropAspectRatio.free);
+
+ TransformActivity _activity = TransformActivity.none;
+
+ TransformActivity get activity => _activity;
+
+ Transformation _transformation = Transformation.zero;
+
+ Transformation get transformation => _transformation;
+
+ bool get modified => _transformation != Transformation.zero;
+
+ final StreamController _transformationStreamController = StreamController.broadcast();
+
+ Stream get transformationStream => _transformationStreamController.stream;
+
+ final StreamController _eventStreamController = StreamController.broadcast();
+
+ Stream get eventStream => _eventStreamController.stream;
+
+ static const double straightenDegreesMin = -45;
+ static const double straightenDegreesMax = 45;
+
+ final Size displaySize;
+
+ TransformController(this.displaySize) {
+ reset();
+ aspectRatioNotifier.addListener(_onAspectRatioChanged);
+ }
+
+ void dispose() {
+ aspectRatioNotifier.dispose();
+ }
+
+ void reset() {
+ _transformation = Transformation.zero.copyWith(
+ region: CropRegion.fromRect(Offset.zero & displaySize),
+ );
+ _transformationStreamController.add(_transformation);
+ }
+
+ void flipHorizontally() {
+ _transformation = _transformation.copyWith(
+ orientation: _transformation.orientation.flipHorizontally(),
+ straightenDegrees: -transformation.straightenDegrees,
+ );
+ _transformationStreamController.add(_transformation);
+ }
+
+ void rotateClockwise() {
+ _transformation = _transformation.copyWith(
+ orientation: _transformation.orientation.rotateClockwise(),
+ );
+ _transformationStreamController.add(_transformation);
+ }
+
+ set straightenDegrees(double straightenDegrees) {
+ _transformation = _transformation.copyWith(
+ straightenDegrees: straightenDegrees.clamp(straightenDegreesMin, straightenDegreesMax),
+ );
+ _transformationStreamController.add(_transformation);
+ }
+
+ set cropRegion(CropRegion region) {
+ _transformation = _transformation.copyWith(
+ region: region,
+ );
+ _transformationStreamController.add(_transformation);
+ }
+
+ set activity(TransformActivity activity) {
+ _activity = activity;
+ _eventStreamController.add(TransformEvent(activity: _activity));
+ }
+
+ void _onAspectRatioChanged() {
+ // TODO TLAD [crop] apply
+ }
+}
diff --git a/lib/widgets/editor/transform/crop_region.dart b/lib/widgets/editor/transform/crop_region.dart
new file mode 100644
index 000000000..013a12c9a
--- /dev/null
+++ b/lib/widgets/editor/transform/crop_region.dart
@@ -0,0 +1,48 @@
+import 'dart:ui';
+
+import 'package:collection/collection.dart';
+import 'package:equatable/equatable.dart';
+import 'package:flutter/foundation.dart';
+
+@immutable
+class CropRegion extends Equatable {
+ final Offset topLeft, topRight, bottomRight, bottomLeft;
+
+ List get corners => [topLeft, topRight, bottomRight, bottomLeft];
+
+ Offset get center => (topLeft + bottomRight) / 2;
+
+ Rect get outsideRect {
+ final xMin = corners.map((v) => v.dx).min;
+ final xMax = corners.map((v) => v.dx).max;
+ final yMin = corners.map((v) => v.dy).min;
+ final yMax = corners.map((v) => v.dy).max;
+ return Rect.fromPoints(Offset(xMin, yMin), Offset(xMax, yMax));
+ }
+
+ @override
+ List