diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt b/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt index fabdf5eef7a..db8e2c548a1 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt @@ -31,6 +31,7 @@ import android.os.Bundle import android.preference.PreferenceManager import android.view.Menu import android.view.MenuItem +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment @@ -41,9 +42,12 @@ import org.catrobat.catroid.common.Constants.DEFAULT_IMAGE_EXTENSION import org.catrobat.catroid.common.Constants.TMP_IMAGE_FILE_NAME import org.catrobat.catroid.common.FlavoredConstants import org.catrobat.catroid.content.Project +import org.catrobat.catroid.content.Sprite import org.catrobat.catroid.content.StartScript import org.catrobat.catroid.content.bricks.Brick import org.catrobat.catroid.content.bricks.PlaceAtBrick +import org.catrobat.catroid.content.bricks.PointInDirectionBrick +import org.catrobat.catroid.content.bricks.SetSizeToBrick import org.catrobat.catroid.databinding.ActivityRecyclerBinding import org.catrobat.catroid.databinding.DialogNewActorBinding import org.catrobat.catroid.databinding.ProgressBarBinding @@ -72,6 +76,7 @@ import org.catrobat.catroid.utils.ToastUtil import org.catrobat.catroid.utils.Utils import org.catrobat.catroid.utils.setVisibleOrGone import org.catrobat.catroid.visualplacement.VisualPlacementActivity +import org.catrobat.catroid.content.Look.DEGREE_UI_OFFSET import org.koin.android.ext.android.inject import java.io.File @@ -89,6 +94,32 @@ class ProjectActivity : BaseCastActivity() { const val SPRITE_CAMERA = 3 const val SPRITE_OBJECT = 4 const val SPRITE_FROM_LOCAL = 5 + + const val DEFAULT_SCALE = 1.0f + const val PERCENTAGE_MULTIPLIER = 100.0 + + @JvmStatic + @VisibleForTesting + internal fun applyVisualPlacementBricks( + sprite: Sprite, + xCoordinate: Int, + yCoordinate: Int, + placementScale: Float, + placementRotation: Float + ) { + val startScript = StartScript() + sprite.prependScript(startScript) + startScript.addBrick(PlaceAtBrick(xCoordinate, yCoordinate)) + if (placementScale != DEFAULT_SCALE) { + val sizePercent = placementScale.toDouble() * + PERCENTAGE_MULTIPLIER + startScript.addBrick(SetSizeToBrick(sizePercent)) + } + if (placementRotation != DEGREE_UI_OFFSET) { + val direction = placementRotation.toDouble() + startScript.addBrick(PointInDirectionBrick(direction)) + } + } } private lateinit var binding: ActivityRecyclerBinding @@ -258,11 +289,15 @@ class ProjectActivity : BaseCastActivity() { extras.getInt(VisualPlacementActivity.X_COORDINATE_BUNDLE_ARGUMENT) val yCoordinate = extras.getInt(VisualPlacementActivity.Y_COORDINATE_BUNDLE_ARGUMENT) - val placeAtBrick = PlaceAtBrick(xCoordinate, yCoordinate) - val currentSprite = projectManager.currentSprite - val startScript = StartScript() - currentSprite.prependScript(startScript) - startScript.addBrick(placeAtBrick) + val placementScale = + extras.getFloat(VisualPlacementActivity.SCALE_BUNDLE_ARGUMENT, 1.0f) + val placementRotation = + extras.getFloat(VisualPlacementActivity.ROTATION_BUNDLE_ARGUMENT, DEGREE_UI_OFFSET) + val currentSprite = projectManager.currentSprite ?: return + applyVisualPlacementBricks( + currentSprite, xCoordinate, yCoordinate, + placementScale, placementRotation + ) } SPRITE_FROM_LOCAL -> if (data != null && data.hasExtra(ProjectListActivity.IMPORT_LOCAL_INTENT)) { diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java index f499330634c..e0f9b8f294f 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java +++ b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java @@ -1,6 +1,6 @@ /* * Catroid: An on-device visual programming system for Android devices - * Copyright (C) 2010-2025 The Catrobat Team + * Copyright (C) 2010-2026 The Catrobat Team * () * * This program is free software: you can redistribute it and/or modify @@ -96,6 +96,7 @@ import static org.catrobat.catroid.common.FlavoredConstants.CATROBAT_CONTENT_LOOKS_URL; import static org.catrobat.catroid.common.FlavoredConstants.CATROBAT_CONTENT_SOUNDS_URL; import static org.catrobat.catroid.common.SharedPreferenceKeys.INDEXING_VARIABLE_PREFERENCE_KEY; +import static org.catrobat.catroid.content.Look.DEGREE_UI_OFFSET; import static org.catrobat.catroid.stage.TestResult.TEST_RESULT_MESSAGE; import static org.catrobat.catroid.ui.SpriteActivityOnTabSelectedListenerKt.addTabLayout; import static org.catrobat.catroid.ui.SpriteActivityOnTabSelectedListenerKt.getTabPositionInSpriteActivity; @@ -104,6 +105,8 @@ import static org.catrobat.catroid.ui.SpriteActivityOnTabSelectedListenerKt.removeTabLayout; import static org.catrobat.catroid.ui.WebViewActivity.MEDIA_FILE_PATH; import static org.catrobat.catroid.visualplacement.VisualPlacementActivity.CHANGED_COORDINATES; +import static org.catrobat.catroid.visualplacement.VisualPlacementActivity.ROTATION_BUNDLE_ARGUMENT; +import static org.catrobat.catroid.visualplacement.VisualPlacementActivity.SCALE_BUNDLE_ARGUMENT; import static org.catrobat.catroid.visualplacement.VisualPlacementActivity.X_COORDINATE_BUNDLE_ARGUMENT; import static org.catrobat.catroid.visualplacement.VisualPlacementActivity.Y_COORDINATE_BUNDLE_ARGUMENT; @@ -349,8 +352,9 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { } if (resultCode != RESULT_OK) { - if (SettingsFragment.isCastSharedPreferenceEnabled(this) - && projectManager.getCurrentProject().isCastProject() + Project project = projectManager.getCurrentProject(); + if (project != null && SettingsFragment.isCastSharedPreferenceEnabled(this) + && project.isCastProject() && !CastManager.getInstance().isConnected()) { CastManager.getInstance().openDeviceSelectorOrDisconnectDialog(this); @@ -430,6 +434,8 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { int xCoordinate = extras.getInt(X_COORDINATE_BUNDLE_ARGUMENT); int yCoordinate = extras.getInt(Y_COORDINATE_BUNDLE_ARGUMENT); + float placementScale = extras.getFloat(SCALE_BUNDLE_ARGUMENT, 1.0f); + float placementRotation = extras.getFloat(ROTATION_BUNDLE_ARGUMENT, DEGREE_UI_OFFSET); int brickHash = extras.getInt(EXTRA_BRICK_HASH); Fragment fragment = getCurrentFragment(); @@ -448,6 +454,21 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { } } + if (placementScale != 1.0f) { + Sprite sprite = projectManager.getCurrentSprite(); + if (sprite != null && sprite.look != null) { + sprite.look.setScaleX(placementScale); + sprite.look.setScaleY(placementScale); + } + } + + if (placementRotation != DEGREE_UI_OFFSET) { + Sprite rotSprite = projectManager.getCurrentSprite(); + if (rotSprite != null && rotSprite.look != null) { + rotSprite.look.setMotionDirectionInUserInterfaceDimensionUnit(placementRotation); + } + } + setUndoMenuItemVisibility(extras.getBoolean(CHANGED_COORDINATES)); break; diff --git a/catroid/src/main/java/org/catrobat/catroid/visualplacement/BoundingBoxOverlay.kt b/catroid/src/main/java/org/catrobat/catroid/visualplacement/BoundingBoxOverlay.kt new file mode 100644 index 00000000000..0592fbd067b --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/visualplacement/BoundingBoxOverlay.kt @@ -0,0 +1,118 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.visualplacement + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.view.View +import android.widget.ImageView +import androidx.core.content.ContextCompat +import androidx.core.graphics.toColorInt +import androidx.core.graphics.withRotation +import androidx.core.graphics.withTranslation +import org.catrobat.catroid.R + +class BoundingBoxOverlay(context: Context) : View(context) { + + private val boundingBoxPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = BOUNDING_BOX_COLOR + style = Paint.Style.STROKE + strokeWidth = BOUNDING_BOX_STROKE_WIDTH + } + + private val cornerHandleDrawable = ContextCompat.getDrawable(context, R.drawable.ic_corner_handle) + + private val handleSizePx: Int = + (HANDLE_SIZE_DP * context.resources.displayMetrics.density).toInt() + + private val rect = RectF() + + var trackedImageView: ImageView? = null + + fun updateOverlay() { + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + val imageView = trackedImageView ?: return + val drawable = imageView.drawable ?: return + + val imgWidth = drawable.intrinsicWidth.toFloat() + val imgHeight = drawable.intrinsicHeight.toFloat() + + val scaleX = imageView.scaleX + val scaleY = imageView.scaleY + + val scaledWidth = imgWidth * scaleX + val scaledHeight = imgHeight * scaleY + + val viewCenterX = imageView.x + imageView.width / 2f + val viewCenterY = imageView.y + imageView.height / 2f + + val left = viewCenterX - scaledWidth / 2f + val top = viewCenterY - scaledHeight / 2f + val right = viewCenterX + scaledWidth / 2f + val bottom = viewCenterY + scaledHeight / 2f + + rect.left = left + rect.top = top + rect.right = right + rect.bottom = bottom + + canvas.withRotation(imageView.rotation, viewCenterX, viewCenterY) { + drawRect(rect, boundingBoxPaint) + + drawCornerHandle(this, left, top, ROTATION_0) + drawCornerHandle(this, right, top, ROTATION_90) + drawCornerHandle(this, right, bottom, ROTATION_180) + drawCornerHandle(this, left, bottom, ROTATION_270) + } + } + + private fun drawCornerHandle(canvas: Canvas, cx: Float, cy: Float, rotationDegrees: Float) { + val handle = cornerHandleDrawable ?: return + + canvas.withTranslation(cx, cy) { + withRotation(rotationDegrees) { + val halfSize = handleSizePx / 2 + handle.setBounds(-halfSize, -halfSize, halfSize, halfSize) + handle.draw(this) + } + } + } + + companion object { + private const val BOUNDING_BOX_STROKE_WIDTH = 3f + private val BOUNDING_BOX_COLOR = "#4FC3F7".toColorInt() + private const val HANDLE_SIZE_DP = 32 + private const val ROTATION_0 = 0f + private const val ROTATION_90 = 90f + private const val ROTATION_180 = 180f + private const val ROTATION_270 = 270f + } +} diff --git a/catroid/src/main/java/org/catrobat/catroid/visualplacement/ResizeRotateGestureDetector.kt b/catroid/src/main/java/org/catrobat/catroid/visualplacement/ResizeRotateGestureDetector.kt new file mode 100644 index 00000000000..4c0f2825823 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/visualplacement/ResizeRotateGestureDetector.kt @@ -0,0 +1,244 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.visualplacement + +import android.view.MotionEvent +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.sqrt + +class ResizeRotateGestureDetector(private val listener: OnTransformGestureListener) { + + interface OnTransformGestureListener { + fun onScale(scaleFactor: Float) + fun onRotate(rotationDegrees: Float) + fun onPan(dx: Float, dy: Float) + } + + var isTransforming: Boolean = false + private set + + var cumulativeScale: Float = 1.0f + var cumulativeRotation: Float = 0.0f + + private var initialDistance: Float = 0f + private var initialAngle: Float = 0f + private var previousMidpointX: Float = 0f + private var previousMidpointY: Float = 0f + + private var lastRotationTime: Long = 0 + private var lastRotationAngle: Float = 0f + private var isRotationActive: Boolean = false + private var isPanActive: Boolean = false + private var accumulatedPanX: Float = 0f + private var accumulatedPanY: Float = 0f + + fun onTouchEvent(event: MotionEvent): Boolean { + if (event.pointerCount < 2) { + return handleSinglePointer() + } + + val x0 = event.getX(0) + val y0 = event.getY(0) + val x1 = event.getX(1) + val y1 = event.getY(1) + + val currentDistance = calculateDistance(x0, y0, x1, y1) + val currentAngle = calculateAngle(x0, y0, x1, y1) + val currentMidpointX = (x0 + x1) / 2f + val currentMidpointY = (y0 + y1) / 2f + + return when (event.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> + handlePointerDown(currentDistance, currentAngle, currentMidpointX, currentMidpointY) + MotionEvent.ACTION_MOVE -> + handleMove(currentDistance, currentAngle, currentMidpointX, currentMidpointY) + MotionEvent.ACTION_POINTER_UP -> + handlePointerUp(currentDistance, currentAngle) + else -> false + } + } + + private fun handleSinglePointer(): Boolean { + if (isTransforming) { + isTransforming = false + isRotationActive = false + isPanActive = false + } + return false + } + + private fun handlePointerDown( + distance: Float, + angle: Float, + midpointX: Float, + midpointY: Float + ): Boolean { + isTransforming = true + isRotationActive = false + isPanActive = false + accumulatedPanX = 0f + accumulatedPanY = 0f + initialDistance = distance + initialAngle = angle + previousMidpointX = midpointX + previousMidpointY = midpointY + lastRotationTime = System.currentTimeMillis() + lastRotationAngle = angle + return true + } + + private fun handleMove( + currentDistance: Float, + currentAngle: Float, + currentMidpointX: Float, + currentMidpointY: Float + ): Boolean { + if (!isTransforming || initialDistance <= 0) { + return false + } + + applyScale(currentDistance) + applyRotation(currentAngle) + applyPan(currentMidpointX, currentMidpointY) + return true + } + + private fun applyScale(currentDistance: Float) { + val scaleFactor = currentDistance / initialDistance + val newScale = (cumulativeScale * scaleFactor).coerceIn(MIN_SCALE, MAX_SCALE) + listener.onScale(newScale) + } + + private fun applyRotation(currentAngle: Float) { + val angleDelta = normalizeAngle(currentAngle - initialAngle) + + if (!isRotationActive && abs(angleDelta) < ROTATION_DEAD_ZONE) { + return + } + isRotationActive = true + + var totalRotation = cumulativeRotation + angleDelta + totalRotation = applySnappingIfNeeded(totalRotation, currentAngle) + listener.onRotate(totalRotation) + } + + private fun applySnappingIfNeeded(totalRotation: Float, currentAngle: Float): Float { + val currentTime = System.currentTimeMillis() + val deltaTime = currentTime - lastRotationTime + var result = totalRotation + + if (deltaTime > 0) { + val rotationVelocity = abs(currentAngle - lastRotationAngle) / deltaTime + if (rotationVelocity > SNAP_VELOCITY_THRESHOLD) { + result = getSnappedAngle(totalRotation) + } + } + lastRotationTime = currentTime + lastRotationAngle = currentAngle + return result + } + + private fun applyPan(currentMidpointX: Float, currentMidpointY: Float) { + val dx = currentMidpointX - previousMidpointX + val dy = currentMidpointY - previousMidpointY + previousMidpointX = currentMidpointX + previousMidpointY = currentMidpointY + + if (!isPanActive) { + accumulatedPanX += dx + accumulatedPanY += dy + val totalDrift = sqrt(accumulatedPanX * accumulatedPanX + accumulatedPanY * accumulatedPanY) + if (totalDrift < PAN_DEAD_ZONE) { + return + } + isPanActive = true + listener.onPan(accumulatedPanX, accumulatedPanY) + } else { + listener.onPan(dx, dy) + } + } + + private fun handlePointerUp(currentDistance: Float, currentAngle: Float): Boolean { + if (isTransforming && initialDistance > 0) { + val finalScaleFactor = currentDistance / initialDistance + cumulativeScale = (cumulativeScale * finalScaleFactor).coerceIn(MIN_SCALE, MAX_SCALE) + + if (isRotationActive) { + val finalAngleDelta = normalizeAngle(currentAngle - initialAngle) + cumulativeRotation += finalAngleDelta + } + } + isTransforming = false + isRotationActive = false + isPanActive = false + return true + } + + private fun getSnappedAngle(angle: Float): Float { + val normalizedRotation = (angle % FULL_CIRCLE + FULL_CIRCLE) % FULL_CIRCLE + for (snapAngle in SNAP_ANGLES) { + if (abs(normalizedRotation - snapAngle) < SNAP_ANGLE_THRESHOLD) { + return snapAngle + } + } + return angle + } + + companion object { + private const val MIN_SCALE = 0.1f + private const val MAX_SCALE = 5.0f + private const val HALF_CIRCLE = 180f + private const val FULL_CIRCLE = 360f + private const val SNAP_VELOCITY_THRESHOLD = 1.5f // degrees per ms + private const val SNAP_ANGLE_THRESHOLD = 10f // degrees + private const val ROTATION_DEAD_ZONE = 5f // degrees threshold before rotation activates + private const val PAN_DEAD_ZONE = 10f // pixels threshold before pan activates + + private val SNAP_ANGLES = floatArrayOf(0f, 90f, 180f, 270f, FULL_CIRCLE) + + /** + * Wraps an angle delta into the [-180, 180] range so that + * crossing the atan2 ±180° boundary never produces a ~360° jump. + */ + @JvmStatic + fun normalizeAngle(angle: Float): Float { + var a = angle % FULL_CIRCLE + if (a > HALF_CIRCLE) a -= FULL_CIRCLE + if (a < -HALF_CIRCLE) a += FULL_CIRCLE + return a + } + + @JvmStatic + fun calculateDistance(x0: Float, y0: Float, x1: Float, y1: Float): Float { + val dx = x1 - x0 + val dy = y1 - y0 + return sqrt(dx * dx + dy * dy) + } + + @JvmStatic + fun calculateAngle(x0: Float, y0: Float, x1: Float, y1: Float): Float = + Math.toDegrees(atan2((y1 - y0).toDouble(), (x1 - x0).toDouble())).toFloat() + } +} diff --git a/catroid/src/main/java/org/catrobat/catroid/visualplacement/TransformationInterface.kt b/catroid/src/main/java/org/catrobat/catroid/visualplacement/TransformationInterface.kt new file mode 100644 index 00000000000..060229d6fe2 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/visualplacement/TransformationInterface.kt @@ -0,0 +1,29 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.visualplacement + +interface TransformationInterface : CoordinateInterface { + fun setScale(scaleFactor: Float) + fun setRotation(rotationDegrees: Float) +} diff --git a/catroid/src/main/java/org/catrobat/catroid/visualplacement/VisualPlacementActivity.java b/catroid/src/main/java/org/catrobat/catroid/visualplacement/VisualPlacementActivity.java index 8d7bd7e9122..6f9a1c69368 100644 --- a/catroid/src/main/java/org/catrobat/catroid/visualplacement/VisualPlacementActivity.java +++ b/catroid/src/main/java/org/catrobat/catroid/visualplacement/VisualPlacementActivity.java @@ -1,6 +1,6 @@ /* * Catroid: An on-device visual programming system for Android devices - * Copyright (C) 2010-2025 The Catrobat Team + * Copyright (C) 2010-2026 The Catrobat Team * () * * This program is free software: you can redistribute it and/or modify @@ -52,6 +52,7 @@ import org.catrobat.catroid.ProjectManager; import org.catrobat.catroid.R; import org.catrobat.catroid.common.ScreenValues; +import org.catrobat.catroid.utils.ScreenValueHandler; import org.catrobat.catroid.content.Project; import org.catrobat.catroid.content.Sprite; import org.catrobat.catroid.ui.BaseCastActivity; @@ -92,12 +93,14 @@ import static org.catrobat.catroid.utils.ShowTextUtils.sanitizeTextSize; public class VisualPlacementActivity extends BaseCastActivity implements View.OnTouchListener, - DialogInterface.OnClickListener, CoordinateInterface { + DialogInterface.OnClickListener, TransformationInterface { public static final String TAG = VisualPlacementActivity.class.getSimpleName(); public static final String X_COORDINATE_BUNDLE_ARGUMENT = "xCoordinate"; public static final String Y_COORDINATE_BUNDLE_ARGUMENT = "yCoordinate"; + public static final String SCALE_BUNDLE_ARGUMENT = "scaleFactor"; + public static final String ROTATION_BUNDLE_ARGUMENT = "rotationDegrees"; public static final String CHANGED_COORDINATES = "changedCoordinates"; private ProjectManager projectManager; @@ -109,11 +112,16 @@ public class VisualPlacementActivity extends BaseCastActivity implements View.On private float yCoord; private float scaleX; private float scaleY; - private float rotation; + private float initialLookRotation; private int rotationMode; private float translateX; private float translateY; + private float scaleFactor = 1.0f; + private float rotation = 0.0f; + private float initialSpriteScale = 1.0f; + private float initialSpriteRotation = DEGREE_UI_OFFSET; + private boolean isText; private String text; private String textColor; @@ -125,6 +133,8 @@ public class VisualPlacementActivity extends BaseCastActivity implements View.On private float layoutWidthRatio; private float layoutHeightRatio; private VisualPlacementTouchListener visualPlacementTouchListener; + private ResizeRotateGestureDetector resizeRotateDetector; + private BoundingBoxOverlay boundingBoxOverlay; @Override public boolean onCreateOptionsMenu(Menu menu) { @@ -134,17 +144,46 @@ public boolean onCreateOptionsMenu(Menu menu) { @Override public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - onBackPressed(); - break; - case R.id.confirm: - finishWithResult(); - break; + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + onBackPressed(); + } else if (itemId == R.id.confirm) { + finishWithResult(); + } else if (itemId == R.id.reset) { + resetTransformations(); + } else if (itemId == R.id.rotate_90) { + rotateBy90Degrees(); } return true; } + private void resetTransformations() { + setupImageViewPositionAndScale(); + if (boundingBoxOverlay != null) { + boundingBoxOverlay.updateOverlay(); + } + } + + private void rotateBy90Degrees() { + if (imageView == null) { + return; + } + rotation += 90f; + imageView.setRotation(rotation); + resizeRotateDetector.setCumulativeRotation(rotation); + if (boundingBoxOverlay != null) { + boundingBoxOverlay.updateOverlay(); + } + } + + private void updateCoordinatesFromView() { + if (imageView == null) { + return; + } + setXCoordinate(imageView.getX()); + setYCoordinate(imageView.getY()); + } + @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(null); @@ -158,9 +197,49 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { projectManager = ProjectManager.getInstance(); Project currentProject = projectManager.getCurrentProject(); + if (currentProject == null || projectManager.getCurrentSprite() == null) { + finish(); + return; + } setContentView(R.layout.visual_placement_layout); Bundle extras = getIntent().getExtras(); + if (extras == null) { + finish(); + return; + } + + parseIntentExtras(extras); + setupToolbar(); + setupOrientation(); + ScreenValueHandler.updateScreenWidthAndHeight(this); + + resizeRotateDetector = createResizeRotateDetector(); + visualPlacementTouchListener = new VisualPlacementTouchListener(); + visualPlacementTouchListener.setResizeRotateDetector(resizeRotateDetector); + + frameLayout = findViewById(R.id.frame_container); + setupLayout(currentProject); + + bitmapOptions = new BitmapFactory.Options(); + bitmapOptions.inPreferredConfig = Bitmap.Config.ARGB_8888; + + setBackground(); + showMovableImageView(); + + boundingBoxOverlay = new BoundingBoxOverlay(this); + boundingBoxOverlay.setTrackedImageView(imageView); + frameLayout.addView(boundingBoxOverlay, + new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + + visualPlacementTouchListener.setBoundingBoxOverlay(boundingBoxOverlay); + + findViewById(R.id.transparent_toolbar).bringToFront(); + frameLayout.setOnTouchListener(this); + } + + private void parseIntentExtras(Bundle extras) { translateX = extras.getInt(EXTRA_X_TRANSFORM); translateY = extras.getInt(EXTRA_Y_TRANSFORM); if (extras.containsKey(EXTRA_TEXT)) { @@ -175,21 +254,55 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { } xOffsetText = -DEFAULT_X_OFFSET; } + } + private void setupToolbar() { Toolbar toolbar = findViewById(R.id.transparent_toolbar); setSupportActionBar(toolbar); getSupportActionBar().setDisplayHomeAsUpEnabled(true); getSupportActionBar().setTitle(R.string.brick_option_place_visually); + } + private void setupOrientation() { if (projectManager.isCurrentProjectLandscapeMode()) { setRequestedOrientation(SCREEN_ORIENTATION_LANDSCAPE); } else { setRequestedOrientation(SCREEN_ORIENTATION_PORTRAIT); } - visualPlacementTouchListener = new VisualPlacementTouchListener(); + } - frameLayout = findViewById(R.id.frame_container); + private ResizeRotateGestureDetector createResizeRotateDetector() { + return new ResizeRotateGestureDetector(new ResizeRotateGestureDetector.OnTransformGestureListener() { + @Override + public void onScale(float scale) { + scaleFactor = scale; + if (imageView != null) { + float appliedScale = initialSpriteScale * scaleFactor; + imageView.setScaleX(appliedScale); + imageView.setScaleY(appliedScale); + } + } + + @Override + public void onRotate(float rotation) { + VisualPlacementActivity.this.rotation = rotation; + if (imageView != null) { + imageView.setRotation(VisualPlacementActivity.this.rotation); + } + } + + @Override + public void onPan(float dx, float dy) { + if (imageView != null) { + imageView.setX(imageView.getX() + dx); + imageView.setY(imageView.getY() + dy); + updateCoordinatesFromView(); + } + } + }); + } + private void setupLayout(Project currentProject) { Resolution projectResolution = new Resolution( currentProject.getXmlHeader().virtualScreenWidth, currentProject.getXmlHeader().virtualScreenHeight); @@ -203,8 +316,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { break; } - LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT); + LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) frameLayout.getLayoutParams(); layoutParams.gravity = Gravity.CENTER; layoutParams.width = layoutResolution.getWidth(); layoutParams.height = layoutResolution.getHeight(); @@ -212,89 +324,120 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { layoutHeightRatio = (float) layoutResolution.getHeight() / (float) projectResolution.getHeight(); layoutWidthRatio = (float) layoutResolution.getWidth() / (float) projectResolution.getWidth(); - - bitmapOptions = new BitmapFactory.Options(); - bitmapOptions.inPreferredConfig = Bitmap.Config.ARGB_8888; - - setBackground(); - showMovableImageView(); - - toolbar.bringToFront(); - frameLayout.setOnTouchListener(this); } private void setBackground() { try { Bitmap backgroundBitmap = ProjectManagerExtensionsKt.getProjectBitmap(projectManager); - Bitmap scaledBackgroundBitmap = Bitmap.createScaledBitmap(backgroundBitmap, - (int) (backgroundBitmap.getWidth() * layoutWidthRatio), - (int) (backgroundBitmap.getHeight() * layoutHeightRatio), true); - Drawable backgroundDrawable = new BitmapDrawable(getResources(), scaledBackgroundBitmap); - backgroundDrawable.setColorFilter(Color.parseColor("#6F000000"), PorterDuff.Mode.SRC_ATOP); - - frameLayout.setBackground(backgroundDrawable); + if (backgroundBitmap != null) { + Bitmap scaledBackgroundBitmap = Bitmap.createScaledBitmap(backgroundBitmap, + (int) (backgroundBitmap.getWidth() * layoutWidthRatio), + (int) (backgroundBitmap.getHeight() * layoutHeightRatio), true); + Drawable backgroundDrawable = new BitmapDrawable(getResources(), scaledBackgroundBitmap); + backgroundDrawable.setColorFilter(Color.parseColor("#6F000000"), PorterDuff.Mode.SRC_ATOP); + + frameLayout.setBackground(backgroundDrawable); + if (backgroundBitmap != scaledBackgroundBitmap) { + backgroundBitmap.recycle(); + } + } } catch (Exception e) { frameLayout.setBackgroundColor(Color.WHITE); } } public void showMovableImageView() { - Bitmap visualPlacementBitmap; - String objectLookPath; - Sprite currentSprite = projectManager.getCurrentSprite(); - imageView = new ImageView(this); + Bitmap visualPlacementBitmap = loadBitmapForPlacement(); + + if (visualPlacementBitmap != null) { + visualPlacementBitmap = applyInitialBitmapTransformations(visualPlacementBitmap); + imageView.setImageBitmap(visualPlacementBitmap); + } + + setupImageViewPositionAndScale(); + frameLayout.addView(imageView); + } + + private Bitmap loadBitmapForPlacement() { + Bitmap bitmap = null; + Sprite currentSprite = projectManager.getCurrentSprite(); if (isText) { - visualPlacementBitmap = convertTextToBitmap(); - } else { - if (!currentSprite.look.getImagePath().isEmpty()) { - objectLookPath = currentSprite.look.getImagePath(); - scaleX = currentSprite.look.getScaleX(); - scaleY = currentSprite.look.getScaleY(); - rotationMode = currentSprite.look.getRotationMode(); - rotation = currentSprite.look.getMotionDirectionInUserInterfaceDimensionUnit(); - visualPlacementBitmap = BitmapFactory.decodeFile(objectLookPath, bitmapOptions); - } else if (currentSprite.getLookList().size() != 0) { - objectLookPath = currentSprite.getLookList().get(0).getFile().getAbsolutePath(); - visualPlacementBitmap = BitmapFactory.decodeFile(objectLookPath, bitmapOptions); - } else { - Drawable drawable = ContextCompat.getDrawable(getApplicationContext(), R.drawable.pc_toolbar_icon); - - visualPlacementBitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(visualPlacementBitmap); - drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); - drawable.draw(canvas); - } + bitmap = convertTextToBitmap(); + } else if (currentSprite.look != null && !currentSprite.look.getImagePath().isEmpty()) { + String objectLookPath = currentSprite.look.getImagePath(); + scaleX = currentSprite.look.getScaleX(); + scaleY = currentSprite.look.getScaleY(); + rotationMode = currentSprite.look.getRotationMode(); + initialLookRotation = currentSprite.look.getMotionDirectionInUserInterfaceDimensionUnit(); + bitmap = BitmapFactory.decodeFile(objectLookPath, bitmapOptions); + } else if (currentSprite.getLookList() != null && !currentSprite.getLookList().isEmpty() + && currentSprite.getLookList().get(0).getFile() != null) { + String path = currentSprite.getLookList().get(0).getFile().getAbsolutePath(); + rotationMode = ROTATION_STYLE_ALL_AROUND; + initialLookRotation = DEGREE_UI_OFFSET; + bitmap = BitmapFactory.decodeFile(path, bitmapOptions); + } + + if (bitmap == null) { + bitmap = loadDefaultToolbarIcon(); } + return bitmap; + } + private Bitmap loadDefaultToolbarIcon() { + Drawable drawable = ContextCompat.getDrawable(getApplicationContext(), R.drawable.pc_toolbar_icon); + if (drawable == null) { + return Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); + } + int width = Math.max(1, drawable.getIntrinsicWidth()); + int height = Math.max(1, drawable.getIntrinsicHeight()); + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, width, height); + drawable.draw(canvas); + return bitmap; + } + + private Bitmap applyInitialBitmapTransformations(Bitmap bitmap) { + Matrix matrix = createInitialRotationMatrix(bitmap); + Bitmap rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true); + if (bitmap != rotatedBitmap) { + bitmap.recycle(); + } + bitmap = rotatedBitmap; + + Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, (int) (bitmap.getWidth() * layoutWidthRatio), + (int) (bitmap.getHeight() * layoutHeightRatio), true); + if (bitmap != scaledBitmap) { + bitmap.recycle(); + } + return scaledBitmap; + } + + private Matrix createInitialRotationMatrix(Bitmap bitmap) { Matrix matrix = new Matrix(); switch (rotationMode) { case ROTATION_STYLE_NONE: matrix.postRotate(0); break; case ROTATION_STYLE_ALL_AROUND: - if (rotation != 90) { - matrix.postRotate(rotation - DEGREE_UI_OFFSET); + if (initialLookRotation != 90) { + matrix.postRotate(initialLookRotation - DEGREE_UI_OFFSET); } break; case ROTATION_STYLE_LEFT_RIGHT_ONLY: - if (rotation < 0) { - matrix.postScale(-1, 1, (float) visualPlacementBitmap.getWidth() / 2, (float) visualPlacementBitmap.getHeight() / 2); + if (initialLookRotation < 0) { + matrix.postScale(-1, 1, (float) bitmap.getWidth() / 2, (float) bitmap.getHeight() / 2); } break; } + return matrix; + } - visualPlacementBitmap = Bitmap.createBitmap(visualPlacementBitmap, 0, 0, - visualPlacementBitmap.getWidth(), - visualPlacementBitmap.getHeight(), matrix, true); - - Bitmap scaledBitmap = Bitmap.createScaledBitmap(visualPlacementBitmap, (int) (visualPlacementBitmap.getWidth() * layoutWidthRatio), - (int) (visualPlacementBitmap.getHeight() * layoutHeightRatio), true); - - imageView.setImageBitmap(scaledBitmap); + private void setupImageViewPositionAndScale() { imageView.setScaleType(ImageView.ScaleType.CENTER); - if (isText) { imageView.setTranslationX(translateX + xOffsetText); imageView.setTranslationY(-translateY + yOffsetText); @@ -307,12 +450,17 @@ public void showMovableImageView() { if (scaleX > 0.01) { imageView.setScaleX(scaleX); + initialSpriteScale = scaleX; } - if (scaleY > 0.01) { imageView.setScaleY(scaleY); } - frameLayout.addView(imageView); + initialSpriteRotation = initialLookRotation; + + resizeRotateDetector.setCumulativeScale(1.0f); + resizeRotateDetector.setCumulativeRotation(0.0f); + scaleFactor = 1.0f; + rotation = 0.0f; } private Bitmap convertTextToBitmap() { @@ -334,12 +482,15 @@ private Bitmap convertTextToBitmap() { float baseline = -paint.ascent(); int bitmapWidth = (int) paint.measureText(text); - int canvasWidth = calculateAlignmentValuesForText(paint, bitmapWidth, textAlignment); int height = (int) (baseline + paint.descent()); + bitmapWidth = Math.max(1, bitmapWidth); + height = Math.max(1, height); + visualPlacementBitmap = Bitmap.createBitmap(bitmapWidth, height, Bitmap.Config.ARGB_8888); + int canvasWidth = calculateAlignmentValuesForText(paint, bitmapWidth, textAlignment); Canvas canvas = new Canvas(visualPlacementBitmap); canvas.drawText(text, canvasWidth, baseline, @@ -374,7 +525,10 @@ public void onBackPressed() { int xCoordinate = Math.round(xCoord / layoutWidthRatio); int yCoordinate = Math.round(yCoord / layoutHeightRatio); - if (translateX != xCoordinate || translateY != yCoordinate) { + boolean hasChanges = translateX != xCoordinate || translateY != yCoordinate + || scaleFactor != 1.0f || rotation != 0.0f; + + if (hasChanges) { showSaveChangesDialog(this); } else { finish(); @@ -403,7 +557,15 @@ private void finishWithResult() { extras.putInt(X_COORDINATE_BUNDLE_ARGUMENT, xCoordinate); extras.putInt(Y_COORDINATE_BUNDLE_ARGUMENT, yCoordinate); - extras.putBoolean(CHANGED_COORDINATES, translateX != xCoordinate || translateY != yCoordinate); + + float finalScale = initialSpriteScale * scaleFactor; + float finalRotation = initialSpriteRotation + rotation; + extras.putFloat(SCALE_BUNDLE_ARGUMENT, finalScale); + extras.putFloat(ROTATION_BUNDLE_ARGUMENT, finalRotation); + + boolean hasChanges = translateX != xCoordinate || translateY != yCoordinate + || scaleFactor != 1.0f || rotation != 0.0f; + extras.putBoolean(CHANGED_COORDINATES, hasChanges); returnIntent.putExtras(extras); setResult(Activity.RESULT_OK, returnIntent); @@ -437,4 +599,14 @@ public void setYCoordinate(float yCoordinate) { yCoord = yCoordinate; } } + + @Override + public void setScale(float scaleFactor) { + this.scaleFactor = scaleFactor; + } + + @Override + public void setRotation(float rotationDegrees) { + this.rotation = rotationDegrees; + } } diff --git a/catroid/src/main/java/org/catrobat/catroid/visualplacement/VisualPlacementTouchListener.java b/catroid/src/main/java/org/catrobat/catroid/visualplacement/VisualPlacementTouchListener.java index 7d9833191ea..2729edde560 100644 --- a/catroid/src/main/java/org/catrobat/catroid/visualplacement/VisualPlacementTouchListener.java +++ b/catroid/src/main/java/org/catrobat/catroid/visualplacement/VisualPlacementTouchListener.java @@ -1,6 +1,6 @@ /* * Catroid: An on-device visual programming system for Android devices - * Copyright (C) 2010-2025 The Catrobat Team + * Copyright (C) 2010-2026 The Catrobat Team * () * * This program is free software: you can redistribute it and/or modify @@ -39,6 +39,9 @@ public class VisualPlacementTouchListener { private float previousY; private List recentTouchEventsData = new ArrayList<>(); + private ResizeRotateGestureDetector resizeRotateDetector; + private BoundingBoxOverlay boundingBoxOverlay; + private final class TouchEventData { private final long timeStamp; private final float xCoordinate; @@ -55,63 +58,122 @@ private void setMode(Mode mode) { this.mode = mode; } + public void setResizeRotateDetector(ResizeRotateGestureDetector detector) { + this.resizeRotateDetector = detector; + } + + public void setBoundingBoxOverlay(BoundingBoxOverlay overlay) { + this.boundingBoxOverlay = overlay; + } + public boolean onTouch(ImageView imageView, MotionEvent event, CoordinateInterface coordinateInterface) { - if (event.getPointerId(0) == 0) { - float currentX = event.getRawX(); - float currentY = event.getRawY(); - long motionEventTime = event.getEventTime(); - - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - setMode(Mode.TAP); - previousX = currentX; - previousY = currentY; - recentTouchEventsData.add(new TouchEventData(motionEventTime, currentX, currentY)); - break; - case MotionEvent.ACTION_CANCEL: - case MotionEvent.ACTION_UP: - if (mode == Mode.TAP) { - imageView.setX(event.getX() - (float) imageView.getWidth() / 2); - imageView.setY(event.getY() - (float) imageView.getHeight() / 2); - } else { - removeObsoleteTouchEventsData(motionEventTime); - float dX = currentX - previousX; - float dY = currentY - previousY; - if (!recentTouchEventsData.isEmpty() && recentTouchEventsData.size() > 1) { - TouchEventData oldestEntry = recentTouchEventsData.get(0); - float distanceCorrectionX = currentX - oldestEntry.xCoordinate; - float distanceCorrectionY = currentY - oldestEntry.yCoordinate; - double distance = distanceCorrectionX * distanceCorrectionX + distanceCorrectionY * distanceCorrectionY; - if (distance < JITTER_DISTANCE_THRESHOLD && distance != 0) { - dX -= distanceCorrectionX; - dY -= distanceCorrectionY; - } - } - imageView.setX(imageView.getX() + dX); - imageView.setY(imageView.getY() + dY); - } - break; - case MotionEvent.ACTION_MOVE: - long delayTime = motionEventTime - event.getDownTime(); - if (delayTime > TAP_DELAY_THRESHOLD) { - setMode(Mode.MOVE); - float dX = currentX - previousX; - float dY = currentY - previousY; - imageView.setX(imageView.getX() + dX); - imageView.setY(imageView.getY() + dY); - recentTouchEventsData.add(new TouchEventData(motionEventTime, currentX, currentY)); - previousX = currentX; - previousY = currentY; - removeObsoleteTouchEventsData(motionEventTime); - } - break; - } - coordinateInterface.setXCoordinate(imageView.getX()); - coordinateInterface.setYCoordinate(-imageView.getY()); + if (imageView == null || coordinateInterface == null) { + return false; + } + + if (handleMultiTouch(event)) { return true; - } else { + } + + return handleSingleTouch(imageView, event, coordinateInterface); + } + + private boolean handleMultiTouch(MotionEvent event) { + if (resizeRotateDetector == null) { + return false; + } + if (event.getPointerCount() >= 2 || resizeRotateDetector.isTransforming()) { + boolean wasTransforming = resizeRotateDetector.isTransforming(); + resizeRotateDetector.onTouchEvent(event); + boolean isCurrentlyTransforming = resizeRotateDetector.isTransforming(); + + if (isCurrentlyTransforming) { + updateBoundingBox(); + return true; + } + if (wasTransforming && !isCurrentlyTransforming) { + previousX = event.getRawX(); + previousY = event.getRawY(); + setMode(Mode.MOVE); + updateBoundingBox(); + return true; + } + } + return false; + } + + private boolean handleSingleTouch(ImageView imageView, MotionEvent event, CoordinateInterface coordinateInterface) { + if (event.getPointerCount() <= 0 || event.getPointerId(0) != 0) { return false; } + + float currentX = event.getRawX(); + float currentY = event.getRawY(); + long motionEventTime = event.getEventTime(); + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + setMode(Mode.TAP); + previousX = currentX; + previousY = currentY; + recentTouchEventsData.add(new TouchEventData(motionEventTime, currentX, currentY)); + break; + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + handleTouchUp(imageView, event, currentX, currentY, motionEventTime); + break; + case MotionEvent.ACTION_MOVE: + handleTouchMove(imageView, event, currentX, currentY, motionEventTime); + break; + } + coordinateInterface.setXCoordinate(imageView.getX()); + coordinateInterface.setYCoordinate(-imageView.getY()); + updateBoundingBox(); + return true; + } + + private void handleTouchUp(ImageView imageView, MotionEvent event, float currentX, float currentY, long motionEventTime) { + if (mode == Mode.TAP) { + imageView.setX(event.getX() - (float) imageView.getWidth() / 2); + imageView.setY(event.getY() - (float) imageView.getHeight() / 2); + } else { + removeObsoleteTouchEventsData(motionEventTime); + float dX = currentX - previousX; + float dY = currentY - previousY; + if (!recentTouchEventsData.isEmpty() && recentTouchEventsData.size() > 1) { + TouchEventData oldestEntry = recentTouchEventsData.get(0); + float distanceCorrectionX = currentX - oldestEntry.xCoordinate; + float distanceCorrectionY = currentY - oldestEntry.yCoordinate; + double distance = distanceCorrectionX * distanceCorrectionX + distanceCorrectionY * distanceCorrectionY; + if (distance < JITTER_DISTANCE_THRESHOLD && distance != 0) { + dX -= distanceCorrectionX; + dY -= distanceCorrectionY; + } + } + imageView.setX(imageView.getX() + dX); + imageView.setY(imageView.getY() + dY); + } + } + + private void handleTouchMove(ImageView imageView, MotionEvent event, float currentX, float currentY, long motionEventTime) { + long delayTime = motionEventTime - event.getDownTime(); + if (delayTime > TAP_DELAY_THRESHOLD) { + setMode(Mode.MOVE); + float dX = currentX - previousX; + float dY = currentY - previousY; + imageView.setX(imageView.getX() + dX); + imageView.setY(imageView.getY() + dY); + recentTouchEventsData.add(new TouchEventData(motionEventTime, currentX, currentY)); + previousX = currentX; + previousY = currentY; + removeObsoleteTouchEventsData(motionEventTime); + } + } + + private void updateBoundingBox() { + if (boundingBoxOverlay != null) { + boundingBoxOverlay.updateOverlay(); + } } private void removeObsoleteTouchEventsData(long timeStamp) { diff --git a/catroid/src/main/res/drawable/ic_corner_handle.xml b/catroid/src/main/res/drawable/ic_corner_handle.xml new file mode 100644 index 00000000000..5f19d642dab --- /dev/null +++ b/catroid/src/main/res/drawable/ic_corner_handle.xml @@ -0,0 +1,45 @@ + + + + + + + + + diff --git a/catroid/src/main/res/drawable/ic_rotate_90.xml b/catroid/src/main/res/drawable/ic_rotate_90.xml new file mode 100644 index 00000000000..6d9957664fc --- /dev/null +++ b/catroid/src/main/res/drawable/ic_rotate_90.xml @@ -0,0 +1,32 @@ + + + + + diff --git a/catroid/src/main/res/menu/menu_confirm.xml b/catroid/src/main/res/menu/menu_confirm.xml index 01dcae8e73a..cfb2484f30e 100644 --- a/catroid/src/main/res/menu/menu_confirm.xml +++ b/catroid/src/main/res/menu/menu_confirm.xml @@ -25,6 +25,18 @@ + + + + Settings Redo Undo + Reset + Rotate 90° new… edit… diff --git a/catroid/src/test/java/org/catrobat/catroid/test/ui/ProjectActivityVisualPlacementTest.kt b/catroid/src/test/java/org/catrobat/catroid/test/ui/ProjectActivityVisualPlacementTest.kt new file mode 100644 index 00000000000..76b65116d12 --- /dev/null +++ b/catroid/src/test/java/org/catrobat/catroid/test/ui/ProjectActivityVisualPlacementTest.kt @@ -0,0 +1,188 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.test.ui + +import org.catrobat.catroid.content.Look.DEGREE_UI_OFFSET +import org.catrobat.catroid.content.Sprite +import org.catrobat.catroid.content.StartScript +import org.catrobat.catroid.content.bricks.PlaceAtBrick +import org.catrobat.catroid.content.bricks.PointInDirectionBrick +import org.catrobat.catroid.content.bricks.SetSizeToBrick +import org.catrobat.catroid.ui.ProjectActivity +import org.catrobat.catroid.ui.ProjectActivity.Companion.DEFAULT_SCALE +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class ProjectActivityVisualPlacementTest { + + private lateinit var sprite: Sprite + + @Before + fun setUp() { + sprite = Sprite("TestSprite") + } + + @Test + fun testDefaultRotationSuppressesPointInDirectionBrick() { + ProjectActivity.applyVisualPlacementBricks( + sprite, + xCoordinate = 100, + yCoordinate = 200, + placementScale = DEFAULT_SCALE, + placementRotation = DEGREE_UI_OFFSET + ) + + val script = sprite.getScript(0) + val bricks = script.brickList + + assertEquals(1, bricks.size) + assertTrue(bricks[0] is PlaceAtBrick) + assertFalse( + "PointInDirectionBrick must not be added for default rotation", + bricks.any { it is PointInDirectionBrick } + ) + } + + @Test + fun testDefaultScaleSuppressesSetSizeToBrick() { + ProjectActivity.applyVisualPlacementBricks( + sprite, + xCoordinate = 0, + yCoordinate = 0, + placementScale = DEFAULT_SCALE, + placementRotation = DEGREE_UI_OFFSET + ) + + val script = sprite.getScript(0) + val bricks = script.brickList + + assertEquals(1, bricks.size) + assertFalse( + "SetSizeToBrick must not be added for default scale", + bricks.any { it is SetSizeToBrick } + ) + } + + @Test + fun testNonDefaultRotationAddsPointInDirectionBrick() { + val customRotation = 45.0f + + ProjectActivity.applyVisualPlacementBricks( + sprite, + xCoordinate = 50, + yCoordinate = -30, + placementScale = DEFAULT_SCALE, + placementRotation = customRotation + ) + + val script = sprite.getScript(0) + val bricks = script.brickList + + assertEquals(2, bricks.size) + assertTrue(bricks[0] is PlaceAtBrick) + assertTrue(bricks[1] is PointInDirectionBrick) + } + + @Test + fun testNonDefaultScaleAddsSetSizeToBrick() { + val customScale = 2.5f + + ProjectActivity.applyVisualPlacementBricks( + sprite, + xCoordinate = 0, + yCoordinate = 0, + placementScale = customScale, + placementRotation = DEGREE_UI_OFFSET + ) + + val script = sprite.getScript(0) + val bricks = script.brickList + + assertEquals(2, bricks.size) + assertTrue(bricks[0] is PlaceAtBrick) + assertTrue(bricks[1] is SetSizeToBrick) + } + + @Test + fun testNonDefaultScaleAndRotationAddsBothBricks() { + val customScale = 1.5f + val customRotation = 135.0f + + ProjectActivity.applyVisualPlacementBricks( + sprite, + xCoordinate = 10, + yCoordinate = 20, + placementScale = customScale, + placementRotation = customRotation + ) + + val script = sprite.getScript(0) + val bricks = script.brickList + + assertEquals(3, bricks.size) + assertTrue(bricks[0] is PlaceAtBrick) + assertTrue(bricks[1] is SetSizeToBrick) + assertTrue(bricks[2] is PointInDirectionBrick) + } + + @Test + fun testScriptIsPrependedToExistingScripts() { + sprite.addScript(StartScript()) + + ProjectActivity.applyVisualPlacementBricks( + sprite, + xCoordinate = 5, + yCoordinate = 10, + placementScale = DEFAULT_SCALE, + placementRotation = DEGREE_UI_OFFSET + ) + + assertEquals(2, sprite.numberOfScripts) + val firstScript = sprite.getScript(0) + assertTrue(firstScript.brickList[0] is PlaceAtBrick) + } + + @Test + fun testZeroRotationAddsPointInDirectionBrick() { + ProjectActivity.applyVisualPlacementBricks( + sprite, + xCoordinate = 0, + yCoordinate = 0, + placementScale = DEFAULT_SCALE, + placementRotation = 0.0f + ) + + val script = sprite.getScript(0) + assertTrue( + "Rotation of 0 differs from DEGREE_UI_OFFSET and must add a brick", + script.brickList.any { it is PointInDirectionBrick } + ) + } +} diff --git a/catroid/src/test/java/org/catrobat/catroid/test/visualplacement/ResizeRotateGestureDetectorTest.kt b/catroid/src/test/java/org/catrobat/catroid/test/visualplacement/ResizeRotateGestureDetectorTest.kt new file mode 100644 index 00000000000..0fbeff27b04 --- /dev/null +++ b/catroid/src/test/java/org/catrobat/catroid/test/visualplacement/ResizeRotateGestureDetectorTest.kt @@ -0,0 +1,182 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.test.visualplacement + +import org.catrobat.catroid.visualplacement.ResizeRotateGestureDetector +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class ResizeRotateGestureDetectorTest { + + private lateinit var detector: ResizeRotateGestureDetector + private var lastScale = 1.0f + private var lastRotation = 0.0f + private var lastPanDx = 0.0f + private var lastPanDy = 0.0f + + @Before + fun setUp() { + lastScale = 1.0f + lastRotation = 0.0f + lastPanDx = 0.0f + lastPanDy = 0.0f + detector = ResizeRotateGestureDetector( + object : ResizeRotateGestureDetector.OnTransformGestureListener { + override fun onScale(scaleFactor: Float) { + lastScale = scaleFactor + } + + override fun onRotate(rotationDegrees: Float) { + lastRotation = rotationDegrees + } + + override fun onPan(dx: Float, dy: Float) { + lastPanDx = dx + lastPanDy = dy + } + } + ) + } + + @Test + fun testCalculateDistanceOrigin() { + val distance = ResizeRotateGestureDetector.calculateDistance(0f, 0f, 3f, 4f) + assertEquals(5.0f, distance, DELTA) + } + + @Test + fun testCalculateDistanceSamePoint() { + val distance = ResizeRotateGestureDetector.calculateDistance(5f, 5f, 5f, 5f) + assertEquals(0.0f, distance, DELTA) + } + + @Test + fun testCalculateDistanceNegativeCoords() { + val distance = ResizeRotateGestureDetector.calculateDistance(-3f, -4f, 0f, 0f) + assertEquals(5.0f, distance, DELTA) + } + + @Test + fun testCalculateAngleHorizontal() { + val angle = ResizeRotateGestureDetector.calculateAngle(0f, 0f, 10f, 0f) + assertEquals(0.0f, angle, DELTA) + } + + @Test + fun testCalculateAngleVerticalDown() { + val angle = ResizeRotateGestureDetector.calculateAngle(0f, 0f, 0f, 10f) + assertEquals(90.0f, angle, DELTA) + } + + @Test + fun testCalculateAngleVerticalUp() { + val angle = ResizeRotateGestureDetector.calculateAngle(0f, 0f, 0f, -10f) + assertEquals(-90.0f, angle, DELTA) + } + + @Test + fun testCalculateAngle45Degrees() { + val angle = ResizeRotateGestureDetector.calculateAngle(0f, 0f, 10f, 10f) + assertEquals(45.0f, angle, DELTA) + } + + @Test + fun testInitialState() { + assertFalse(detector.isTransforming) + assertEquals(1.0f, detector.cumulativeScale, DELTA) + assertEquals(0.0f, detector.cumulativeRotation, DELTA) + } + + @Test + fun testSetCumulativeScale() { + detector.cumulativeScale = 2.0f + assertEquals(2.0f, detector.cumulativeScale, DELTA) + } + + @Test + fun testSetCumulativeRotation() { + detector.cumulativeRotation = 45.0f + assertEquals(45.0f, detector.cumulativeRotation, DELTA) + } + + @Test + fun testSingleFingerDoesNotTransform() { + assertFalse(detector.isTransforming) + } + + @Test + fun testNormalizeAngle() { + assertEquals(0.0f, ResizeRotateGestureDetector.normalizeAngle(0.0f), DELTA) + assertEquals(90.0f, ResizeRotateGestureDetector.normalizeAngle(90.0f), DELTA) + assertEquals(-90.0f, ResizeRotateGestureDetector.normalizeAngle(-90.0f), DELTA) + assertEquals(180.0f, ResizeRotateGestureDetector.normalizeAngle(180.0f), DELTA) + assertEquals(-170.0f, ResizeRotateGestureDetector.normalizeAngle(190.0f), DELTA) + assertEquals(170.0f, ResizeRotateGestureDetector.normalizeAngle(-190.0f), DELTA) + assertEquals(0.0f, ResizeRotateGestureDetector.normalizeAngle(360.0f), DELTA) + } + + companion object { + private const val DELTA = 0.01f + } + + @Test + fun testSetCumulativeRotationAndScaleForResetScenario() { + detector.cumulativeRotation = 90.0f + detector.cumulativeScale = 2.0f + + detector.cumulativeRotation = 0.0f + detector.cumulativeScale = 1.0f + + assertEquals(0.0f, detector.cumulativeRotation, DELTA) + assertEquals(1.0f, detector.cumulativeScale, DELTA) + } + + @Test + fun testCumulativeRotationForRotate90Feature() { + // Simulates the rotateBy90Degrees feature: + // rotation starts at 0, incremented by 90, then synced to detector + var rotation = 0.0f + + rotation += 90f + detector.cumulativeRotation = rotation + assertEquals(90.0f, detector.cumulativeRotation, DELTA) + + rotation += 90f + detector.cumulativeRotation = rotation + assertEquals(180.0f, detector.cumulativeRotation, DELTA) + + rotation += 90f + detector.cumulativeRotation = rotation + assertEquals(270.0f, detector.cumulativeRotation, DELTA) + + rotation += 90f + detector.cumulativeRotation = rotation + assertEquals(360.0f, detector.cumulativeRotation, DELTA) + } +} diff --git a/catroid/src/test/java/org/catrobat/catroid/test/visualplacement/VisualPlacementPositionCorrectionTest.java b/catroid/src/test/java/org/catrobat/catroid/test/visualplacement/VisualPlacementPositionCorrectionTest.java index 6ea7fdf23a5..0b8ca63d3e8 100644 --- a/catroid/src/test/java/org/catrobat/catroid/test/visualplacement/VisualPlacementPositionCorrectionTest.java +++ b/catroid/src/test/java/org/catrobat/catroid/test/visualplacement/VisualPlacementPositionCorrectionTest.java @@ -1,6 +1,6 @@ /* * Catroid: An on-device visual programming system for Android devices - * Copyright (C) 2010-2025 The Catrobat Team + * Copyright (C) 2010-2026 The Catrobat Team * () * * This program is free software: you can redistribute it and/or modify @@ -70,6 +70,8 @@ public class VisualPlacementPositionCorrectionTest { @Before public void setUp() { when(motionEvent.getDownTime()).thenReturn((long) 0); + when(motionEvent.getPointerCount()).thenReturn(1); + when(motionEvent.getPointerId(0)).thenReturn(0); } public void triggerTouchDownEvent(float startPositionX, float startPositionY) { diff --git a/catroid/src/test/java/org/catrobat/catroid/test/visualplacement/VisualPlacementResizeRotateTest.kt b/catroid/src/test/java/org/catrobat/catroid/test/visualplacement/VisualPlacementResizeRotateTest.kt new file mode 100644 index 00000000000..99603890dc6 --- /dev/null +++ b/catroid/src/test/java/org/catrobat/catroid/test/visualplacement/VisualPlacementResizeRotateTest.kt @@ -0,0 +1,175 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.test.visualplacement + +import android.view.MotionEvent +import android.widget.ImageView +import org.catrobat.catroid.visualplacement.BoundingBoxOverlay +import org.catrobat.catroid.visualplacement.CoordinateInterface +import org.catrobat.catroid.visualplacement.ResizeRotateGestureDetector +import org.catrobat.catroid.visualplacement.VisualPlacementTouchListener +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.junit.MockitoJUnitRunner +import kotlin.math.abs + +@RunWith(MockitoJUnitRunner::class) +class VisualPlacementResizeRotateTest { + + @Mock + lateinit var imageView: ImageView + + @Mock + lateinit var motionEvent: MotionEvent + + @Mock + lateinit var coordinateInterface: CoordinateInterface + + @Mock + lateinit var boundingBoxOverlay: BoundingBoxOverlay + + private lateinit var listener: VisualPlacementTouchListener + private lateinit var detector: ResizeRotateGestureDetector + private var lastScale = 1.0f + private var lastRotation = 0.0f + + @Before + fun setUp() { + lastScale = 1.0f + lastRotation = 0.0f + listener = VisualPlacementTouchListener() + detector = ResizeRotateGestureDetector( + object : ResizeRotateGestureDetector.OnTransformGestureListener { + override fun onScale(scaleFactor: Float) { + lastScale = scaleFactor + } + + override fun onRotate(rotationDegrees: Float) { + lastRotation = rotationDegrees + } + + override fun onPan(dx: Float, dy: Float) { + // Not tested in this class + } + } + ) + listener.setResizeRotateDetector(detector) + listener.setBoundingBoxOverlay(boundingBoxOverlay) + } + + @Test + fun testSingleTouchDragStillWorks() { + `when`(motionEvent.getPointerId(0)).thenReturn(0) + `when`(motionEvent.action).thenReturn(MotionEvent.ACTION_DOWN) + `when`(motionEvent.pointerCount).thenReturn(1) + `when`(imageView.x).thenReturn(0f) + `when`(imageView.y).thenReturn(0f) + + val result = listener.onTouch(imageView, motionEvent, coordinateInterface) + assertTrue(result) + verify(coordinateInterface).setXCoordinate(0f) + verify(coordinateInterface).setYCoordinate(-0f) + } + + @Test + fun testBoundingBoxOverlayUpdatedOnDrag() { + `when`(motionEvent.getPointerId(0)).thenReturn(0) + `when`(motionEvent.action).thenReturn(MotionEvent.ACTION_DOWN) + `when`(motionEvent.pointerCount).thenReturn(1) + `when`(imageView.x).thenReturn(10f) + `when`(imageView.y).thenReturn(20f) + + listener.onTouch(imageView, motionEvent, coordinateInterface) + verify(boundingBoxOverlay).updateOverlay() + } + + @Test + fun testDetectorNotTransformingWithSingleTouch() { + `when`(motionEvent.getPointerId(0)).thenReturn(0) + `when`(motionEvent.action).thenReturn(MotionEvent.ACTION_DOWN) + `when`(motionEvent.pointerCount).thenReturn(1) + `when`(imageView.x).thenReturn(0f) + `when`(imageView.y).thenReturn(0f) + + listener.onTouch(imageView, motionEvent, coordinateInterface) + assertFalse(detector.isTransforming) + } + + @Test + fun testDetectorInitialScaleIsOne() { + assertTrue(abs(detector.cumulativeScale - 1.0f) < DELTA) + } + + @Test + fun testDetectorInitialRotationIsZero() { + assertTrue(abs(detector.cumulativeRotation) < DELTA) + } + + @Test + fun testTransitionFromMultiToSingleTouchResetsBaseline() { + // 1. Start multi-touch + `when`(motionEvent.pointerCount).thenReturn(2) + `when`(motionEvent.actionMasked).thenReturn(MotionEvent.ACTION_POINTER_DOWN) + `when`(motionEvent.getX(0)).thenReturn(0f) + `when`(motionEvent.getY(0)).thenReturn(0f) + `when`(motionEvent.getX(1)).thenReturn(100f) + `when`(motionEvent.getY(1)).thenReturn(100f) + listener.onTouch(imageView, motionEvent, coordinateInterface) + + // 2. End multi-touch (Pointer Up) + `when`(motionEvent.actionMasked).thenReturn(MotionEvent.ACTION_POINTER_UP) + `when`(motionEvent.rawX).thenReturn(50f) + `when`(motionEvent.rawY).thenReturn(60f) + listener.onTouch(imageView, motionEvent, coordinateInterface) + assertFalse(detector.isTransforming) + + // 3. Subsequent Single-Touch Move + `when`(motionEvent.pointerCount).thenReturn(1) + `when`(motionEvent.action).thenReturn(MotionEvent.ACTION_MOVE) + // Long enough delay to trigger MOVE mode + `when`(motionEvent.eventTime).thenReturn(1000L) + `when`(motionEvent.downTime).thenReturn(0L) + `when`(motionEvent.rawX).thenReturn(70f) // moved 20 from 50 + `when`(motionEvent.rawY).thenReturn(85f) // moved 25 from 60 + `when`(imageView.x).thenReturn(100f) + `when`(imageView.y).thenReturn(200f) + + listener.onTouch(imageView, motionEvent, coordinateInterface) + + // If baseline was reset correctly, dX = 70-50 = 20, dY = 85-60 = 25 + // New position should be original + delta + verify(imageView).setX(100f + 20f) + verify(imageView).setY(200f + 25f) + } + + companion object { + private const val DELTA = 0.001f + } +} diff --git a/catroid/src/test/java/org/catrobat/catroid/test/visualplacement/VisualPlacementTouchListenerTest.java b/catroid/src/test/java/org/catrobat/catroid/test/visualplacement/VisualPlacementTouchListenerTest.java index edb36406370..29a3c2e26ec 100644 --- a/catroid/src/test/java/org/catrobat/catroid/test/visualplacement/VisualPlacementTouchListenerTest.java +++ b/catroid/src/test/java/org/catrobat/catroid/test/visualplacement/VisualPlacementTouchListenerTest.java @@ -1,6 +1,6 @@ /* * Catroid: An on-device visual programming system for Android devices - * Copyright (C) 2010-2025 The Catrobat Team + * Copyright (C) 2010-2026 The Catrobat Team * () * * This program is free software: you can redistribute it and/or modify @@ -61,6 +61,7 @@ public class VisualPlacementTouchListenerTest { @Test public void testTouchDownSetCoordinates() { + when(firstEvent.getPointerCount()).thenReturn(1); when(firstEvent.getAction()).thenReturn(MotionEvent.ACTION_DOWN); when(imageView.getX()).thenReturn(13f); when(imageView.getY()).thenReturn(17f); @@ -71,6 +72,7 @@ public void testTouchDownSetCoordinates() { @Test public void testTouchMoveSetCoordinates() { + when(secondEvent.getPointerCount()).thenReturn(1); when(secondEvent.getAction()).thenReturn(MotionEvent.ACTION_MOVE); when(imageView.getX()).thenReturn(10f); when(imageView.getY()).thenReturn(21f); @@ -83,6 +85,7 @@ public void testTouchMoveSetCoordinates() { @Test public void testOnTouchReturnFalse() { + when(firstEvent.getPointerCount()).thenReturn(1); when(firstEvent.getPointerId(0)).thenReturn(1); boolean returnValue = listener.onTouch(imageView, firstEvent, coordinateInterface); assertFalse(returnValue); @@ -91,6 +94,7 @@ public void testOnTouchReturnFalse() { @Test public void testOnTouchReturnTrue() { + when(firstEvent.getPointerCount()).thenReturn(1); when(firstEvent.getPointerId(0)).thenReturn(0); boolean returnValue = listener.onTouch(imageView, firstEvent, coordinateInterface); assertTrue(returnValue);