package org.thoughtcrime.securesms.conversation.colors.ui.custom import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.PointF import android.graphics.Rect import android.graphics.RectF import android.util.AttributeSet import android.view.GestureDetector import android.view.MotionEvent import android.view.View import androidx.annotation.ColorInt import androidx.annotation.Dimension import androidx.core.content.ContextCompat import androidx.core.view.GestureDetectorCompat import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.util.ViewUtil import kotlin.math.abs import kotlin.math.atan import kotlin.math.atan2 import kotlin.math.pow import kotlin.math.sqrt import kotlin.math.tan /** * Renders the gradient customization tool. * * The Gradient customization tool is two selectable circles on either side * of a rectangle with a pipe connecting them, a TOP and a BOTTOM (an edge) * * The user can then swap between the selected edge via a touch-down and can * drag the selected edge such that it traces around the outline of the square. * The other edge traces along the opposite side of the rectangle. * * The way the position along the edge is determined is by dividing the rectangle * into 8 right-angled triangles, all joining at the center. Using the specified * angle, we can determine which "octant" the top edge should be in, and can * determine its distance from the center point of the relevant edge, and use * similar logic to determine where the bottom edge lies. * * All of the math assumes an origin at the dead center of the view, and * that 0deg corresponds to a vector pointing directly towards the right hand * side of the view. This doesn't quite line up with what the gradient rendering * math requires, so we apply a simple function to degrees when it comes into or * leaves this tool (see `Float.invert`) */ class CustomChatColorGradientToolView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val clipRect = Rect() private val rect = RectF() private val center = PointF() private val top = PointF() private val bottom = PointF() private var selectedEdge: CustomChatColorEdge = CustomChatColorEdge.TOP private var degrees: Float = 18f private var listener: Listener? = null private val thumbRadius: Float = ViewUtil.dpToPx(THUMB_RADIUS).toFloat() private val thumbBorder: Float = ViewUtil.dpToPx(THUMB_BORDER).toFloat() private val thumbBorderSelected: Float = ViewUtil.dpToPx(THUMB_BORDER_SELECTED).toFloat() private val opaqueThumbRadius: Float = ViewUtil.dpToPx(OPAQUE_THUMB_RADIUS).toFloat() private val opaqueThumbPadding: Float = ViewUtil.dpToPx(OPAGUE_THUMB_PADDING).toFloat() private val opaqueThumbPaddingSelected: Float = ViewUtil.dpToPx(OPAGUE_THUMB_PADDING_SELECTED).toFloat() private val pipeWidth: Float = ViewUtil.dpToPx(PIPE_WIDTH).toFloat() private val pipeBorder: Float = ViewUtil.dpToPx(PIPE_BORDER).toFloat() private val topColorPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.RED } private val bottomColorPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLUE } private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ContextCompat.getColor(context, R.color.signal_background_primary) } private val thumbBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ContextCompat.getColor(context, R.color.signal_inverse_transparent_10) } private val thumbBorderPaintSelected = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ContextCompat.getColor(context, R.color.signal_inverse_transparent_60) } private val pipePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { strokeWidth = pipeWidth - pipeBorder * 2 style = Paint.Style.STROKE color = ContextCompat.getColor(context, R.color.signal_background_primary) } private val pipeBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { strokeWidth = pipeWidth style = Paint.Style.STROKE color = ContextCompat.getColor(context, R.color.signal_inverse_transparent_10) } private val gestureListener = GestureListener() private val gestureDetectorCompat = GestureDetectorCompat(context, gestureListener) fun setTopColor(@ColorInt color: Int) { topColorPaint.color = color invalidate() } fun setBottomColor(@ColorInt color: Int) { bottomColorPaint.color = color invalidate() } fun setSelectedEdge(selectedEdge: CustomChatColorEdge) { if (this.selectedEdge == selectedEdge) { return } this.selectedEdge = selectedEdge invalidate() listener?.onSelectedEdgeChanged(selectedEdge) } fun setDegrees(degrees: Float) { setDegreesInternal(degrees.invertDegrees()) } private fun setDegreesInternal(degrees: Float) { if (this.degrees == degrees) { return } this.degrees = degrees invalidate() listener?.onDegreesChanged(degrees.invertDegrees()) } private fun Float.invertDegrees(): Float = 360f - rotate(90f) fun setListener(listener: Listener) { this.listener = listener } override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_CANCEL || event.action == MotionEvent.ACTION_UP) { listener?.onGestureFinished() } else if (event.action == MotionEvent.ACTION_DOWN) { listener?.onGestureStarted() } return gestureDetectorCompat.onTouchEvent(event) } override fun onDraw(canvas: Canvas) { canvas.getClipBounds(clipRect) rect.set(clipRect) rect.inset(thumbRadius, thumbRadius) center.set(rect.width() / 2f, rect.height() / 2f) val alpha = atan((rect.height() / rect.width())).toDegrees() val beta = (360.0 - alpha * 4) / 4f if (degrees < alpha) { // Right top val a = center.x val b = a * tan(degrees.toRadians()) top.set(rect.width(), center.y - b) bottom.set(0f, center.y + b) } else if (degrees < 90f) { // Top right val phi = 90f - degrees val a = center.y val b = a * tan(phi.toRadians()) top.set(center.x + b, 0f) bottom.set(center.x - b, rect.height()) } else if (degrees < (90f + beta)) { // Top left val phi = degrees - 90f val a = center.y val b = a * tan(phi.toRadians()) top.set(center.x - b, 0f) bottom.set(center.x + b, rect.height()) } else if (degrees < 180f) { // left top val phi = 180f - degrees val a = center.x val b = a * tan(phi.toRadians()) top.set(0f, center.y - b) bottom.set(rect.width(), center.y + b) } else if (degrees < (180f + alpha)) { // left bottom val phi = degrees - 180f val a = center.x val b = a * tan(phi.toRadians()) top.set(0f, center.y + b) bottom.set(rect.width(), center.y - b) } else if (degrees < 270f) { // bottom left val phi = 270f - degrees val a = center.y val b = a * tan(phi.toRadians()) top.set(center.x - b, rect.height()) bottom.set(center.x + b, 0f) } else if (degrees < (270f + beta)) { // bottom right val phi = degrees - 270f val a = center.y val b = a * tan(phi.toRadians()) top.set(center.x + b, rect.height()) bottom.set(center.x - b, 0f) } else { // right bottom val phi = 360f - degrees val a = center.x val b = a * tan(phi.toRadians()) top.set(rect.width(), center.y + b) bottom.set(0f, center.y - b) } val (selected, other) = when (selectedEdge) { CustomChatColorEdge.TOP -> top to bottom CustomChatColorEdge.BOTTOM -> bottom to top } val (selectedPaint, otherPaint) = when (selectedEdge) { CustomChatColorEdge.TOP -> topColorPaint to bottomColorPaint CustomChatColorEdge.BOTTOM -> bottomColorPaint to topColorPaint } canvas.apply { save() translate(rect.top, rect.left) drawLine(selected.x, selected.y, other.x, other.y, pipeBorderPaint) drawLine(selected.x, selected.y, other.x, other.y, pipePaint) drawCircle(other.x, other.y, opaqueThumbRadius + thumbBorder, thumbBorderPaint) drawCircle(other.x, other.y, opaqueThumbRadius, backgroundPaint) drawCircle(other.x, other.y, opaqueThumbRadius - opaqueThumbPadding, otherPaint) drawCircle(selected.x, selected.y, opaqueThumbRadius + thumbBorderSelected, thumbBorderPaintSelected) drawCircle(selected.x, selected.y, opaqueThumbRadius, backgroundPaint) drawCircle(selected.x, selected.y, opaqueThumbRadius - opaqueThumbPaddingSelected, selectedPaint) restore() } top.offset(rect.top, rect.left) bottom.offset(rect.top, rect.left) } private fun Float.toDegrees(): Float = this * (180f / Math.PI.toFloat()) private fun Float.toRadians(): Float = this * (Math.PI.toFloat() / 180f) private fun PointF.distance(other: PointF): Float = abs(sqrt((this.x - other.x).pow(2) + (this.y - other.y).pow(2))) private fun PointF.dotProduct(other: PointF): Float = (this.x * other.x) + (this.y * other.y) private fun PointF.determinate(other: PointF): Float = (this.x * other.y) - (this.y * other.x) private inner class GestureListener : GestureDetector.SimpleOnGestureListener() { var activePointerId: Int = MotionEvent.INVALID_POINTER_ID override fun onDown(e: MotionEvent): Boolean { activePointerId = e.getPointerId(0) val touchPoint = PointF(e.getX(activePointerId), e.getY(activePointerId)) val distanceFromTop = touchPoint.distance(top) if (distanceFromTop <= thumbRadius) { setSelectedEdge(CustomChatColorEdge.TOP) return true } val distanceFromBottom = touchPoint.distance(bottom) if (distanceFromBottom <= thumbRadius) { setSelectedEdge(CustomChatColorEdge.BOTTOM) return true } return false } override fun onScroll( e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float ): Boolean { val a = PointF(e2.getX(activePointerId) - center.x, e2.getY(activePointerId) - center.y) val b = PointF(center.x, 0f) val dot = a.dotProduct(b) val det = a.determinate(b) val offset = if (selectedEdge == CustomChatColorEdge.BOTTOM) 180f else 0f val degrees = (atan2(det, dot).toDegrees() + 360f + offset) % 360f setDegreesInternal(degrees) return true } } private fun Float.rotate(degrees: Float): Float = (this + degrees + 360f) % 360f interface Listener { fun onGestureStarted() fun onGestureFinished() fun onDegreesChanged(degrees: Float) fun onSelectedEdgeChanged(edge: CustomChatColorEdge) } companion object { @Dimension(unit = Dimension.DP) private const val THUMB_RADIUS = 24 @Dimension(unit = Dimension.DP) private const val THUMB_BORDER = 1 @Dimension(unit = Dimension.DP) private const val THUMB_BORDER_SELECTED = 4 @Dimension(unit = Dimension.DP) private const val OPAQUE_THUMB_RADIUS = 20 @Dimension(unit = Dimension.DP) private const val OPAGUE_THUMB_PADDING = 2 @Dimension(unit = Dimension.DP) private const val OPAGUE_THUMB_PADDING_SELECTED = 1 @Dimension(unit = Dimension.DP) private const val PIPE_WIDTH = 6 @Dimension(unit = Dimension.DP) private const val PIPE_BORDER = 1 } }