From f0ef9e97066f5d8cbfb4d20161763b00c137a455 Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Thu, 11 Nov 2021 14:15:01 +0000
Subject: [PATCH 01/17] inverting and splitting the voice message view into
 logic and views - creates a display entry point which will be called
 externally

---
 .../home/room/detail/RoomDetailFragment.kt    |   5 +-
 .../composer/voice/DraggableStateProcessor.kt | 112 ++++++
 .../voice/VoiceMessageRecorderView.kt         | 233 ++++++++++++
 .../composer/voice/VoiceMessageViews.kt       | 358 ++++++++++++++++++
 .../main/res/layout/fragment_room_detail.xml  |   2 +-
 5 files changed, 706 insertions(+), 4 deletions(-)
 create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
 create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
 create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
index f0d7c6157e..230c68cb31 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
@@ -138,7 +138,7 @@ import im.vector.app.features.home.room.detail.composer.TextComposerView
 import im.vector.app.features.home.room.detail.composer.TextComposerViewEvents
 import im.vector.app.features.home.room.detail.composer.TextComposerViewModel
 import im.vector.app.features.home.room.detail.composer.TextComposerViewState
-import im.vector.app.features.home.room.detail.composer.VoiceMessageRecorderView
+import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
 import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet
 import im.vector.app.features.home.room.detail.timeline.TimelineEventController
 import im.vector.app.features.home.room.detail.timeline.action.EventSharedAction
@@ -692,8 +692,7 @@ class RoomDetailFragment @Inject constructor(
     }
 
     private fun setupVoiceMessageView() {
-        views.voiceMessageRecorderView.voiceMessagePlaybackTracker = voiceMessagePlaybackTracker
-
+        voiceMessagePlaybackTracker.track(VoiceMessagePlaybackTracker.RECORDING_ID, views.voiceMessageRecorderView)
         views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback {
             override fun onVoiceRecordingStarted(): Boolean {
                 return if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
new file mode 100644
index 0000000000..4cbb96a703
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2021 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.home.room.detail.composer.voice
+
+import android.content.res.Resources
+import android.view.MotionEvent
+import im.vector.app.R
+import im.vector.app.core.utils.DimensionConverter
+import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.DraggingState
+import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingState
+import kotlin.math.abs
+
+class DraggableStateProcessor(
+        resources: Resources,
+        dimensionConverter: DimensionConverter,
+) {
+
+    private val minimumMove = dimensionConverter.dpToPx(16)
+    private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
+    private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
+    private val rtlXMultiplier = resources.getInteger(R.integer.rtl_x_multiplier)
+
+    private var firstX: Float = 0f
+    private var firstY: Float = 0f
+    private var lastX: Float = 0f
+    private var lastY: Float = 0f
+    private var lastDistanceX: Float = 0f
+    private var lastDistanceY: Float = 0f
+
+    fun reset(event: MotionEvent) {
+        firstX = event.rawX
+        firstY = event.rawY
+        lastX = firstX
+        lastY = firstY
+        lastDistanceX = 0F
+        lastDistanceY = 0F
+    }
+
+    fun process(event: MotionEvent, recordingState: RecordingState): RecordingState {
+        val currentX = event.rawX
+        val currentY = event.rawY
+        val distanceX = abs(firstX - currentX)
+        val distanceY = abs(firstY - currentY)
+        return nextRecordingState(recordingState, currentX, currentY, distanceX, distanceY).also {
+            lastX = currentX
+            lastY = currentY
+            lastDistanceX = distanceX
+            lastDistanceY = distanceY
+        }
+    }
+
+    private fun nextRecordingState(recordingState: RecordingState, currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): RecordingState {
+        return when (recordingState) {
+            RecordingState.Started      -> {
+                // Determine if cancelling or locking for the first move action.
+                if (((currentX < firstX && rtlXMultiplier == 1) || (currentX > firstX && rtlXMultiplier == -1)) && distanceX > distanceY && distanceX > lastDistanceX) {
+                    DraggingState.Cancelling(distanceX)
+                } else if (currentY < firstY && distanceY > distanceX && distanceY > lastDistanceY) {
+                    DraggingState.Locking(distanceY)
+                } else {
+                    recordingState
+                }
+            }
+            is DraggingState.Cancelling -> {
+                // Check if cancelling conditions met, also check if it should be initial state
+                if (distanceX < minimumMove && distanceX < lastDistanceX) {
+                    RecordingState.Started
+                } else if (shouldCancelRecording(distanceX)) {
+                    RecordingState.Cancelled
+                } else {
+                    DraggingState.Cancelling(distanceX)
+                }
+            }
+            is DraggingState.Locking    -> {
+                // Check if locking conditions met, also check if it should be initial state
+                if (distanceY < minimumMove && distanceY < lastDistanceY) {
+                    RecordingState.Started
+                } else if (shouldLockRecording(distanceY)) {
+                    RecordingState.Locked
+                } else {
+                    DraggingState.Locking(distanceY)
+                }
+            }
+            else                        -> {
+                recordingState
+            }
+        }
+    }
+
+    private fun shouldCancelRecording(distanceX: Float): Boolean {
+        return distanceX >= distanceToCancel
+    }
+
+    private fun shouldLockRecording(distanceY: Float): Boolean {
+        return distanceY >= distanceToLock
+    }
+}
+
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
new file mode 100644
index 0000000000..6bd55d4400
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
@@ -0,0 +1,233 @@
+/*
+ * Copyright (c) 2021 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.home.room.detail.composer.voice
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import androidx.constraintlayout.widget.ConstraintLayout
+import im.vector.app.BuildConfig
+import im.vector.app.R
+import im.vector.app.core.extensions.exhaustive
+import im.vector.app.core.hardware.vibrate
+import im.vector.app.core.utils.CountUpTimer
+import im.vector.app.core.utils.DimensionConverter
+import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
+import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
+import org.matrix.android.sdk.api.extensions.orFalse
+import kotlin.math.floor
+
+/**
+ * Encapsulates the voice message recording view and animations.
+ */
+class VoiceMessageRecorderView @JvmOverloads constructor(
+        context: Context,
+        attrs: AttributeSet? = null,
+        defStyleAttr: Int = 0
+) : ConstraintLayout(context, attrs, defStyleAttr), VoiceMessagePlaybackTracker.Listener {
+
+    interface Callback {
+        // Return true if the recording is started
+        fun onVoiceRecordingStarted(): Boolean
+        fun onVoiceRecordingEnded(isCancelled: Boolean)
+        fun onVoiceRecordingPlaybackModeOn()
+        fun onVoicePlaybackButtonClicked()
+    }
+
+    // We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
+    @Suppress("UNNECESSARY_LATEINIT")
+    private lateinit var voiceMessageViews: VoiceMessageViews
+
+    var callback: Callback? = null
+
+    private var recordingState: RecordingState = RecordingState.None
+    private var recordingTicker: CountUpTimer? = null
+
+    init {
+        inflate(this.context, R.layout.view_voice_message_recorder, this)
+        val dimensionConverter = DimensionConverter(this.context.resources)
+        voiceMessageViews = VoiceMessageViews(
+                this.context.resources,
+                ViewVoiceMessageRecorderBinding.bind(this),
+                dimensionConverter
+        )
+        initVoiceRecordingViews()
+        initListeners()
+    }
+
+    override fun onVisibilityChanged(changedView: View, visibility: Int) {
+        super.onVisibilityChanged(changedView, visibility)
+        // onVisibilityChanged is called by constructor on api 21 and 22.
+        if (!this::voiceMessageViews.isInitialized) return
+        val parentChanged = changedView == this
+        voiceMessageViews.renderVisibilityChanged(parentChanged, visibility)
+    }
+
+    fun initVoiceRecordingViews() {
+        recordingState = RecordingState.None
+        stopRecordingTicker()
+        voiceMessageViews.initViews(onVoiceRecordingEnded = {})
+    }
+
+    private fun initListeners() {
+        voiceMessageViews.start(object : VoiceMessageViews.Actions {
+            override fun onRequestRecording() {
+                if (callback?.onVoiceRecordingStarted().orFalse()) {
+                    display(RecordingState.Started)
+                }
+            }
+
+            override fun onRecordingStopped() {
+                if (recordingState != RecordingState.Locked && recordingState != RecordingState.None) {
+                    display(RecordingState.None)
+                }
+            }
+
+            override fun isActive() = recordingState != RecordingState.Cancelled
+
+            override fun updateState(updater: (RecordingState) -> RecordingState) {
+                updater(recordingState).also {
+                    display(it)
+                }
+            }
+
+            override fun sendMessage() {
+                display(RecordingState.None)
+            }
+
+            override fun delete() {
+                // this was previously marked as cancelled true
+                display(RecordingState.None)
+            }
+
+            override fun waveformClicked() {
+                display(RecordingState.Playback)
+            }
+
+            override fun onVoicePlaybackButtonClicked() {
+                callback?.onVoicePlaybackButtonClicked()
+            }
+        })
+    }
+
+    fun display(recordingState: RecordingState) {
+        val previousState = this.recordingState
+        val stateHasChanged = recordingState != this.recordingState
+        this.recordingState = recordingState
+
+        if (stateHasChanged) {
+            when (recordingState) {
+                RecordingState.None      -> {
+                    val isCancelled = previousState == RecordingState.Cancelled
+                    voiceMessageViews.hideRecordingViews(recordingState, isCancelled = isCancelled) { callback?.onVoiceRecordingEnded(it) }
+                    stopRecordingTicker()
+                }
+                RecordingState.Started   -> {
+                    startRecordingTicker()
+                    voiceMessageViews.renderToast(context.getString(R.string.voice_message_release_to_send_toast))
+                    voiceMessageViews.showRecordingViews()
+                }
+                RecordingState.Cancelled -> {
+                    voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback?.onVoiceRecordingEnded(it) }
+                    vibrate(context)
+                }
+                RecordingState.Locked    -> {
+                    voiceMessageViews.renderLocked()
+                    postDelayed({
+                        voiceMessageViews.showRecordingLockedViews(recordingState) { callback?.onVoiceRecordingEnded(it) }
+                    }, 500)
+                }
+                RecordingState.Playback  -> {
+                    stopRecordingTicker()
+                    voiceMessageViews.showPlaybackViews()
+                    callback?.onVoiceRecordingPlaybackModeOn()
+                }
+                is DraggingState         -> when (recordingState) {
+                    is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(recordingState.distanceX)
+                    is DraggingState.Locking    -> voiceMessageViews.renderLocking(recordingState.distanceY)
+                }.exhaustive
+            }
+        }
+    }
+
+    private fun startRecordingTicker() {
+        recordingTicker?.stop()
+        recordingTicker = CountUpTimer().apply {
+            tickListener = object : CountUpTimer.TickListener {
+                override fun onTick(milliseconds: Long) {
+                    onRecordingTick(milliseconds)
+                }
+            }
+            resume()
+        }
+        onRecordingTick(0L)
+    }
+
+    private fun onRecordingTick(milliseconds: Long) {
+        voiceMessageViews.renderRecordingTimer(recordingState, milliseconds / 1_000)
+        val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
+        if (timeDiffToRecordingLimit <= 0) {
+            post {
+                display(RecordingState.Playback)
+            }
+        } else if (timeDiffToRecordingLimit in 10_000..10_999) {
+            post {
+                voiceMessageViews.renderToast(context.getString(R.string.voice_message_n_seconds_warning_toast, floor(timeDiffToRecordingLimit / 1000f).toInt()))
+                vibrate(context)
+            }
+        }
+    }
+
+    private fun stopRecordingTicker() {
+        recordingTicker?.stop()
+        recordingTicker = null
+    }
+
+    /**
+     * Returns true if the voice message is recording or is in playback mode
+     */
+    fun isActive() = recordingState !in listOf(RecordingState.None, RecordingState.Cancelled)
+
+    override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
+        when (state) {
+            is VoiceMessagePlaybackTracker.Listener.State.Recording -> {
+                voiceMessageViews.renderRecordingWaveform(state.amplitudeList.toTypedArray())
+            }
+            is VoiceMessagePlaybackTracker.Listener.State.Playing   -> {
+                voiceMessageViews.renderPlaying(state)
+            }
+            is VoiceMessagePlaybackTracker.Listener.State.Paused,
+            is VoiceMessagePlaybackTracker.Listener.State.Idle      -> {
+                voiceMessageViews.renderIdle()
+            }
+        }
+    }
+
+    sealed interface RecordingState {
+        object None : RecordingState
+        object Started : RecordingState
+        object Cancelled : RecordingState
+        object Locked : RecordingState
+        object Playback : RecordingState
+    }
+
+    sealed interface DraggingState : RecordingState {
+        data class Cancelling(val distanceX: Float) : DraggingState
+        data class Locking(val distanceY: Float) : DraggingState
+    }
+}
+
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
new file mode 100644
index 0000000000..d9f5f9675b
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
@@ -0,0 +1,358 @@
+/*
+ * Copyright (c) 2021 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.home.room.detail.composer.voice
+
+import android.annotation.SuppressLint
+import android.content.res.Resources
+import android.text.format.DateUtils
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.view.isInvisible
+import androidx.core.view.isVisible
+import androidx.core.view.updateLayoutParams
+import im.vector.app.R
+import im.vector.app.core.extensions.setAttributeBackground
+import im.vector.app.core.extensions.setAttributeTintedBackground
+import im.vector.app.core.extensions.setAttributeTintedImageResource
+import im.vector.app.core.utils.DimensionConverter
+import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
+import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingState
+import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
+import org.matrix.android.sdk.api.extensions.orFalse
+
+class VoiceMessageViews(
+        private val resources: Resources,
+        private val views: ViewVoiceMessageRecorderBinding,
+        private val dimensionConverter: DimensionConverter,
+) {
+
+    private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
+    private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
+    private val rtlXMultiplier = resources.getInteger(R.integer.rtl_x_multiplier)
+
+    fun start(actions: Actions) {
+        views.voiceMessageSendButton.setOnClickListener {
+            views.voiceMessageSendButton.isVisible = false
+            actions.sendMessage()
+        }
+
+        views.voiceMessageDeletePlayback.setOnClickListener {
+            views.voiceMessageSendButton.isVisible = false
+            actions.delete()
+        }
+
+        views.voicePlaybackWaveform.setOnClickListener {
+            actions.waveformClicked()
+        }
+
+        views.voicePlaybackControlButton.setOnClickListener {
+            actions.onVoicePlaybackButtonClicked()
+        }
+        observeMicButton(actions)
+    }
+
+    @SuppressLint("ClickableViewAccessibility")
+    private fun observeMicButton(actions: Actions) {
+        val positions = DraggableStateProcessor(resources, dimensionConverter)
+        views.voiceMessageMicButton.setOnTouchListener { _, event ->
+            when (event.action) {
+                MotionEvent.ACTION_DOWN -> {
+                    positions.reset(event)
+                    actions.onRequestRecording()
+                    true
+                }
+                MotionEvent.ACTION_UP   -> {
+                    actions.onRecordingStopped()
+                    true
+                }
+                MotionEvent.ACTION_MOVE -> {
+                    if (actions.isActive()) {
+                        actions.updateState { currentState -> positions.process(event, currentState) }
+                        true
+                    } else {
+                        false
+                    }
+                }
+                else                    -> false
+            }
+        }
+    }
+
+    fun renderStarted(distanceX: Float) {
+        val translationAmount = distanceX.coerceAtMost(distanceToCancel)
+        views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
+        views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
+    }
+
+    fun renderLocked() {
+        views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_locked)
+    }
+
+    fun renderLocking(distanceY: Float) {
+        views.voiceMessageLockImage.setAttributeTintedImageResource(R.drawable.ic_voice_message_locked, R.attr.colorPrimary)
+        val translationAmount = -distanceY.coerceIn(0F, distanceToLock)
+        views.voiceMessageMicButton.translationY = translationAmount
+        views.voiceMessageLockArrow.translationY = translationAmount
+        views.voiceMessageLockArrow.alpha = 1 - (-translationAmount / distanceToLock)
+        // Reset X translations
+        views.voiceMessageMicButton.translationX = 0F
+        views.voiceMessageSlideToCancel.translationX = 0F
+    }
+
+    fun renderCancelling(distanceX: Float) {
+        val translationAmount = distanceX.coerceAtMost(distanceToCancel)
+        views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
+        views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
+        val reducedAlpha = (1 - translationAmount / distanceToCancel / 1.5).toFloat()
+        views.voiceMessageSlideToCancel.alpha = reducedAlpha
+        views.voiceMessageTimerIndicator.alpha = reducedAlpha
+        views.voiceMessageTimer.alpha = reducedAlpha
+        views.voiceMessageLockBackground.isVisible = false
+        views.voiceMessageLockImage.isVisible = false
+        views.voiceMessageLockArrow.isVisible = false
+        // Reset Y translations
+        views.voiceMessageMicButton.translationY = 0F
+        views.voiceMessageLockArrow.translationY = 0F
+    }
+
+    fun showRecordingViews() {
+        views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic_recording)
+        views.voiceMessageMicButton.setAttributeTintedBackground(R.drawable.circle_with_halo, R.attr.colorPrimary)
+        views.voiceMessageMicButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+            setMargins(0, 0, 0, 0)
+        }
+        views.voiceMessageMicButton.animate().scaleX(1.5f).scaleY(1.5f).setDuration(300).start()
+
+        views.voiceMessageLockBackground.isVisible = true
+        views.voiceMessageLockBackground.animate().setDuration(300).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
+        views.voiceMessageLockImage.isVisible = true
+        views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_unlocked)
+        views.voiceMessageLockImage.animate().setDuration(500).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
+        views.voiceMessageLockArrow.isVisible = true
+        views.voiceMessageLockArrow.alpha = 1f
+        views.voiceMessageSlideToCancel.isVisible = true
+        views.voiceMessageTimerIndicator.isVisible = true
+        views.voiceMessageTimer.isVisible = true
+        views.voiceMessageSlideToCancel.alpha = 1f
+        views.voiceMessageTimerIndicator.alpha = 1f
+        views.voiceMessageTimer.alpha = 1f
+        views.voiceMessageSendButton.isVisible = false
+    }
+
+    fun hideRecordingViews(recordingState: RecordingState, isCancelled: Boolean?, onVoiceRecordingEnded: (Boolean) -> Unit) {
+        // We need to animate the lock image first
+        if (recordingState != RecordingState.Locked || isCancelled.orFalse()) {
+            views.voiceMessageLockImage.isVisible = false
+            views.voiceMessageLockImage.animate().translationY(0f).start()
+            views.voiceMessageLockBackground.isVisible = false
+            views.voiceMessageLockBackground.animate().translationY(0f).start()
+        } else {
+            animateLockImageWithBackground()
+        }
+        views.voiceMessageLockArrow.isVisible = false
+        views.voiceMessageLockArrow.animate().translationY(0f).start()
+        views.voiceMessageSlideToCancel.isVisible = false
+        views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
+        views.voiceMessagePlaybackLayout.isVisible = false
+
+        if (recordingState != RecordingState.Locked) {
+            views.voiceMessageMicButton
+                    .animate()
+                    .scaleX(1f)
+                    .scaleY(1f)
+                    .translationX(0f)
+                    .translationY(0f)
+                    .setDuration(150)
+                    .withEndAction {
+                        views.voiceMessageTimerIndicator.isVisible = false
+                        views.voiceMessageTimer.isVisible = false
+                        resetMicButtonUi()
+                        isCancelled?.let {
+                            onVoiceRecordingEnded(it)
+                        }
+                    }
+                    .start()
+        } else {
+            views.voiceMessageTimerIndicator.isVisible = false
+            views.voiceMessageTimer.isVisible = false
+            views.voiceMessageMicButton.apply {
+                scaleX = 1f
+                scaleY = 1f
+                translationX = 0f
+                translationY = 0f
+            }
+            isCancelled?.let {
+                onVoiceRecordingEnded(it)
+            }
+        }
+
+        // Hide toasts if user cancelled recording before the timeout of the toast.
+        if (recordingState == RecordingState.Cancelled || recordingState == RecordingState.None) {
+            hideToast()
+        }
+    }
+
+    fun animateLockImageWithBackground() {
+        views.voiceMessageLockBackground.updateLayoutParams {
+            height = dimensionConverter.dpToPx(78)
+        }
+        views.voiceMessageLockBackground.apply {
+            animate()
+                    .scaleX(0f)
+                    .scaleY(0f)
+                    .setDuration(400L)
+                    .withEndAction {
+                        updateLayoutParams {
+                            height = dimensionConverter.dpToPx(180)
+                        }
+                        isVisible = false
+                        scaleX = 1f
+                        scaleY = 1f
+                        animate().translationY(0f).start()
+                    }
+                    .start()
+        }
+
+        // Lock image animation
+        views.voiceMessageMicButton.isInvisible = true
+        views.voiceMessageLockImage.apply {
+            isVisible = true
+            animate()
+                    .scaleX(0f)
+                    .scaleY(0f)
+                    .setDuration(400L)
+                    .withEndAction {
+                        isVisible = false
+                        scaleX = 1f
+                        scaleY = 1f
+                        translationY = 0f
+                        resetMicButtonUi()
+                    }
+                    .start()
+        }
+    }
+
+    fun resetMicButtonUi() {
+        views.voiceMessageMicButton.isVisible = true
+        views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic)
+        views.voiceMessageMicButton.setAttributeBackground(android.R.attr.selectableItemBackgroundBorderless)
+        views.voiceMessageMicButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+            if (rtlXMultiplier == -1) {
+                // RTL
+                setMargins(dimensionConverter.dpToPx(12), 0, 0, dimensionConverter.dpToPx(12))
+            } else {
+                setMargins(0, 0, dimensionConverter.dpToPx(12), dimensionConverter.dpToPx(12))
+            }
+        }
+    }
+
+    fun hideToast() {
+        views.voiceMessageToast.isVisible = false
+    }
+
+    fun showRecordingLockedViews(recordingState: RecordingState, onVoiceRecordingEnded: (Boolean) -> Unit) {
+        hideRecordingViews(recordingState, null, onVoiceRecordingEnded)
+        views.voiceMessagePlaybackLayout.isVisible = true
+        views.voiceMessagePlaybackTimerIndicator.isVisible = true
+        views.voicePlaybackControlButton.isVisible = false
+        views.voiceMessageSendButton.isVisible = true
+        views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+        renderToast(resources.getString(R.string.voice_message_tap_to_stop_toast))
+    }
+
+    fun showPlaybackViews() {
+        views.voiceMessagePlaybackTimerIndicator.isVisible = false
+        views.voicePlaybackControlButton.isVisible = true
+        views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+    }
+
+    fun initViews(onVoiceRecordingEnded: (Boolean) -> Unit) {
+        hideRecordingViews(RecordingState.None, null, onVoiceRecordingEnded)
+        views.voiceMessageMicButton.isVisible = true
+        views.voiceMessageSendButton.isVisible = false
+        views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }
+    }
+
+    fun renderPlaying(state: VoiceMessagePlaybackTracker.Listener.State.Playing) {
+        views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
+        views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_pause_voice_message)
+        val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong())
+        views.voicePlaybackTime.text = formattedTimerText
+    }
+
+    fun renderIdle() {
+        views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
+        views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_play_voice_message)
+    }
+
+    fun renderToast(message: String) {
+        views.voiceMessageToast.removeCallbacks(hideToastRunnable)
+        views.voiceMessageToast.text = message
+        views.voiceMessageToast.isVisible = true
+        views.voiceMessageToast.postDelayed(hideToastRunnable, 2_000)
+    }
+
+    private val hideToastRunnable = Runnable {
+        views.voiceMessageToast.isVisible = false
+    }
+
+    fun renderRecordingTimer(recordingState: RecordingState, recordingTimeMillis: Long) {
+        val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis)
+        if (recordingState == RecordingState.Locked) {
+            views.voicePlaybackTime.apply {
+                post {
+                    text = formattedTimerText
+                }
+            }
+        } else {
+            views.voiceMessageTimer.post {
+                views.voiceMessageTimer.text = formattedTimerText
+            }
+        }
+    }
+
+    fun renderRecordingWaveform(amplitudeList: Array<Int>) {
+        views.voicePlaybackWaveform.post {
+            views.voicePlaybackWaveform.apply {
+                amplitudeList.iterator().forEach {
+                    update(it)
+                }
+            }
+        }
+    }
+
+    fun renderVisibilityChanged(parentChanged: Boolean, visibility: Int) {
+        if (parentChanged && visibility == ConstraintLayout.VISIBLE) {
+            views.voiceMessageMicButton.contentDescription = resources.getString(R.string.a11y_start_voice_message)
+        } else {
+            views.voiceMessageMicButton.contentDescription = ""
+        }
+    }
+
+    interface Actions {
+        fun onRequestRecording()
+        fun onRecordingStopped()
+        fun isActive(): Boolean
+        fun updateState(updater: (RecordingState) -> RecordingState)
+        fun sendMessage()
+        fun delete()
+        fun waveformClicked()
+        fun onVoicePlaybackButtonClicked()
+    }
+}
diff --git a/vector/src/main/res/layout/fragment_room_detail.xml b/vector/src/main/res/layout/fragment_room_detail.xml
index c0ac3170e5..1b73e0e91d 100644
--- a/vector/src/main/res/layout/fragment_room_detail.xml
+++ b/vector/src/main/res/layout/fragment_room_detail.xml
@@ -212,7 +212,7 @@
         app:layout_constraintStart_toStartOf="parent"
         tools:visibility="visible" />
 
-    <im.vector.app.features.home.room.detail.composer.VoiceMessageRecorderView
+    <im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
         android:id="@+id/voiceMessageRecorderView"
         android:layout_width="0dp"
         android:layout_height="wrap_content"

From f2690552a242c0bd82243fb19a636c5446aeed58 Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Thu, 11 Nov 2021 14:50:35 +0000
Subject: [PATCH 02/17] lifting voice display logic out of the view and to the
 layer above

---
 .../home/room/detail/RoomDetailFragment.kt    |  36 ++++-
 .../composer/voice/DraggableStateProcessor.kt |  16 +--
 .../voice/VoiceMessageRecorderView.kt         | 130 +++++++++---------
 .../composer/voice/VoiceMessageViews.kt       |  20 +--
 4 files changed, 114 insertions(+), 88 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
index 230c68cb31..e1dab55979 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
@@ -139,6 +139,7 @@ import im.vector.app.features.home.room.detail.composer.TextComposerViewEvents
 import im.vector.app.features.home.room.detail.composer.TextComposerViewModel
 import im.vector.app.features.home.room.detail.composer.TextComposerViewState
 import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
+import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState
 import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet
 import im.vector.app.features.home.room.detail.timeline.TimelineEventController
 import im.vector.app.features.home.room.detail.timeline.action.EventSharedAction
@@ -694,15 +695,15 @@ class RoomDetailFragment @Inject constructor(
     private fun setupVoiceMessageView() {
         voiceMessagePlaybackTracker.track(VoiceMessagePlaybackTracker.RECORDING_ID, views.voiceMessageRecorderView)
         views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback {
-            override fun onVoiceRecordingStarted(): Boolean {
-                return if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
+
+            private var currentUiState: RecordingUiState = RecordingUiState.None
+
+            override fun onVoiceRecordingStarted() {
+                if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
                     roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
                     textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(true))
                     vibrate(requireContext())
-                    true
-                } else {
-                    // Permission dialog is displayed
-                    false
+                    views.voiceMessageRecorderView.display(RecordingUiState.Started)
                 }
             }
 
@@ -718,6 +719,29 @@ class RoomDetailFragment @Inject constructor(
             override fun onVoicePlaybackButtonClicked() {
                 roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseRecordingPlayback)
             }
+
+            override fun onRecordingStopped() {
+                if (currentUiState != RecordingUiState.Locked && currentUiState != RecordingUiState.None) {
+                    views.voiceMessageRecorderView.display(RecordingUiState.None)
+                }
+            }
+
+            override fun onUiStateChanged(state: RecordingUiState) {
+                currentUiState = state
+                views.voiceMessageRecorderView.display(state)
+            }
+
+            override fun sendVoiceMessage() {
+                views.voiceMessageRecorderView.display(RecordingUiState.None)
+            }
+
+            override fun deleteVoiceMessage() {
+                views.voiceMessageRecorderView.display(RecordingUiState.None)
+            }
+
+            override fun onRecordingLimitReached() {
+                views.voiceMessageRecorderView.display(RecordingUiState.Playback)
+            }
         }
     }
 
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
index 4cbb96a703..5825e60ecf 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
@@ -21,7 +21,7 @@ import android.view.MotionEvent
 import im.vector.app.R
 import im.vector.app.core.utils.DimensionConverter
 import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.DraggingState
-import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingState
+import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState
 import kotlin.math.abs
 
 class DraggableStateProcessor(
@@ -50,7 +50,7 @@ class DraggableStateProcessor(
         lastDistanceY = 0F
     }
 
-    fun process(event: MotionEvent, recordingState: RecordingState): RecordingState {
+    fun process(event: MotionEvent, recordingState: RecordingUiState): RecordingUiState {
         val currentX = event.rawX
         val currentY = event.rawY
         val distanceX = abs(firstX - currentX)
@@ -63,9 +63,9 @@ class DraggableStateProcessor(
         }
     }
 
-    private fun nextRecordingState(recordingState: RecordingState, currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): RecordingState {
+    private fun nextRecordingState(recordingState: RecordingUiState, currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): RecordingUiState {
         return when (recordingState) {
-            RecordingState.Started      -> {
+            RecordingUiState.Started    -> {
                 // Determine if cancelling or locking for the first move action.
                 if (((currentX < firstX && rtlXMultiplier == 1) || (currentX > firstX && rtlXMultiplier == -1)) && distanceX > distanceY && distanceX > lastDistanceX) {
                     DraggingState.Cancelling(distanceX)
@@ -78,9 +78,9 @@ class DraggableStateProcessor(
             is DraggingState.Cancelling -> {
                 // Check if cancelling conditions met, also check if it should be initial state
                 if (distanceX < minimumMove && distanceX < lastDistanceX) {
-                    RecordingState.Started
+                    RecordingUiState.Started
                 } else if (shouldCancelRecording(distanceX)) {
-                    RecordingState.Cancelled
+                    RecordingUiState.Cancelled
                 } else {
                     DraggingState.Cancelling(distanceX)
                 }
@@ -88,9 +88,9 @@ class DraggableStateProcessor(
             is DraggingState.Locking    -> {
                 // Check if locking conditions met, also check if it should be initial state
                 if (distanceY < minimumMove && distanceY < lastDistanceY) {
-                    RecordingState.Started
+                    RecordingUiState.Started
                 } else if (shouldLockRecording(distanceY)) {
-                    RecordingState.Locked
+                    RecordingUiState.Locked
                 } else {
                     DraggingState.Locking(distanceY)
                 }
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
index 6bd55d4400..79898dad32 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
@@ -28,7 +28,6 @@ import im.vector.app.core.utils.CountUpTimer
 import im.vector.app.core.utils.DimensionConverter
 import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
 import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
-import org.matrix.android.sdk.api.extensions.orFalse
 import kotlin.math.floor
 
 /**
@@ -41,11 +40,15 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
 ) : ConstraintLayout(context, attrs, defStyleAttr), VoiceMessagePlaybackTracker.Listener {
 
     interface Callback {
-        // Return true if the recording is started
-        fun onVoiceRecordingStarted(): Boolean
+        fun onVoiceRecordingStarted()
         fun onVoiceRecordingEnded(isCancelled: Boolean)
         fun onVoiceRecordingPlaybackModeOn()
         fun onVoicePlaybackButtonClicked()
+        fun onRecordingStopped()
+        fun onUiStateChanged(state: RecordingUiState)
+        fun sendVoiceMessage()
+        fun deleteVoiceMessage()
+        fun onRecordingLimitReached()
     }
 
     // We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
@@ -54,7 +57,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
 
     var callback: Callback? = null
 
-    private var recordingState: RecordingState = RecordingState.None
+    private var currentUiState: RecordingUiState = RecordingUiState.None
     private var recordingTicker: CountUpTimer? = null
 
     init {
@@ -78,7 +81,6 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
     }
 
     fun initVoiceRecordingViews() {
-        recordingState = RecordingState.None
         stopRecordingTicker()
         voiceMessageViews.initViews(onVoiceRecordingEnded = {})
     }
@@ -86,36 +88,39 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
     private fun initListeners() {
         voiceMessageViews.start(object : VoiceMessageViews.Actions {
             override fun onRequestRecording() {
-                if (callback?.onVoiceRecordingStarted().orFalse()) {
-                    display(RecordingState.Started)
-                }
+                callback?.onVoiceRecordingStarted()
             }
 
             override fun onRecordingStopped() {
-                if (recordingState != RecordingState.Locked && recordingState != RecordingState.None) {
-                    display(RecordingState.None)
-                }
+                callback?.onRecordingStopped()
             }
 
-            override fun isActive() = recordingState != RecordingState.Cancelled
+            override fun isActive() = currentUiState != RecordingUiState.Cancelled
 
-            override fun updateState(updater: (RecordingState) -> RecordingState) {
-                updater(recordingState).also {
-                    display(it)
+            override fun updateState(updater: (RecordingUiState) -> RecordingUiState) {
+                updater(currentUiState).also { newState ->
+                    when (newState) {
+                        is DraggingState -> display(newState)
+                        else             -> {
+                            if (newState != currentUiState) {
+                                callback?.onUiStateChanged(newState)
+                            }
+                        }
+                    }
                 }
             }
 
             override fun sendMessage() {
-                display(RecordingState.None)
+                callback?.sendVoiceMessage()
             }
 
             override fun delete() {
                 // this was previously marked as cancelled true
-                display(RecordingState.None)
+                callback?.deleteVoiceMessage()
             }
 
             override fun waveformClicked() {
-                display(RecordingState.Playback)
+                display(RecordingUiState.Playback)
             }
 
             override fun onVoicePlaybackButtonClicked() {
@@ -124,43 +129,41 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
         })
     }
 
-    fun display(recordingState: RecordingState) {
-        val previousState = this.recordingState
-        val stateHasChanged = recordingState != this.recordingState
-        this.recordingState = recordingState
+    fun display(recordingState: RecordingUiState) {
+        if (recordingState == this.currentUiState) return
 
-        if (stateHasChanged) {
-            when (recordingState) {
-                RecordingState.None      -> {
-                    val isCancelled = previousState == RecordingState.Cancelled
-                    voiceMessageViews.hideRecordingViews(recordingState, isCancelled = isCancelled) { callback?.onVoiceRecordingEnded(it) }
-                    stopRecordingTicker()
-                }
-                RecordingState.Started   -> {
-                    startRecordingTicker()
-                    voiceMessageViews.renderToast(context.getString(R.string.voice_message_release_to_send_toast))
-                    voiceMessageViews.showRecordingViews()
-                }
-                RecordingState.Cancelled -> {
-                    voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback?.onVoiceRecordingEnded(it) }
-                    vibrate(context)
-                }
-                RecordingState.Locked    -> {
-                    voiceMessageViews.renderLocked()
-                    postDelayed({
-                        voiceMessageViews.showRecordingLockedViews(recordingState) { callback?.onVoiceRecordingEnded(it) }
-                    }, 500)
-                }
-                RecordingState.Playback  -> {
-                    stopRecordingTicker()
-                    voiceMessageViews.showPlaybackViews()
-                    callback?.onVoiceRecordingPlaybackModeOn()
-                }
-                is DraggingState         -> when (recordingState) {
-                    is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(recordingState.distanceX)
-                    is DraggingState.Locking    -> voiceMessageViews.renderLocking(recordingState.distanceY)
-                }.exhaustive
+        val previousState = this.currentUiState
+        this.currentUiState = recordingState
+        when (recordingState) {
+            RecordingUiState.None      -> {
+                val isCancelled = previousState == RecordingUiState.Cancelled
+                voiceMessageViews.hideRecordingViews(recordingState, isCancelled = isCancelled) { callback?.onVoiceRecordingEnded(it) }
+                stopRecordingTicker()
             }
+            RecordingUiState.Started   -> {
+                startRecordingTicker()
+                voiceMessageViews.renderToast(context.getString(R.string.voice_message_release_to_send_toast))
+                voiceMessageViews.showRecordingViews()
+            }
+            RecordingUiState.Cancelled -> {
+                voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback?.onVoiceRecordingEnded(it) }
+                vibrate(context)
+            }
+            RecordingUiState.Locked    -> {
+                voiceMessageViews.renderLocked()
+                postDelayed({
+                    voiceMessageViews.showRecordingLockedViews(recordingState) { callback?.onVoiceRecordingEnded(it) }
+                }, 500)
+            }
+            RecordingUiState.Playback  -> {
+                stopRecordingTicker()
+                voiceMessageViews.showPlaybackViews()
+                callback?.onVoiceRecordingPlaybackModeOn()
+            }
+            is DraggingState           -> when (recordingState) {
+                is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(recordingState.distanceX)
+                is DraggingState.Locking    -> voiceMessageViews.renderLocking(recordingState.distanceY)
+            }.exhaustive
         }
     }
 
@@ -178,11 +181,11 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
     }
 
     private fun onRecordingTick(milliseconds: Long) {
-        voiceMessageViews.renderRecordingTimer(recordingState, milliseconds / 1_000)
+        voiceMessageViews.renderRecordingTimer(currentUiState, milliseconds / 1_000)
         val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
         if (timeDiffToRecordingLimit <= 0) {
             post {
-                display(RecordingState.Playback)
+                callback?.onRecordingLimitReached()
             }
         } else if (timeDiffToRecordingLimit in 10_000..10_999) {
             post {
@@ -200,7 +203,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
     /**
      * Returns true if the voice message is recording or is in playback mode
      */
-    fun isActive() = recordingState !in listOf(RecordingState.None, RecordingState.Cancelled)
+    fun isActive() = currentUiState !in listOf(RecordingUiState.None, RecordingUiState.Cancelled)
 
     override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
         when (state) {
@@ -217,17 +220,16 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
         }
     }
 
-    sealed interface RecordingState {
-        object None : RecordingState
-        object Started : RecordingState
-        object Cancelled : RecordingState
-        object Locked : RecordingState
-        object Playback : RecordingState
+    sealed interface RecordingUiState {
+        object None : RecordingUiState
+        object Started : RecordingUiState
+        object Cancelled : RecordingUiState
+        object Locked : RecordingUiState
+        object Playback : RecordingUiState
     }
 
-    sealed interface DraggingState : RecordingState {
+    sealed interface DraggingState : RecordingUiState {
         data class Cancelling(val distanceX: Float) : DraggingState
         data class Locking(val distanceY: Float) : DraggingState
     }
 }
-
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
index d9f5f9675b..ce4ec4b519 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
@@ -32,7 +32,7 @@ import im.vector.app.core.extensions.setAttributeTintedBackground
 import im.vector.app.core.extensions.setAttributeTintedImageResource
 import im.vector.app.core.utils.DimensionConverter
 import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
-import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingState
+import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState
 import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
 import org.matrix.android.sdk.api.extensions.orFalse
 
@@ -155,9 +155,9 @@ class VoiceMessageViews(
         views.voiceMessageSendButton.isVisible = false
     }
 
-    fun hideRecordingViews(recordingState: RecordingState, isCancelled: Boolean?, onVoiceRecordingEnded: (Boolean) -> Unit) {
+    fun hideRecordingViews(recordingState: RecordingUiState, isCancelled: Boolean?, onVoiceRecordingEnded: (Boolean) -> Unit) {
         // We need to animate the lock image first
-        if (recordingState != RecordingState.Locked || isCancelled.orFalse()) {
+        if (recordingState != RecordingUiState.Locked || isCancelled.orFalse()) {
             views.voiceMessageLockImage.isVisible = false
             views.voiceMessageLockImage.animate().translationY(0f).start()
             views.voiceMessageLockBackground.isVisible = false
@@ -171,7 +171,7 @@ class VoiceMessageViews(
         views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
         views.voiceMessagePlaybackLayout.isVisible = false
 
-        if (recordingState != RecordingState.Locked) {
+        if (recordingState != RecordingUiState.Locked) {
             views.voiceMessageMicButton
                     .animate()
                     .scaleX(1f)
@@ -203,7 +203,7 @@ class VoiceMessageViews(
         }
 
         // Hide toasts if user cancelled recording before the timeout of the toast.
-        if (recordingState == RecordingState.Cancelled || recordingState == RecordingState.None) {
+        if (recordingState == RecordingUiState.Cancelled || recordingState == RecordingUiState.None) {
             hideToast()
         }
     }
@@ -266,7 +266,7 @@ class VoiceMessageViews(
         views.voiceMessageToast.isVisible = false
     }
 
-    fun showRecordingLockedViews(recordingState: RecordingState, onVoiceRecordingEnded: (Boolean) -> Unit) {
+    fun showRecordingLockedViews(recordingState: RecordingUiState, onVoiceRecordingEnded: (Boolean) -> Unit) {
         hideRecordingViews(recordingState, null, onVoiceRecordingEnded)
         views.voiceMessagePlaybackLayout.isVisible = true
         views.voiceMessagePlaybackTimerIndicator.isVisible = true
@@ -283,7 +283,7 @@ class VoiceMessageViews(
     }
 
     fun initViews(onVoiceRecordingEnded: (Boolean) -> Unit) {
-        hideRecordingViews(RecordingState.None, null, onVoiceRecordingEnded)
+        hideRecordingViews(RecordingUiState.None, null, onVoiceRecordingEnded)
         views.voiceMessageMicButton.isVisible = true
         views.voiceMessageSendButton.isVisible = false
         views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }
@@ -312,9 +312,9 @@ class VoiceMessageViews(
         views.voiceMessageToast.isVisible = false
     }
 
-    fun renderRecordingTimer(recordingState: RecordingState, recordingTimeMillis: Long) {
+    fun renderRecordingTimer(recordingState: RecordingUiState, recordingTimeMillis: Long) {
         val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis)
-        if (recordingState == RecordingState.Locked) {
+        if (recordingState == RecordingUiState.Locked) {
             views.voicePlaybackTime.apply {
                 post {
                     text = formattedTimerText
@@ -349,7 +349,7 @@ class VoiceMessageViews(
         fun onRequestRecording()
         fun onRecordingStopped()
         fun isActive(): Boolean
-        fun updateState(updater: (RecordingState) -> RecordingState)
+        fun updateState(updater: (RecordingUiState) -> RecordingUiState)
         fun sendMessage()
         fun delete()
         fun waveformClicked()

From 40d762c37d384f670f80be4e26bc4b4bbfcc40c4 Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Thu, 11 Nov 2021 15:50:00 +0000
Subject: [PATCH 03/17] lifting current recording state out of the view

---
 .../home/room/detail/RoomDetailFragment.kt    |  36 +-
 .../composer/VoiceMessageRecorderView.kt      | 551 ------------------
 .../voice/VoiceMessageRecorderView.kt         |  58 +-
 .../composer/voice/VoiceMessageViews.kt       |   4 +-
 4 files changed, 49 insertions(+), 600 deletions(-)
 delete mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageRecorderView.kt

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
index e1dab55979..ba45348cab 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
@@ -506,7 +506,7 @@ class RoomDetailFragment @Inject constructor(
 
     private fun onCannotRecord() {
         // Update the UI, cancel the animation
-        views.voiceMessageRecorderView.initVoiceRecordingViews()
+        views.voiceMessageRecorderView.display(RecordingUiState.None)
     }
 
     private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) {
@@ -698,12 +698,16 @@ class RoomDetailFragment @Inject constructor(
 
             private var currentUiState: RecordingUiState = RecordingUiState.None
 
+            init {
+                display(currentUiState)
+            }
+
             override fun onVoiceRecordingStarted() {
                 if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
                     roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
                     textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(true))
                     vibrate(requireContext())
-                    views.voiceMessageRecorderView.display(RecordingUiState.Started)
+                    display(RecordingUiState.Started)
                 }
             }
 
@@ -721,27 +725,39 @@ class RoomDetailFragment @Inject constructor(
             }
 
             override fun onRecordingStopped() {
-                if (currentUiState != RecordingUiState.Locked && currentUiState != RecordingUiState.None) {
-                    views.voiceMessageRecorderView.display(RecordingUiState.None)
+                if (currentUiState != RecordingUiState.Locked) {
+                    display(RecordingUiState.None)
                 }
             }
 
             override fun onUiStateChanged(state: RecordingUiState) {
-                currentUiState = state
-                views.voiceMessageRecorderView.display(state)
+                display(state)
             }
 
             override fun sendVoiceMessage() {
-                views.voiceMessageRecorderView.display(RecordingUiState.None)
+                display(RecordingUiState.None)
             }
 
             override fun deleteVoiceMessage() {
-                views.voiceMessageRecorderView.display(RecordingUiState.None)
+                display(RecordingUiState.Cancelled)
             }
 
             override fun onRecordingLimitReached() {
-                views.voiceMessageRecorderView.display(RecordingUiState.Playback)
+                display(RecordingUiState.Playback)
             }
+
+            override fun recordingWaveformClicked() {
+                display(RecordingUiState.Playback)
+            }
+
+            private fun display(state: RecordingUiState) {
+                if (currentUiState != state) {
+                    views.voiceMessageRecorderView.display(state)
+                }
+                currentUiState = state
+            }
+
+            override fun currentState() = currentUiState
         }
     }
 
@@ -1132,7 +1148,7 @@ class RoomDetailFragment @Inject constructor(
 
         // We should improve the UX to support going into playback mode when paused and delete the media when the view is destroyed.
         roomDetailViewModel.handle(RoomDetailAction.EndAllVoiceActions(deleteRecord = false))
-        views.voiceMessageRecorderView.initVoiceRecordingViews()
+        views.voiceMessageRecorderView.display(RecordingUiState.None)
     }
 
     private val attachmentFileActivityResultLauncher = registerStartForActivityResult {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageRecorderView.kt
deleted file mode 100644
index f7b8cead37..0000000000
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageRecorderView.kt
+++ /dev/null
@@ -1,551 +0,0 @@
-/*
- * Copyright (c) 2021 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package im.vector.app.features.home.room.detail.composer
-
-import android.content.Context
-import android.text.format.DateUtils
-import android.util.AttributeSet
-import android.view.MotionEvent
-import android.view.View
-import androidx.constraintlayout.widget.ConstraintLayout
-import androidx.core.view.isInvisible
-import androidx.core.view.isVisible
-import androidx.core.view.updateLayoutParams
-import im.vector.app.BuildConfig
-import im.vector.app.R
-import im.vector.app.core.extensions.setAttributeBackground
-import im.vector.app.core.extensions.setAttributeTintedBackground
-import im.vector.app.core.extensions.setAttributeTintedImageResource
-import im.vector.app.core.hardware.vibrate
-import im.vector.app.core.utils.CountUpTimer
-import im.vector.app.core.utils.DimensionConverter
-import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
-import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
-import org.matrix.android.sdk.api.extensions.orFalse
-import timber.log.Timber
-import kotlin.math.abs
-import kotlin.math.floor
-
-/**
- * Encapsulates the voice message recording view and animations.
- */
-class VoiceMessageRecorderView : ConstraintLayout, VoiceMessagePlaybackTracker.Listener {
-
-    interface Callback {
-        // Return true if the recording is started
-        fun onVoiceRecordingStarted(): Boolean
-        fun onVoiceRecordingEnded(isCancelled: Boolean)
-        fun onVoiceRecordingPlaybackModeOn()
-        fun onVoicePlaybackButtonClicked()
-    }
-
-    private lateinit var views: ViewVoiceMessageRecorderBinding
-
-    var callback: Callback? = null
-    var voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker? = null
-        set(value) {
-            field = value
-            value?.track(VoiceMessagePlaybackTracker.RECORDING_ID, this)
-        }
-
-    private var recordingState: RecordingState = RecordingState.NONE
-
-    private var firstX: Float = 0f
-    private var firstY: Float = 0f
-    private var lastX: Float = 0f
-    private var lastY: Float = 0f
-    private var lastDistanceX: Float = 0f
-    private var lastDistanceY: Float = 0f
-
-    private var recordingTicker: CountUpTimer? = null
-
-    private val dimensionConverter = DimensionConverter(context.resources)
-    private val minimumMove = dimensionConverter.dpToPx(16)
-    private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
-    private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
-    private val rtlXMultiplier = context.resources.getInteger(R.integer.rtl_x_multiplier)
-
-    // Don't convert to primary constructor.
-    // We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
-    @JvmOverloads constructor(
-            context: Context,
-            attrs: AttributeSet? = null,
-            defStyleAttr: Int = 0
-    ) : super(context, attrs, defStyleAttr) {
-        initialize()
-    }
-
-    fun initialize() {
-        inflate(context, R.layout.view_voice_message_recorder, this)
-        views = ViewVoiceMessageRecorderBinding.bind(this)
-
-        initVoiceRecordingViews()
-        initListeners()
-    }
-
-    override fun onVisibilityChanged(changedView: View, visibility: Int) {
-        super.onVisibilityChanged(changedView, visibility)
-        // onVisibilityChanged is called by constructor on api 21 and 22.
-        if (!this::views.isInitialized) return
-
-        if (changedView == this && visibility == VISIBLE) {
-            views.voiceMessageMicButton.contentDescription = context.getString(R.string.a11y_start_voice_message)
-        } else {
-            views.voiceMessageMicButton.contentDescription = ""
-        }
-    }
-
-    fun initVoiceRecordingViews() {
-        recordingState = RecordingState.NONE
-
-        hideRecordingViews(null)
-        stopRecordingTicker()
-
-        views.voiceMessageMicButton.isVisible = true
-        views.voiceMessageSendButton.isVisible = false
-
-        views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }
-    }
-
-    private fun initListeners() {
-        views.voiceMessageSendButton.setOnClickListener {
-            stopRecordingTicker()
-            hideRecordingViews(isCancelled = false)
-            views.voiceMessageSendButton.isVisible = false
-            recordingState = RecordingState.NONE
-        }
-
-        views.voiceMessageDeletePlayback.setOnClickListener {
-            stopRecordingTicker()
-            hideRecordingViews(isCancelled = true)
-            views.voiceMessageSendButton.isVisible = false
-            recordingState = RecordingState.NONE
-        }
-
-        views.voicePlaybackWaveform.setOnClickListener {
-            if (recordingState != RecordingState.PLAYBACK) {
-                recordingState = RecordingState.PLAYBACK
-                showPlaybackViews()
-            }
-        }
-
-        views.voicePlaybackControlButton.setOnClickListener {
-            callback?.onVoicePlaybackButtonClicked()
-        }
-
-        views.voiceMessageMicButton.setOnTouchListener { _, event ->
-            when (event.action) {
-                MotionEvent.ACTION_DOWN -> {
-                    handleMicActionDown(event)
-                    true
-                }
-                MotionEvent.ACTION_UP   -> {
-                    handleMicActionUp()
-                    true
-                }
-                MotionEvent.ACTION_MOVE -> {
-                    if (recordingState == RecordingState.CANCELLED) return@setOnTouchListener false
-                    handleMicActionMove(event)
-                    true
-                }
-                else                    ->
-                    false
-            }
-        }
-    }
-
-    private fun handleMicActionDown(event: MotionEvent) {
-        val recordingStarted = callback?.onVoiceRecordingStarted().orFalse()
-        if (recordingStarted) {
-            startRecordingTicker()
-            renderToast(context.getString(R.string.voice_message_release_to_send_toast))
-            recordingState = RecordingState.STARTED
-            showRecordingViews()
-
-            firstX = event.rawX
-            firstY = event.rawY
-            lastX = firstX
-            lastY = firstY
-            lastDistanceX = 0F
-            lastDistanceY = 0F
-        }
-    }
-
-    private fun handleMicActionUp() {
-        if (recordingState != RecordingState.LOCKED && recordingState != RecordingState.NONE) {
-            stopRecordingTicker()
-            val isCancelled = recordingState == RecordingState.NONE || recordingState == RecordingState.CANCELLED
-            recordingState = RecordingState.NONE
-            hideRecordingViews(isCancelled = isCancelled)
-        }
-    }
-
-    private fun handleMicActionMove(event: MotionEvent) {
-        val currentX = event.rawX
-        val currentY = event.rawY
-
-        val distanceX = abs(firstX - currentX)
-        val distanceY = abs(firstY - currentY)
-
-        val isRecordingStateChanged = updateRecordingState(currentX, currentY, distanceX, distanceY)
-
-        when (recordingState) {
-            RecordingState.CANCELLING -> {
-                val translationAmount = distanceX.coerceAtMost(distanceToCancel)
-                views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
-                views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
-                val reducedAlpha = (1 - translationAmount / distanceToCancel / 1.5).toFloat()
-                views.voiceMessageSlideToCancel.alpha = reducedAlpha
-                views.voiceMessageTimerIndicator.alpha = reducedAlpha
-                views.voiceMessageTimer.alpha = reducedAlpha
-                views.voiceMessageLockBackground.isVisible = false
-                views.voiceMessageLockImage.isVisible = false
-                views.voiceMessageLockArrow.isVisible = false
-                // Reset Y translations
-                views.voiceMessageMicButton.translationY = 0F
-                views.voiceMessageLockArrow.translationY = 0F
-            }
-            RecordingState.LOCKING    -> {
-                views.voiceMessageLockImage.setAttributeTintedImageResource(R.drawable.ic_voice_message_locked, R.attr.colorPrimary)
-                val translationAmount = -distanceY.coerceIn(0F, distanceToLock)
-                views.voiceMessageMicButton.translationY = translationAmount
-                views.voiceMessageLockArrow.translationY = translationAmount
-                views.voiceMessageLockArrow.alpha = 1 - (-translationAmount / distanceToLock)
-                // Reset X translations
-                views.voiceMessageMicButton.translationX = 0F
-                views.voiceMessageSlideToCancel.translationX = 0F
-            }
-            RecordingState.CANCELLED  -> {
-                hideRecordingViews(isCancelled = true)
-                vibrate(context)
-            }
-            RecordingState.LOCKED     -> {
-                if (isRecordingStateChanged) { // Do not update views if it was already in locked state.
-                    views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_locked)
-                    views.voiceMessageLockImage.postDelayed({
-                        showRecordingLockedViews()
-                    }, 500)
-                }
-            }
-            RecordingState.STARTED    -> {
-                showRecordingViews()
-                val translationAmount = distanceX.coerceAtMost(distanceToCancel)
-                views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
-                views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
-            }
-            RecordingState.NONE       -> Timber.d("VoiceMessageRecorderView shouldn't be in NONE state while moving.")
-            RecordingState.PLAYBACK   -> Timber.d("VoiceMessageRecorderView shouldn't be in PLAYBACK state while moving.")
-        }
-        lastX = currentX
-        lastY = currentY
-        lastDistanceX = distanceX
-        lastDistanceY = distanceY
-    }
-
-    private fun updateRecordingState(currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): Boolean {
-        val previousRecordingState = recordingState
-        if (recordingState == RecordingState.STARTED) {
-            // Determine if cancelling or locking for the first move action.
-            if (((currentX < firstX && rtlXMultiplier == 1) || (currentX > firstX && rtlXMultiplier == -1)) &&
-                    distanceX > distanceY && distanceX > lastDistanceX) {
-                recordingState = RecordingState.CANCELLING
-            } else if (currentY < firstY && distanceY > distanceX && distanceY > lastDistanceY) {
-                recordingState = RecordingState.LOCKING
-            }
-        } else if (recordingState == RecordingState.CANCELLING) {
-            // Check if cancelling conditions met, also check if it should be initial state
-            if (distanceX < minimumMove && distanceX < lastDistanceX) {
-                recordingState = RecordingState.STARTED
-            } else if (shouldCancelRecording(distanceX)) {
-                recordingState = RecordingState.CANCELLED
-            }
-        } else if (recordingState == RecordingState.LOCKING) {
-            // Check if locking conditions met, also check if it should be initial state
-            if (distanceY < minimumMove && distanceY < lastDistanceY) {
-                recordingState = RecordingState.STARTED
-            } else if (shouldLockRecording(distanceY)) {
-                recordingState = RecordingState.LOCKED
-            }
-        }
-        return previousRecordingState != recordingState
-    }
-
-    private fun shouldCancelRecording(distanceX: Float): Boolean {
-        return distanceX >= distanceToCancel
-    }
-
-    private fun shouldLockRecording(distanceY: Float): Boolean {
-        return distanceY >= distanceToLock
-    }
-
-    private fun startRecordingTicker() {
-        recordingTicker?.stop()
-        recordingTicker = CountUpTimer().apply {
-            tickListener = object : CountUpTimer.TickListener {
-                override fun onTick(milliseconds: Long) {
-                    onRecordingTick(milliseconds)
-                }
-            }
-            resume()
-        }
-        onRecordingTick(0L)
-    }
-
-    private fun onRecordingTick(milliseconds: Long) {
-        renderRecordingTimer(milliseconds / 1_000)
-        val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
-        if (timeDiffToRecordingLimit <= 0) {
-            views.voiceMessageRecordingLayout.post {
-                recordingState = RecordingState.PLAYBACK
-                showPlaybackViews()
-                stopRecordingTicker()
-            }
-        } else if (timeDiffToRecordingLimit in 10_000..10_999) {
-            views.voiceMessageRecordingLayout.post {
-                renderToast(context.getString(R.string.voice_message_n_seconds_warning_toast, floor(timeDiffToRecordingLimit / 1000f).toInt()))
-                vibrate(context)
-            }
-        }
-    }
-
-    private fun renderToast(message: String) {
-        views.voiceMessageToast.removeCallbacks(hideToastRunnable)
-        views.voiceMessageToast.text = message
-        views.voiceMessageToast.isVisible = true
-        views.voiceMessageToast.postDelayed(hideToastRunnable, 2_000)
-    }
-
-    private fun hideToast() {
-        views.voiceMessageToast.isVisible = false
-    }
-
-    private val hideToastRunnable = Runnable {
-        views.voiceMessageToast.isVisible = false
-    }
-
-    private fun renderRecordingTimer(recordingTimeMillis: Long) {
-        val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis)
-        if (recordingState == RecordingState.LOCKED) {
-            views.voicePlaybackTime.apply {
-                post {
-                    text = formattedTimerText
-                }
-            }
-        } else {
-            views.voiceMessageTimer.post {
-                views.voiceMessageTimer.text = formattedTimerText
-            }
-        }
-    }
-
-    private fun renderRecordingWaveform(amplitudeList: Array<Int>) {
-        post {
-            views.voicePlaybackWaveform.apply {
-                amplitudeList.iterator().forEach {
-                    update(it)
-                }
-            }
-        }
-    }
-
-    private fun stopRecordingTicker() {
-        recordingTicker?.stop()
-        recordingTicker = null
-    }
-
-    private fun showRecordingViews() {
-        views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic_recording)
-        views.voiceMessageMicButton.setAttributeTintedBackground(R.drawable.circle_with_halo, R.attr.colorPrimary)
-        views.voiceMessageMicButton.updateLayoutParams<MarginLayoutParams> {
-            setMargins(0, 0, 0, 0)
-        }
-        views.voiceMessageMicButton.animate().scaleX(1.5f).scaleY(1.5f).setDuration(300).start()
-
-        views.voiceMessageLockBackground.isVisible = true
-        views.voiceMessageLockBackground.animate().setDuration(300).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
-        views.voiceMessageLockImage.isVisible = true
-        views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_unlocked)
-        views.voiceMessageLockImage.animate().setDuration(500).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
-        views.voiceMessageLockArrow.isVisible = true
-        views.voiceMessageLockArrow.alpha = 1f
-        views.voiceMessageSlideToCancel.isVisible = true
-        views.voiceMessageTimerIndicator.isVisible = true
-        views.voiceMessageTimer.isVisible = true
-        views.voiceMessageSlideToCancel.alpha = 1f
-        views.voiceMessageTimerIndicator.alpha = 1f
-        views.voiceMessageTimer.alpha = 1f
-        views.voiceMessageSendButton.isVisible = false
-    }
-
-    private fun hideRecordingViews(isCancelled: Boolean?) {
-        // We need to animate the lock image first
-        if (recordingState != RecordingState.LOCKED || isCancelled.orFalse()) {
-            views.voiceMessageLockImage.isVisible = false
-            views.voiceMessageLockImage.animate().translationY(0f).start()
-            views.voiceMessageLockBackground.isVisible = false
-            views.voiceMessageLockBackground.animate().translationY(0f).start()
-        } else {
-            animateLockImageWithBackground()
-        }
-        views.voiceMessageLockArrow.isVisible = false
-        views.voiceMessageLockArrow.animate().translationY(0f).start()
-        views.voiceMessageSlideToCancel.isVisible = false
-        views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
-        views.voiceMessagePlaybackLayout.isVisible = false
-
-        if (recordingState != RecordingState.LOCKED) {
-            views.voiceMessageMicButton
-                    .animate()
-                    .scaleX(1f)
-                    .scaleY(1f)
-                    .translationX(0f)
-                    .translationY(0f)
-                    .setDuration(150)
-                    .withEndAction {
-                        views.voiceMessageTimerIndicator.isVisible = false
-                        views.voiceMessageTimer.isVisible = false
-                        resetMicButtonUi()
-                        isCancelled?.let {
-                            callback?.onVoiceRecordingEnded(it)
-                        }
-                    }
-                    .start()
-        } else {
-            views.voiceMessageTimerIndicator.isVisible = false
-            views.voiceMessageTimer.isVisible = false
-            views.voiceMessageMicButton.apply {
-                scaleX = 1f
-                scaleY = 1f
-                translationX = 0f
-                translationY = 0f
-            }
-            isCancelled?.let {
-                callback?.onVoiceRecordingEnded(it)
-            }
-        }
-
-        // Hide toasts if user cancelled recording before the timeout of the toast.
-        if (recordingState == RecordingState.CANCELLED || recordingState == RecordingState.NONE) {
-            hideToast()
-        }
-    }
-
-    private fun resetMicButtonUi() {
-        views.voiceMessageMicButton.isVisible = true
-        views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic)
-        views.voiceMessageMicButton.setAttributeBackground(android.R.attr.selectableItemBackgroundBorderless)
-        views.voiceMessageMicButton.updateLayoutParams<MarginLayoutParams> {
-            if (rtlXMultiplier == -1) {
-                // RTL
-                setMargins(dimensionConverter.dpToPx(12), 0, 0, dimensionConverter.dpToPx(12))
-            } else {
-                setMargins(0, 0, dimensionConverter.dpToPx(12), dimensionConverter.dpToPx(12))
-            }
-        }
-    }
-
-    private fun animateLockImageWithBackground() {
-        views.voiceMessageLockBackground.updateLayoutParams {
-            height = dimensionConverter.dpToPx(78)
-        }
-        views.voiceMessageLockBackground.apply {
-            animate()
-                    .scaleX(0f)
-                    .scaleY(0f)
-                    .setDuration(400L)
-                    .withEndAction {
-                        updateLayoutParams {
-                            height = dimensionConverter.dpToPx(180)
-                        }
-                        isVisible = false
-                        scaleX = 1f
-                        scaleY = 1f
-                        animate().translationY(0f).start()
-                    }
-                    .start()
-        }
-
-        // Lock image animation
-        views.voiceMessageMicButton.isInvisible = true
-        views.voiceMessageLockImage.apply {
-            isVisible = true
-            animate()
-                    .scaleX(0f)
-                    .scaleY(0f)
-                    .setDuration(400L)
-                    .withEndAction {
-                        isVisible = false
-                        scaleX = 1f
-                        scaleY = 1f
-                        translationY = 0f
-                        resetMicButtonUi()
-                    }
-                    .start()
-        }
-    }
-
-    private fun showRecordingLockedViews() {
-        hideRecordingViews(null)
-        views.voiceMessagePlaybackLayout.isVisible = true
-        views.voiceMessagePlaybackTimerIndicator.isVisible = true
-        views.voicePlaybackControlButton.isVisible = false
-        views.voiceMessageSendButton.isVisible = true
-        views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
-        renderToast(context.getString(R.string.voice_message_tap_to_stop_toast))
-    }
-
-    private fun showPlaybackViews() {
-        views.voiceMessagePlaybackTimerIndicator.isVisible = false
-        views.voicePlaybackControlButton.isVisible = true
-        views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
-        callback?.onVoiceRecordingPlaybackModeOn()
-    }
-
-    private enum class RecordingState {
-        NONE,
-        STARTED,
-        CANCELLING,
-        CANCELLED,
-        LOCKING,
-        LOCKED,
-        PLAYBACK
-    }
-
-    /**
-     * Returns true if the voice message is recording or is in playback mode
-     */
-    fun isActive() = recordingState !in listOf(RecordingState.NONE, RecordingState.CANCELLED)
-
-    override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
-        when (state) {
-            is VoiceMessagePlaybackTracker.Listener.State.Recording -> {
-                renderRecordingWaveform(state.amplitudeList.toTypedArray())
-            }
-            is VoiceMessagePlaybackTracker.Listener.State.Playing   -> {
-                views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
-                views.voicePlaybackControlButton.contentDescription = context.getString(R.string.a11y_pause_voice_message)
-                val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong())
-                views.voicePlaybackTime.text = formattedTimerText
-            }
-            is VoiceMessagePlaybackTracker.Listener.State.Paused,
-            is VoiceMessagePlaybackTracker.Listener.State.Idle      -> {
-                views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
-                views.voicePlaybackControlButton.contentDescription = context.getString(R.string.a11y_play_voice_message)
-            }
-        }
-    }
-}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
index 79898dad32..2227405507 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
@@ -49,15 +49,15 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
         fun sendVoiceMessage()
         fun deleteVoiceMessage()
         fun onRecordingLimitReached()
+        fun recordingWaveformClicked()
+        fun currentState(): RecordingUiState
     }
 
     // We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
     @Suppress("UNNECESSARY_LATEINIT")
     private lateinit var voiceMessageViews: VoiceMessageViews
+    lateinit var callback: Callback
 
-    var callback: Callback? = null
-
-    private var currentUiState: RecordingUiState = RecordingUiState.None
     private var recordingTicker: CountUpTimer? = null
 
     init {
@@ -68,7 +68,6 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
                 ViewVoiceMessageRecorderBinding.bind(this),
                 dimensionConverter
         )
-        initVoiceRecordingViews()
         initListeners()
     }
 
@@ -80,65 +79,49 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
         voiceMessageViews.renderVisibilityChanged(parentChanged, visibility)
     }
 
-    fun initVoiceRecordingViews() {
-        stopRecordingTicker()
-        voiceMessageViews.initViews(onVoiceRecordingEnded = {})
-    }
-
     private fun initListeners() {
         voiceMessageViews.start(object : VoiceMessageViews.Actions {
             override fun onRequestRecording() {
-                callback?.onVoiceRecordingStarted()
+                callback.onVoiceRecordingStarted()
             }
 
             override fun onRecordingStopped() {
-                callback?.onRecordingStopped()
+                callback.onRecordingStopped()
             }
 
-            override fun isActive() = currentUiState != RecordingUiState.Cancelled
+            override fun isActive() = callback.currentState() != RecordingUiState.Cancelled
 
             override fun updateState(updater: (RecordingUiState) -> RecordingUiState) {
-                updater(currentUiState).also { newState ->
-                    when (newState) {
-                        is DraggingState -> display(newState)
-                        else             -> {
-                            if (newState != currentUiState) {
-                                callback?.onUiStateChanged(newState)
-                            }
-                        }
-                    }
+                updater(callback.currentState()).also { newState ->
+                    callback.onUiStateChanged(newState)
                 }
             }
 
             override fun sendMessage() {
-                callback?.sendVoiceMessage()
+                callback.sendVoiceMessage()
             }
 
             override fun delete() {
                 // this was previously marked as cancelled true
-                callback?.deleteVoiceMessage()
+                callback.deleteVoiceMessage()
             }
 
             override fun waveformClicked() {
-                display(RecordingUiState.Playback)
+                callback.recordingWaveformClicked()
             }
 
             override fun onVoicePlaybackButtonClicked() {
-                callback?.onVoicePlaybackButtonClicked()
+                callback.onVoicePlaybackButtonClicked()
             }
         })
     }
 
     fun display(recordingState: RecordingUiState) {
-        if (recordingState == this.currentUiState) return
-
-        val previousState = this.currentUiState
-        this.currentUiState = recordingState
         when (recordingState) {
             RecordingUiState.None      -> {
-                val isCancelled = previousState == RecordingUiState.Cancelled
-                voiceMessageViews.hideRecordingViews(recordingState, isCancelled = isCancelled) { callback?.onVoiceRecordingEnded(it) }
                 stopRecordingTicker()
+                voiceMessageViews.initViews()
+                callback.onVoiceRecordingEnded(false)
             }
             RecordingUiState.Started   -> {
                 startRecordingTicker()
@@ -146,19 +129,20 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
                 voiceMessageViews.showRecordingViews()
             }
             RecordingUiState.Cancelled -> {
-                voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback?.onVoiceRecordingEnded(it) }
+                stopRecordingTicker()
+                voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback.onVoiceRecordingEnded(it) }
                 vibrate(context)
             }
             RecordingUiState.Locked    -> {
                 voiceMessageViews.renderLocked()
                 postDelayed({
-                    voiceMessageViews.showRecordingLockedViews(recordingState) { callback?.onVoiceRecordingEnded(it) }
+                    voiceMessageViews.showRecordingLockedViews(recordingState) { callback.onVoiceRecordingEnded(it) }
                 }, 500)
             }
             RecordingUiState.Playback  -> {
                 stopRecordingTicker()
                 voiceMessageViews.showPlaybackViews()
-                callback?.onVoiceRecordingPlaybackModeOn()
+                callback.onVoiceRecordingPlaybackModeOn()
             }
             is DraggingState           -> when (recordingState) {
                 is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(recordingState.distanceX)
@@ -181,11 +165,11 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
     }
 
     private fun onRecordingTick(milliseconds: Long) {
-        voiceMessageViews.renderRecordingTimer(currentUiState, milliseconds / 1_000)
+        voiceMessageViews.renderRecordingTimer(callback.currentState(), milliseconds / 1_000)
         val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
         if (timeDiffToRecordingLimit <= 0) {
             post {
-                callback?.onRecordingLimitReached()
+                callback.onRecordingLimitReached()
             }
         } else if (timeDiffToRecordingLimit in 10_000..10_999) {
             post {
@@ -203,7 +187,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
     /**
      * Returns true if the voice message is recording or is in playback mode
      */
-    fun isActive() = currentUiState !in listOf(RecordingUiState.None, RecordingUiState.Cancelled)
+    fun isActive() = callback.currentState() !in listOf(RecordingUiState.None, RecordingUiState.Cancelled)
 
     override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
         when (state) {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
index ce4ec4b519..63b5dc17ee 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
@@ -282,8 +282,8 @@ class VoiceMessageViews(
         views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
     }
 
-    fun initViews(onVoiceRecordingEnded: (Boolean) -> Unit) {
-        hideRecordingViews(RecordingUiState.None, null, onVoiceRecordingEnded)
+    fun initViews() {
+        hideRecordingViews(RecordingUiState.None, null, onVoiceRecordingEnded = {})
         views.voiceMessageMicButton.isVisible = true
         views.voiceMessageSendButton.isVisible = false
         views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }

From 2ad121e96e6f809ce4b7f1ed07bf72f1509a8d96 Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Thu, 11 Nov 2021 16:57:06 +0000
Subject: [PATCH 04/17] moving the recording ui state to the textcomposer view
 model and state

---
 .../home/room/detail/RoomDetailFragment.kt    | 24 +++++--------------
 .../detail/composer/TextComposerAction.kt     |  3 ++-
 .../detail/composer/TextComposerViewModel.kt  | 22 ++++++++---------
 .../detail/composer/TextComposerViewState.kt  | 15 ++++++++++--
 .../composer/voice/DraggableStateProcessor.kt | 10 ++++----
 .../voice/VoiceMessageRecorderView.kt         | 19 +++++++++------
 .../composer/voice/VoiceMessageViews.kt       |  2 ++
 7 files changed, 50 insertions(+), 45 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
index ba45348cab..844de4c980 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
@@ -696,26 +696,14 @@ class RoomDetailFragment @Inject constructor(
         voiceMessagePlaybackTracker.track(VoiceMessagePlaybackTracker.RECORDING_ID, views.voiceMessageRecorderView)
         views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback {
 
-            private var currentUiState: RecordingUiState = RecordingUiState.None
-
-            init {
-                display(currentUiState)
-            }
-
             override fun onVoiceRecordingStarted() {
                 if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
                     roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
-                    textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(true))
                     vibrate(requireContext())
                     display(RecordingUiState.Started)
                 }
             }
 
-            override fun onVoiceRecordingEnded(isCancelled: Boolean) {
-                roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled))
-                textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(false))
-            }
-
             override fun onVoiceRecordingPlaybackModeOn() {
                 roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
             }
@@ -725,7 +713,8 @@ class RoomDetailFragment @Inject constructor(
             }
 
             override fun onRecordingStopped() {
-                if (currentUiState != RecordingUiState.Locked) {
+                roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true))
+                if (currentState() != RecordingUiState.Locked) {
                     display(RecordingUiState.None)
                 }
             }
@@ -739,6 +728,7 @@ class RoomDetailFragment @Inject constructor(
             }
 
             override fun deleteVoiceMessage() {
+                roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true))
                 display(RecordingUiState.Cancelled)
             }
 
@@ -751,13 +741,10 @@ class RoomDetailFragment @Inject constructor(
             }
 
             private fun display(state: RecordingUiState) {
-                if (currentUiState != state) {
-                    views.voiceMessageRecorderView.display(state)
-                }
-                currentUiState = state
+                textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingUiStateChanged(state))
             }
 
-            override fun currentState() = currentUiState
+            override fun currentState() = withState(textComposerViewModel) { it.voiceRecordingUiState }
         }
     }
 
@@ -1444,6 +1431,7 @@ class RoomDetailFragment @Inject constructor(
                 views.composerLayout.isInvisible = !textComposerState.isComposerVisible
                 views.voiceMessageRecorderView.isVisible = textComposerState.isVoiceMessageRecorderVisible
                 views.composerLayout.views.sendButton.isInvisible = !textComposerState.isSendButtonVisible
+                views.voiceMessageRecorderView.display(textComposerState.voiceRecordingUiState)
                 views.composerLayout.setRoomEncrypted(summary.isEncrypted)
                 // views.composerLayout.alwaysShowSendButton = false
                 if (textComposerState.canSendMessage) {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerAction.kt
index 7725400187..4f85b78226 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerAction.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerAction.kt
@@ -17,6 +17,7 @@
 package im.vector.app.features.home.room.detail.composer
 
 import im.vector.app.core.platform.VectorViewModelAction
+import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
 
 sealed class TextComposerAction : VectorViewModelAction {
     data class SaveDraft(val draft: String) : TextComposerAction()
@@ -27,5 +28,5 @@ sealed class TextComposerAction : VectorViewModelAction {
     data class EnterRegularMode(val text: String, val fromSharing: Boolean) : TextComposerAction()
     data class UserIsTyping(val isTyping: Boolean) : TextComposerAction()
     data class OnTextChanged(val text: CharSequence) : TextComposerAction()
-    data class OnVoiceRecordingStateChanged(val isRecording: Boolean) : TextComposerAction()
+    data class OnVoiceRecordingUiStateChanged(val uiState: VoiceMessageRecorderView.RecordingUiState) : TextComposerAction()
 }
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewModel.kt
index 66d49f9819..2ff8ef6618 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewModel.kt
@@ -77,20 +77,20 @@ class TextComposerViewModel @AssistedInject constructor(
     override fun handle(action: TextComposerAction) {
         Timber.v("Handle action: $action")
         when (action) {
-            is TextComposerAction.EnterEditMode                -> handleEnterEditMode(action)
-            is TextComposerAction.EnterQuoteMode               -> handleEnterQuoteMode(action)
-            is TextComposerAction.EnterRegularMode             -> handleEnterRegularMode(action)
-            is TextComposerAction.EnterReplyMode               -> handleEnterReplyMode(action)
-            is TextComposerAction.SaveDraft                    -> handleSaveDraft(action)
-            is TextComposerAction.SendMessage                  -> handleSendMessage(action)
-            is TextComposerAction.UserIsTyping                 -> handleUserIsTyping(action)
-            is TextComposerAction.OnTextChanged                -> handleOnTextChanged(action)
-            is TextComposerAction.OnVoiceRecordingStateChanged -> handleOnVoiceRecordingStateChanged(action)
+            is TextComposerAction.EnterEditMode                  -> handleEnterEditMode(action)
+            is TextComposerAction.EnterQuoteMode                 -> handleEnterQuoteMode(action)
+            is TextComposerAction.EnterRegularMode               -> handleEnterRegularMode(action)
+            is TextComposerAction.EnterReplyMode                 -> handleEnterReplyMode(action)
+            is TextComposerAction.SaveDraft                      -> handleSaveDraft(action)
+            is TextComposerAction.SendMessage                    -> handleSendMessage(action)
+            is TextComposerAction.UserIsTyping                   -> handleUserIsTyping(action)
+            is TextComposerAction.OnTextChanged                  -> handleOnTextChanged(action)
+            is TextComposerAction.OnVoiceRecordingUiStateChanged -> handleOnVoiceRecordingUiStateChanged(action)
         }
     }
 
-    private fun handleOnVoiceRecordingStateChanged(action: TextComposerAction.OnVoiceRecordingStateChanged) = setState {
-        copy(isVoiceRecording = action.isRecording)
+    private fun handleOnVoiceRecordingUiStateChanged(action: TextComposerAction.OnVoiceRecordingUiStateChanged) = setState {
+        copy(voiceRecordingUiState = action.uiState)
     }
 
     private fun handleOnTextChanged(action: TextComposerAction.OnTextChanged) {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewState.kt
index 199fb1b82d..99cd4b0e30 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewState.kt
@@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.composer
 
 import com.airbnb.mvrx.MavericksState
 import im.vector.app.features.home.room.detail.RoomDetailArgs
+import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
 import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
 
 /**
@@ -44,11 +45,21 @@ sealed class SendMode(open val text: String) {
 data class TextComposerViewState(
         val roomId: String,
         val canSendMessage: Boolean = true,
-        val isVoiceRecording: Boolean = false,
         val isSendButtonVisible: Boolean = false,
-        val sendMode: SendMode = SendMode.REGULAR("", false)
+        val sendMode: SendMode = SendMode.REGULAR("", false),
+        val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.None
 ) : MavericksState {
 
+    val isVoiceRecording = when (voiceRecordingUiState) {
+        VoiceMessageRecorderView.RecordingUiState.None,
+        VoiceMessageRecorderView.RecordingUiState.Cancelled,
+        VoiceMessageRecorderView.RecordingUiState.Playback -> false
+        is VoiceMessageRecorderView.DraggingState.Cancelling,
+        is VoiceMessageRecorderView.DraggingState.Locking,
+        VoiceMessageRecorderView.RecordingUiState.Locked,
+        VoiceMessageRecorderView.RecordingUiState.Started  -> true
+    }
+
     val isComposerVisible = canSendMessage && !isVoiceRecording
     val isVoiceMessageRecorderVisible = canSendMessage && !isSendButtonVisible
 
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
index 5825e60ecf..41c9f83a97 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
@@ -77,12 +77,10 @@ class DraggableStateProcessor(
             }
             is DraggingState.Cancelling -> {
                 // Check if cancelling conditions met, also check if it should be initial state
-                if (distanceX < minimumMove && distanceX < lastDistanceX) {
-                    RecordingUiState.Started
-                } else if (shouldCancelRecording(distanceX)) {
-                    RecordingUiState.Cancelled
-                } else {
-                    DraggingState.Cancelling(distanceX)
+                when {
+                    distanceX < minimumMove && distanceX < lastDistanceX -> RecordingUiState.Started
+                    shouldCancelRecording(distanceX)                     -> RecordingUiState.Cancelled
+                    else                                                 -> DraggingState.Cancelling(distanceX)
                 }
             }
             is DraggingState.Locking    -> {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
index 2227405507..8c3eadca1c 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
@@ -41,7 +41,6 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
 
     interface Callback {
         fun onVoiceRecordingStarted()
-        fun onVoiceRecordingEnded(isCancelled: Boolean)
         fun onVoiceRecordingPlaybackModeOn()
         fun onVoicePlaybackButtonClicked()
         fun onRecordingStopped()
@@ -59,6 +58,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
     lateinit var callback: Callback
 
     private var recordingTicker: CountUpTimer? = null
+    private var lastKnownState: RecordingUiState? = null
 
     init {
         inflate(this.context, R.layout.view_voice_message_recorder, this)
@@ -92,8 +92,11 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
             override fun isActive() = callback.currentState() != RecordingUiState.Cancelled
 
             override fun updateState(updater: (RecordingUiState) -> RecordingUiState) {
-                updater(callback.currentState()).also { newState ->
-                    callback.onUiStateChanged(newState)
+                updater(lastKnownState ?: RecordingUiState.None).also { newState ->
+                    when (newState) {
+                        is DraggingState -> display(newState)
+                        else             -> callback.onUiStateChanged(newState)
+                    }
                 }
             }
 
@@ -102,7 +105,6 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
             }
 
             override fun delete() {
-                // this was previously marked as cancelled true
                 callback.deleteVoiceMessage()
             }
 
@@ -117,11 +119,12 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
     }
 
     fun display(recordingState: RecordingUiState) {
+        if (lastKnownState == recordingState) return
+        lastKnownState = recordingState
         when (recordingState) {
             RecordingUiState.None      -> {
                 stopRecordingTicker()
                 voiceMessageViews.initViews()
-                callback.onVoiceRecordingEnded(false)
             }
             RecordingUiState.Started   -> {
                 startRecordingTicker()
@@ -130,13 +133,15 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
             }
             RecordingUiState.Cancelled -> {
                 stopRecordingTicker()
-                voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback.onVoiceRecordingEnded(it) }
+                voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback.deleteVoiceMessage() }
                 vibrate(context)
             }
             RecordingUiState.Locked    -> {
                 voiceMessageViews.renderLocked()
                 postDelayed({
-                    voiceMessageViews.showRecordingLockedViews(recordingState) { callback.onVoiceRecordingEnded(it) }
+                    voiceMessageViews.showRecordingLockedViews(recordingState) {
+                        // do nothing
+                    }
                 }, 500)
             }
             RecordingUiState.Playback  -> {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
index 63b5dc17ee..938ae74983 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
@@ -19,6 +19,7 @@ package im.vector.app.features.home.room.detail.composer.voice
 import android.annotation.SuppressLint
 import android.content.res.Resources
 import android.text.format.DateUtils
+import android.util.Log
 import android.view.MotionEvent
 import android.view.View
 import android.view.ViewGroup
@@ -73,6 +74,7 @@ class VoiceMessageViews(
         views.voiceMessageMicButton.setOnTouchListener { _, event ->
             when (event.action) {
                 MotionEvent.ACTION_DOWN -> {
+                    Log.e("!!!", "event down: $event")
                     positions.reset(event)
                     actions.onRequestRecording()
                     true

From e895dbd923555c89bf3d35ddbecba291abebb949 Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Wed, 17 Nov 2021 13:27:22 +0000
Subject: [PATCH 05/17] replacing chained ifs with when

---
 .../composer/voice/DraggableStateProcessor.kt | 29 +++++++++----------
 1 file changed, 14 insertions(+), 15 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
index 41c9f83a97..23e973afda 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
@@ -22,7 +22,6 @@ import im.vector.app.R
 import im.vector.app.core.utils.DimensionConverter
 import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.DraggingState
 import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState
-import kotlin.math.abs
 
 class DraggableStateProcessor(
         resources: Resources,
@@ -53,8 +52,8 @@ class DraggableStateProcessor(
     fun process(event: MotionEvent, recordingState: RecordingUiState): RecordingUiState {
         val currentX = event.rawX
         val currentY = event.rawY
-        val distanceX = abs(firstX - currentX)
-        val distanceY = abs(firstY - currentY)
+        val distanceX = firstX - currentX
+        val distanceY = firstY - currentY
         return nextRecordingState(recordingState, currentX, currentY, distanceX, distanceY).also {
             lastX = currentX
             lastY = currentY
@@ -67,12 +66,10 @@ class DraggableStateProcessor(
         return when (recordingState) {
             RecordingUiState.Started    -> {
                 // Determine if cancelling or locking for the first move action.
-                if (((currentX < firstX && rtlXMultiplier == 1) || (currentX > firstX && rtlXMultiplier == -1)) && distanceX > distanceY && distanceX > lastDistanceX) {
-                    DraggingState.Cancelling(distanceX)
-                } else if (currentY < firstY && distanceY > distanceX && distanceY > lastDistanceY) {
-                    DraggingState.Locking(distanceY)
-                } else {
-                    recordingState
+                when {
+                    (isSlidingToCancel(currentX)) && distanceX > distanceY && distanceX > lastDistanceX -> DraggingState.Cancelling(distanceX)
+                    isSlidingToLock(currentY) && distanceY > distanceX && distanceY > lastDistanceY     -> DraggingState.Locking(distanceY)
+                    else                                                                                -> recordingState
                 }
             }
             is DraggingState.Cancelling -> {
@@ -85,12 +82,10 @@ class DraggableStateProcessor(
             }
             is DraggingState.Locking    -> {
                 // Check if locking conditions met, also check if it should be initial state
-                if (distanceY < minimumMove && distanceY < lastDistanceY) {
-                    RecordingUiState.Started
-                } else if (shouldLockRecording(distanceY)) {
-                    RecordingUiState.Locked
-                } else {
-                    DraggingState.Locking(distanceY)
+                when {
+                    distanceY < minimumMove && distanceY < lastDistanceY -> RecordingUiState.Started
+                    shouldLockRecording(distanceY)                       -> RecordingUiState.Locked
+                    else                                                 -> DraggingState.Locking(distanceY)
                 }
             }
             else                        -> {
@@ -99,6 +94,10 @@ class DraggableStateProcessor(
         }
     }
 
+    private fun isSlidingToLock(currentY: Float) = currentY < firstY
+
+    private fun isSlidingToCancel(currentX: Float) = (currentX < firstX && rtlXMultiplier == 1) || (currentX > firstX && rtlXMultiplier == -1)
+
     private fun shouldCancelRecording(distanceX: Float): Boolean {
         return distanceX >= distanceToCancel
     }

From 9ae03b76cdba2c90bcd2131caf67c73d8e28acab Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Wed, 17 Nov 2021 15:31:41 +0000
Subject: [PATCH 06/17] allows locking and cancelling to occur after choosing
 either option - fixes other quirks caused by porting to the inverted display
 logic

---
 .../home/room/detail/RoomDetailFragment.kt    | 15 +++---
 .../detail/composer/TextComposerViewState.kt  |  6 +++
 .../composer/voice/DraggableStateProcessor.kt | 47 ++++++++-----------
 .../voice/VoiceMessageRecorderView.kt         | 44 ++++++++++-------
 .../composer/voice/VoiceMessageViews.kt       | 23 ++++-----
 .../layout/view_voice_message_recorder.xml    |  1 +
 6 files changed, 69 insertions(+), 67 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
index 844de4c980..271bb9b775 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
@@ -712,9 +712,10 @@ class RoomDetailFragment @Inject constructor(
                 roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseRecordingPlayback)
             }
 
-            override fun onRecordingStopped() {
-                roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true))
-                if (currentState() != RecordingUiState.Locked) {
+            override fun onVoiceRecordingEnded(lastKnownState: RecordingUiState?) {
+                if (lastKnownState != RecordingUiState.Locked) {
+                    val isCancelled = lastKnownState == RecordingUiState.Cancelled
+                    roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = isCancelled))
                     display(RecordingUiState.None)
                 }
             }
@@ -729,7 +730,7 @@ class RoomDetailFragment @Inject constructor(
 
             override fun deleteVoiceMessage() {
                 roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true))
-                display(RecordingUiState.Cancelled)
+                display(RecordingUiState.None)
             }
 
             override fun onRecordingLimitReached() {
@@ -743,8 +744,6 @@ class RoomDetailFragment @Inject constructor(
             private fun display(state: RecordingUiState) {
                 textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingUiStateChanged(state))
             }
-
-            override fun currentState() = withState(textComposerViewModel) { it.voiceRecordingUiState }
         }
     }
 
@@ -1986,7 +1985,7 @@ class RoomDetailFragment @Inject constructor(
                 roomDetailViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add))
             }
             is EventSharedAction.Edit                       -> {
-                if (!views.voiceMessageRecorderView.isActive()) {
+                if (withState(textComposerViewModel) { it.isVoiceMessageIdle }) {
                     textComposerViewModel.handle(TextComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString()))
                 } else {
                     requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
@@ -1996,7 +1995,7 @@ class RoomDetailFragment @Inject constructor(
                 textComposerViewModel.handle(TextComposerAction.EnterQuoteMode(action.eventId, views.composerLayout.text.toString()))
             }
             is EventSharedAction.Reply                      -> {
-                if (!views.voiceMessageRecorderView.isActive()) {
+                if (withState(textComposerViewModel) { it.isVoiceMessageIdle }) {
                     textComposerViewModel.handle(TextComposerAction.EnterReplyMode(action.eventId, views.composerLayout.text.toString()))
                 } else {
                     requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewState.kt
index 99cd4b0e30..4eb70138bb 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewState.kt
@@ -60,8 +60,14 @@ data class TextComposerViewState(
         VoiceMessageRecorderView.RecordingUiState.Started  -> true
     }
 
+    val isVoiceMessageIdle = when (voiceRecordingUiState) {
+        VoiceMessageRecorderView.RecordingUiState.None, VoiceMessageRecorderView.RecordingUiState.Cancelled -> false
+        else                                                                                                -> true
+    }
+
     val isComposerVisible = canSendMessage && !isVoiceRecording
     val isVoiceMessageRecorderVisible = canSendMessage && !isSendButtonVisible
 
+    @Suppress("UNUSED") // needed by mavericks
     constructor(args: RoomDetailArgs) : this(roomId = args.roomId)
 }
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
index 23e973afda..a8b19d6f6a 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
@@ -28,23 +28,18 @@ class DraggableStateProcessor(
         dimensionConverter: DimensionConverter,
 ) {
 
-    private val minimumMove = dimensionConverter.dpToPx(16)
     private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
     private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
     private val rtlXMultiplier = resources.getInteger(R.integer.rtl_x_multiplier)
 
     private var firstX: Float = 0f
     private var firstY: Float = 0f
-    private var lastX: Float = 0f
-    private var lastY: Float = 0f
     private var lastDistanceX: Float = 0f
     private var lastDistanceY: Float = 0f
 
-    fun reset(event: MotionEvent) {
+    fun initialize(event: MotionEvent) {
         firstX = event.rawX
         firstY = event.rawY
-        lastX = firstX
-        lastY = firstY
         lastDistanceX = 0F
         lastDistanceY = 0F
     }
@@ -54,49 +49,48 @@ class DraggableStateProcessor(
         val currentY = event.rawY
         val distanceX = firstX - currentX
         val distanceY = firstY - currentY
-        return nextRecordingState(recordingState, currentX, currentY, distanceX, distanceY).also {
-            lastX = currentX
-            lastY = currentY
+        return recordingState.nextRecordingState(currentX, currentY, distanceX, distanceY).also {
             lastDistanceX = distanceX
             lastDistanceY = distanceY
         }
     }
 
-    private fun nextRecordingState(recordingState: RecordingUiState, currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): RecordingUiState {
-        return when (recordingState) {
+    private fun RecordingUiState.nextRecordingState(currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): RecordingUiState {
+        return when (this) {
             RecordingUiState.Started    -> {
-                // Determine if cancelling or locking for the first move action.
                 when {
-                    (isSlidingToCancel(currentX)) && distanceX > distanceY && distanceX > lastDistanceX -> DraggingState.Cancelling(distanceX)
-                    isSlidingToLock(currentY) && distanceY > distanceX && distanceY > lastDistanceY     -> DraggingState.Locking(distanceY)
-                    else                                                                                -> recordingState
+                    isDraggingToCancel(currentX, distanceX, distanceY) -> DraggingState.Cancelling(distanceX)
+                    isDraggingToLock(currentY, distanceX, distanceY)   -> DraggingState.Locking(distanceY)
+                    else                                               -> this
                 }
             }
             is DraggingState.Cancelling -> {
-                // Check if cancelling conditions met, also check if it should be initial state
                 when {
-                    distanceX < minimumMove && distanceX < lastDistanceX -> RecordingUiState.Started
-                    shouldCancelRecording(distanceX)                     -> RecordingUiState.Cancelled
-                    else                                                 -> DraggingState.Cancelling(distanceX)
+                    isDraggingToLock(currentY, distanceX, distanceY) -> DraggingState.Locking(distanceY)
+                    shouldCancelRecording(distanceX)                 -> RecordingUiState.Cancelled
+                    else                                             -> DraggingState.Cancelling(distanceX)
                 }
             }
             is DraggingState.Locking    -> {
-                // Check if locking conditions met, also check if it should be initial state
                 when {
-                    distanceY < minimumMove && distanceY < lastDistanceY -> RecordingUiState.Started
-                    shouldLockRecording(distanceY)                       -> RecordingUiState.Locked
-                    else                                                 -> DraggingState.Locking(distanceY)
+                    isDraggingToCancel(currentX, distanceX, distanceY) -> DraggingState.Cancelling(distanceX)
+                    shouldLockRecording(distanceY)                     -> RecordingUiState.Locked
+                    else                                               -> DraggingState.Locking(distanceY)
                 }
             }
             else                        -> {
-                recordingState
+                this
             }
         }
     }
 
-    private fun isSlidingToLock(currentY: Float) = currentY < firstY
+    private fun isDraggingToLock(currentY: Float, distanceX: Float, distanceY: Float) = (currentY < firstY) &&
+            distanceY > distanceX && distanceY > lastDistanceY
 
-    private fun isSlidingToCancel(currentX: Float) = (currentX < firstX && rtlXMultiplier == 1) || (currentX > firstX && rtlXMultiplier == -1)
+    private fun isDraggingToCancel(currentX: Float, distanceX: Float, distanceY: Float) = isDraggingHorizontal(currentX) &&
+            distanceX > distanceY && distanceX > lastDistanceX
+
+    private fun isDraggingHorizontal(currentX: Float) = (currentX < firstX && rtlXMultiplier == 1) || (currentX > firstX && rtlXMultiplier == -1)
 
     private fun shouldCancelRecording(distanceX: Float): Boolean {
         return distanceX >= distanceToCancel
@@ -106,4 +100,3 @@ class DraggableStateProcessor(
         return distanceY >= distanceToLock
     }
 }
-
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
index 8c3eadca1c..cd784dd9b6 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
@@ -43,13 +43,12 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
         fun onVoiceRecordingStarted()
         fun onVoiceRecordingPlaybackModeOn()
         fun onVoicePlaybackButtonClicked()
-        fun onRecordingStopped()
         fun onUiStateChanged(state: RecordingUiState)
         fun sendVoiceMessage()
         fun deleteVoiceMessage()
         fun onRecordingLimitReached()
         fun recordingWaveformClicked()
-        fun currentState(): RecordingUiState
+        fun onVoiceRecordingEnded(lastKnownState: RecordingUiState?)
     }
 
     // We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
@@ -85,17 +84,23 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
                 callback.onVoiceRecordingStarted()
             }
 
-            override fun onRecordingStopped() {
-                callback.onRecordingStopped()
+            override fun onMicButtonReleased() {
+                callback.onVoiceRecordingEnded(lastKnownState)
             }
 
-            override fun isActive() = callback.currentState() != RecordingUiState.Cancelled
-
             override fun updateState(updater: (RecordingUiState) -> RecordingUiState) {
-                updater(lastKnownState ?: RecordingUiState.None).also { newState ->
-                    when (newState) {
-                        is DraggingState -> display(newState)
-                        else             -> callback.onUiStateChanged(newState)
+                when (val currentState = lastKnownState) {
+                    null, RecordingUiState.None -> {
+                        // ignore drag events when the view is idle
+                    }
+                    else                        -> {
+                        updater(currentState).also { newState ->
+                            when (newState) {
+                                // display drag events directly without leaving the view for faster UI feedback
+                                is DraggingState -> display(newState)
+                                else             -> callback.onUiStateChanged(newState)
+                            }
+                        }
                     }
                 }
             }
@@ -120,6 +125,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
 
     fun display(recordingState: RecordingUiState) {
         if (lastKnownState == recordingState) return
+        val previousState = lastKnownState
         lastKnownState = recordingState
         when (recordingState) {
             RecordingUiState.None      -> {
@@ -151,7 +157,12 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
             }
             is DraggingState           -> when (recordingState) {
                 is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(recordingState.distanceX)
-                is DraggingState.Locking    -> voiceMessageViews.renderLocking(recordingState.distanceY)
+                is DraggingState.Locking    -> {
+                    if (previousState is DraggingState.Cancelling) {
+                        voiceMessageViews.showRecordingViews()
+                    }
+                    voiceMessageViews.renderLocking(recordingState.distanceY)
+                }
             }.exhaustive
         }
     }
@@ -170,7 +181,8 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
     }
 
     private fun onRecordingTick(milliseconds: Long) {
-        voiceMessageViews.renderRecordingTimer(callback.currentState(), milliseconds / 1_000)
+        val currentState = lastKnownState ?: return
+        voiceMessageViews.renderRecordingTimer(currentState, milliseconds / 1_000)
         val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
         if (timeDiffToRecordingLimit <= 0) {
             post {
@@ -178,7 +190,8 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
             }
         } else if (timeDiffToRecordingLimit in 10_000..10_999) {
             post {
-                voiceMessageViews.renderToast(context.getString(R.string.voice_message_n_seconds_warning_toast, floor(timeDiffToRecordingLimit / 1000f).toInt()))
+                val secondsRemaining = floor(timeDiffToRecordingLimit / 1000f).toInt()
+                voiceMessageViews.renderToast(context.getString(R.string.voice_message_n_seconds_warning_toast, secondsRemaining))
                 vibrate(context)
             }
         }
@@ -189,11 +202,6 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
         recordingTicker = null
     }
 
-    /**
-     * Returns true if the voice message is recording or is in playback mode
-     */
-    fun isActive() = callback.currentState() !in listOf(RecordingUiState.None, RecordingUiState.Cancelled)
-
     override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
         when (state) {
             is VoiceMessagePlaybackTracker.Listener.State.Recording -> {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
index 938ae74983..0b2696931c 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
@@ -19,7 +19,6 @@ package im.vector.app.features.home.room.detail.composer.voice
 import android.annotation.SuppressLint
 import android.content.res.Resources
 import android.text.format.DateUtils
-import android.util.Log
 import android.view.MotionEvent
 import android.view.View
 import android.view.ViewGroup
@@ -74,22 +73,17 @@ class VoiceMessageViews(
         views.voiceMessageMicButton.setOnTouchListener { _, event ->
             when (event.action) {
                 MotionEvent.ACTION_DOWN -> {
-                    Log.e("!!!", "event down: $event")
-                    positions.reset(event)
+                    positions.initialize(event)
                     actions.onRequestRecording()
                     true
                 }
                 MotionEvent.ACTION_UP   -> {
-                    actions.onRecordingStopped()
+                    actions.onMicButtonReleased()
                     true
                 }
                 MotionEvent.ACTION_MOVE -> {
-                    if (actions.isActive()) {
-                        actions.updateState { currentState -> positions.process(event, currentState) }
-                        true
-                    } else {
-                        false
-                    }
+                    actions.updateState { currentState -> positions.process(event, currentState) }
+                    true
                 }
                 else                    -> false
             }
@@ -128,6 +122,7 @@ class VoiceMessageViews(
         views.voiceMessageLockBackground.isVisible = false
         views.voiceMessageLockImage.isVisible = false
         views.voiceMessageLockArrow.isVisible = false
+        views.voiceMessageSlideToCancelDivider.isVisible = true
         // Reset Y translations
         views.voiceMessageMicButton.translationY = 0F
         views.voiceMessageLockArrow.translationY = 0F
@@ -167,11 +162,14 @@ class VoiceMessageViews(
         } else {
             animateLockImageWithBackground()
         }
+        views.voiceMessageSlideToCancelDivider.isVisible = false
         views.voiceMessageLockArrow.isVisible = false
         views.voiceMessageLockArrow.animate().translationY(0f).start()
         views.voiceMessageSlideToCancel.isVisible = false
         views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
         views.voiceMessagePlaybackLayout.isVisible = false
+        views.voiceMessageTimerIndicator.isVisible = false
+        views.voiceMessageTimer.isVisible = false
 
         if (recordingState != RecordingUiState.Locked) {
             views.voiceMessageMicButton
@@ -182,8 +180,6 @@ class VoiceMessageViews(
                     .translationY(0f)
                     .setDuration(150)
                     .withEndAction {
-                        views.voiceMessageTimerIndicator.isVisible = false
-                        views.voiceMessageTimer.isVisible = false
                         resetMicButtonUi()
                         isCancelled?.let {
                             onVoiceRecordingEnded(it)
@@ -349,8 +345,7 @@ class VoiceMessageViews(
 
     interface Actions {
         fun onRequestRecording()
-        fun onRecordingStopped()
-        fun isActive(): Boolean
+        fun onMicButtonReleased()
         fun updateState(updater: (RecordingUiState) -> RecordingUiState)
         fun sendMessage()
         fun delete()
diff --git a/vector/src/main/res/layout/view_voice_message_recorder.xml b/vector/src/main/res/layout/view_voice_message_recorder.xml
index 051928b73d..81d9c64e33 100644
--- a/vector/src/main/res/layout/view_voice_message_recorder.xml
+++ b/vector/src/main/res/layout/view_voice_message_recorder.xml
@@ -95,6 +95,7 @@
 
     <!-- Slide to cancel text should go under this view -->
     <View
+        android:id="@+id/voiceMessageSlideToCancelDivider"
         android:layout_width="48dp"
         android:layout_height="0dp"
         android:background="?android:colorBackground"

From be685bc56ae339216bcf5e26760360b8fe1721e8 Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Thu, 18 Nov 2021 14:59:06 +0000
Subject: [PATCH 07/17] aligning the locked recording view to the send message
 button without the margin, fixes the layout jumping when the mic button
 switches to a send

---
 vector/src/main/res/layout/view_voice_message_recorder.xml | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/vector/src/main/res/layout/view_voice_message_recorder.xml b/vector/src/main/res/layout/view_voice_message_recorder.xml
index 81d9c64e33..53be4f07f6 100644
--- a/vector/src/main/res/layout/view_voice_message_recorder.xml
+++ b/vector/src/main/res/layout/view_voice_message_recorder.xml
@@ -136,11 +136,10 @@
         android:id="@+id/voiceMessagePlaybackLayout"
         android:layout_width="0dp"
         android:layout_height="44dp"
-        android:layout_marginEnd="16dp"
         android:layout_marginBottom="4dp"
         android:visibility="gone"
         app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toStartOf="@id/voiceMessageMicButton"
+        app:layout_constraintEnd_toStartOf="@id/voiceMessageSendButton"
         app:layout_constraintStart_toStartOf="parent"
         tools:layout_marginBottom="120dp"
         tools:visibility="visible">

From dfc67b832ca1de0949c687313ad10a154678f940 Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Thu, 18 Nov 2021 15:06:43 +0000
Subject: [PATCH 08/17] updating the state rather than calling display directly

---
 .../vector/app/features/home/room/detail/RoomDetailFragment.kt  | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
index 271bb9b775..befca5cd7d 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
@@ -506,7 +506,7 @@ class RoomDetailFragment @Inject constructor(
 
     private fun onCannotRecord() {
         // Update the UI, cancel the animation
-        views.voiceMessageRecorderView.display(RecordingUiState.None)
+        textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingUiStateChanged(RecordingUiState.None))
     }
 
     private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) {

From bf374371b8c1cb289c2e511f82cfe8b009e0ef81 Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Thu, 18 Nov 2021 15:14:46 +0000
Subject: [PATCH 09/17] removing no longer needed cancelled status check

---
 .../voice/VoiceMessageRecorderView.kt         |  6 ++----
 .../composer/voice/VoiceMessageViews.kt       | 19 +++++++------------
 2 files changed, 9 insertions(+), 16 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
index cd784dd9b6..7f6f9505f0 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
@@ -139,15 +139,13 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
             }
             RecordingUiState.Cancelled -> {
                 stopRecordingTicker()
-                voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback.deleteVoiceMessage() }
+                voiceMessageViews.hideRecordingViews(recordingState) { callback.deleteVoiceMessage() }
                 vibrate(context)
             }
             RecordingUiState.Locked    -> {
                 voiceMessageViews.renderLocked()
                 postDelayed({
-                    voiceMessageViews.showRecordingLockedViews(recordingState) {
-                        // do nothing
-                    }
+                    voiceMessageViews.showRecordingLockedViews(recordingState)
                 }, 500)
             }
             RecordingUiState.Playback  -> {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
index 0b2696931c..12a32405b2 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
@@ -34,7 +34,6 @@ import im.vector.app.core.utils.DimensionConverter
 import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
 import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState
 import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
-import org.matrix.android.sdk.api.extensions.orFalse
 
 class VoiceMessageViews(
         private val resources: Resources,
@@ -152,9 +151,9 @@ class VoiceMessageViews(
         views.voiceMessageSendButton.isVisible = false
     }
 
-    fun hideRecordingViews(recordingState: RecordingUiState, isCancelled: Boolean?, onVoiceRecordingEnded: (Boolean) -> Unit) {
+    fun hideRecordingViews(recordingState: RecordingUiState, onVoiceRecordingEnded: () -> Unit = {}) {
         // We need to animate the lock image first
-        if (recordingState != RecordingUiState.Locked || isCancelled.orFalse()) {
+        if (recordingState != RecordingUiState.Locked) {
             views.voiceMessageLockImage.isVisible = false
             views.voiceMessageLockImage.animate().translationY(0f).start()
             views.voiceMessageLockBackground.isVisible = false
@@ -181,9 +180,7 @@ class VoiceMessageViews(
                     .setDuration(150)
                     .withEndAction {
                         resetMicButtonUi()
-                        isCancelled?.let {
-                            onVoiceRecordingEnded(it)
-                        }
+                        onVoiceRecordingEnded()
                     }
                     .start()
         } else {
@@ -195,9 +192,7 @@ class VoiceMessageViews(
                 translationX = 0f
                 translationY = 0f
             }
-            isCancelled?.let {
-                onVoiceRecordingEnded(it)
-            }
+            onVoiceRecordingEnded()
         }
 
         // Hide toasts if user cancelled recording before the timeout of the toast.
@@ -264,8 +259,8 @@ class VoiceMessageViews(
         views.voiceMessageToast.isVisible = false
     }
 
-    fun showRecordingLockedViews(recordingState: RecordingUiState, onVoiceRecordingEnded: (Boolean) -> Unit) {
-        hideRecordingViews(recordingState, null, onVoiceRecordingEnded)
+    fun showRecordingLockedViews(recordingState: RecordingUiState) {
+        hideRecordingViews(recordingState)
         views.voiceMessagePlaybackLayout.isVisible = true
         views.voiceMessagePlaybackTimerIndicator.isVisible = true
         views.voicePlaybackControlButton.isVisible = false
@@ -281,7 +276,7 @@ class VoiceMessageViews(
     }
 
     fun initViews() {
-        hideRecordingViews(RecordingUiState.None, null, onVoiceRecordingEnded = {})
+        hideRecordingViews(RecordingUiState.None)
         views.voiceMessageMicButton.isVisible = true
         views.voiceMessageSendButton.isVisible = false
         views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }

From 734e7df910503c05636bd7b5f8ce9be2ae926d4f Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Thu, 18 Nov 2021 17:00:43 +0000
Subject: [PATCH 10/17] renaming display function as its updating state, rather
 than directly displaying

---
 .../home/room/detail/RoomDetailFragment.kt       | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
index befca5cd7d..70b95aefab 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
@@ -700,7 +700,7 @@ class RoomDetailFragment @Inject constructor(
                 if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
                     roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
                     vibrate(requireContext())
-                    display(RecordingUiState.Started)
+                    updateRecordingUiState(RecordingUiState.Started)
                 }
             }
 
@@ -716,32 +716,32 @@ class RoomDetailFragment @Inject constructor(
                 if (lastKnownState != RecordingUiState.Locked) {
                     val isCancelled = lastKnownState == RecordingUiState.Cancelled
                     roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = isCancelled))
-                    display(RecordingUiState.None)
+                    updateRecordingUiState(RecordingUiState.None)
                 }
             }
 
             override fun onUiStateChanged(state: RecordingUiState) {
-                display(state)
+                updateRecordingUiState(state)
             }
 
             override fun sendVoiceMessage() {
-                display(RecordingUiState.None)
+                updateRecordingUiState(RecordingUiState.None)
             }
 
             override fun deleteVoiceMessage() {
                 roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true))
-                display(RecordingUiState.None)
+                updateRecordingUiState(RecordingUiState.None)
             }
 
             override fun onRecordingLimitReached() {
-                display(RecordingUiState.Playback)
+                updateRecordingUiState(RecordingUiState.Playback)
             }
 
             override fun recordingWaveformClicked() {
-                display(RecordingUiState.Playback)
+                updateRecordingUiState(RecordingUiState.Playback)
             }
 
-            private fun display(state: RecordingUiState) {
+            private fun updateRecordingUiState(state: RecordingUiState) {
                 textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingUiStateChanged(state))
             }
         }

From c5746a59aecddf995a2d244bdd69e247eb8e7c5f Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Thu, 18 Nov 2021 17:11:10 +0000
Subject: [PATCH 11/17] updating voice view interface method names for
 consistency

---
 .../home/room/detail/RoomDetailFragment.kt    | 12 ++--
 .../voice/VoiceMessageRecorderView.kt         | 60 +++++++------------
 .../composer/voice/VoiceMessageViews.kt       | 16 ++---
 3 files changed, 33 insertions(+), 55 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
index 70b95aefab..60015b0fa3 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
@@ -704,10 +704,6 @@ class RoomDetailFragment @Inject constructor(
                 }
             }
 
-            override fun onVoiceRecordingPlaybackModeOn() {
-                roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
-            }
-
             override fun onVoicePlaybackButtonClicked() {
                 roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseRecordingPlayback)
             }
@@ -724,21 +720,23 @@ class RoomDetailFragment @Inject constructor(
                 updateRecordingUiState(state)
             }
 
-            override fun sendVoiceMessage() {
+            override fun onSendVoiceMessage() {
                 updateRecordingUiState(RecordingUiState.None)
             }
 
-            override fun deleteVoiceMessage() {
+            override fun onDeleteVoiceMessage() {
                 roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true))
                 updateRecordingUiState(RecordingUiState.None)
             }
 
             override fun onRecordingLimitReached() {
                 updateRecordingUiState(RecordingUiState.Playback)
+                roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
             }
 
-            override fun recordingWaveformClicked() {
+            override fun onRecordingWaveformClicked() {
                 updateRecordingUiState(RecordingUiState.Playback)
+                roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
             }
 
             private fun updateRecordingUiState(state: RecordingUiState) {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
index 7f6f9505f0..818a22107f 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
@@ -41,14 +41,13 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
 
     interface Callback {
         fun onVoiceRecordingStarted()
-        fun onVoiceRecordingPlaybackModeOn()
+        fun onVoiceRecordingEnded(lastKnownState: RecordingUiState?)
         fun onVoicePlaybackButtonClicked()
         fun onUiStateChanged(state: RecordingUiState)
-        fun sendVoiceMessage()
-        fun deleteVoiceMessage()
+        fun onSendVoiceMessage()
+        fun onDeleteVoiceMessage()
         fun onRecordingLimitReached()
-        fun recordingWaveformClicked()
-        fun onVoiceRecordingEnded(lastKnownState: RecordingUiState?)
+        fun onRecordingWaveformClicked()
     }
 
     // We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
@@ -70,25 +69,15 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
         initListeners()
     }
 
-    override fun onVisibilityChanged(changedView: View, visibility: Int) {
-        super.onVisibilityChanged(changedView, visibility)
-        // onVisibilityChanged is called by constructor on api 21 and 22.
-        if (!this::voiceMessageViews.isInitialized) return
-        val parentChanged = changedView == this
-        voiceMessageViews.renderVisibilityChanged(parentChanged, visibility)
-    }
-
     private fun initListeners() {
         voiceMessageViews.start(object : VoiceMessageViews.Actions {
-            override fun onRequestRecording() {
-                callback.onVoiceRecordingStarted()
-            }
-
-            override fun onMicButtonReleased() {
-                callback.onVoiceRecordingEnded(lastKnownState)
-            }
-
-            override fun updateState(updater: (RecordingUiState) -> RecordingUiState) {
+            override fun onRequestRecording() = callback.onVoiceRecordingStarted()
+            override fun onMicButtonReleased() = callback.onVoiceRecordingEnded(lastKnownState)
+            override fun onSendVoiceMessage() = callback.onSendVoiceMessage()
+            override fun onDeleteVoiceMessage() = callback.onDeleteVoiceMessage()
+            override fun onWaveformClicked() = callback.onRecordingWaveformClicked()
+            override fun onVoicePlaybackButtonClicked() = callback.onVoicePlaybackButtonClicked()
+            override fun onMicButtonDrag(updater: (RecordingUiState) -> RecordingUiState) {
                 when (val currentState = lastKnownState) {
                     null, RecordingUiState.None -> {
                         // ignore drag events when the view is idle
@@ -104,25 +93,17 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
                     }
                 }
             }
-
-            override fun sendMessage() {
-                callback.sendVoiceMessage()
-            }
-
-            override fun delete() {
-                callback.deleteVoiceMessage()
-            }
-
-            override fun waveformClicked() {
-                callback.recordingWaveformClicked()
-            }
-
-            override fun onVoicePlaybackButtonClicked() {
-                callback.onVoicePlaybackButtonClicked()
-            }
         })
     }
 
+    override fun onVisibilityChanged(changedView: View, visibility: Int) {
+        super.onVisibilityChanged(changedView, visibility)
+        // onVisibilityChanged is called by constructor on api 21 and 22.
+        if (!this::voiceMessageViews.isInitialized) return
+        val parentChanged = changedView == this
+        voiceMessageViews.renderVisibilityChanged(parentChanged, visibility)
+    }
+
     fun display(recordingState: RecordingUiState) {
         if (lastKnownState == recordingState) return
         val previousState = lastKnownState
@@ -139,7 +120,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
             }
             RecordingUiState.Cancelled -> {
                 stopRecordingTicker()
-                voiceMessageViews.hideRecordingViews(recordingState) { callback.deleteVoiceMessage() }
+                voiceMessageViews.hideRecordingViews(recordingState) { callback.onDeleteVoiceMessage() }
                 vibrate(context)
             }
             RecordingUiState.Locked    -> {
@@ -151,7 +132,6 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
             RecordingUiState.Playback  -> {
                 stopRecordingTicker()
                 voiceMessageViews.showPlaybackViews()
-                callback.onVoiceRecordingPlaybackModeOn()
             }
             is DraggingState           -> when (recordingState) {
                 is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(recordingState.distanceX)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
index 12a32405b2..16a12aae35 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
@@ -48,16 +48,16 @@ class VoiceMessageViews(
     fun start(actions: Actions) {
         views.voiceMessageSendButton.setOnClickListener {
             views.voiceMessageSendButton.isVisible = false
-            actions.sendMessage()
+            actions.onSendVoiceMessage()
         }
 
         views.voiceMessageDeletePlayback.setOnClickListener {
             views.voiceMessageSendButton.isVisible = false
-            actions.delete()
+            actions.onDeleteVoiceMessage()
         }
 
         views.voicePlaybackWaveform.setOnClickListener {
-            actions.waveformClicked()
+            actions.onWaveformClicked()
         }
 
         views.voicePlaybackControlButton.setOnClickListener {
@@ -81,7 +81,7 @@ class VoiceMessageViews(
                     true
                 }
                 MotionEvent.ACTION_MOVE -> {
-                    actions.updateState { currentState -> positions.process(event, currentState) }
+                    actions.onMicButtonDrag { currentState -> positions.process(event, currentState) }
                     true
                 }
                 else                    -> false
@@ -341,10 +341,10 @@ class VoiceMessageViews(
     interface Actions {
         fun onRequestRecording()
         fun onMicButtonReleased()
-        fun updateState(updater: (RecordingUiState) -> RecordingUiState)
-        fun sendMessage()
-        fun delete()
-        fun waveformClicked()
+        fun onMicButtonDrag(updater: (RecordingUiState) -> RecordingUiState)
+        fun onSendVoiceMessage()
+        fun onDeleteVoiceMessage()
+        fun onWaveformClicked()
         fun onVoicePlaybackButtonClicked()
     }
 }

From 16ca7d50402dd59a58e3b74e209685ecccf34e21 Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Thu, 18 Nov 2021 17:18:13 +0000
Subject: [PATCH 12/17] adding sending of voice message on send pressed

---
 .../app/features/home/room/detail/RoomDetailFragment.kt      | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
index 60015b0fa3..dc538bd72b 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
@@ -721,6 +721,7 @@ class RoomDetailFragment @Inject constructor(
             }
 
             override fun onSendVoiceMessage() {
+                roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = false))
                 updateRecordingUiState(RecordingUiState.None)
             }
 
@@ -730,13 +731,13 @@ class RoomDetailFragment @Inject constructor(
             }
 
             override fun onRecordingLimitReached() {
-                updateRecordingUiState(RecordingUiState.Playback)
                 roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
+                updateRecordingUiState(RecordingUiState.Playback)
             }
 
             override fun onRecordingWaveformClicked() {
-                updateRecordingUiState(RecordingUiState.Playback)
                 roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
+                updateRecordingUiState(RecordingUiState.Playback)
             }
 
             private fun updateRecordingUiState(state: RecordingUiState) {

From 4dbb150ac2dd69022975128b855cace949204cc8 Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Thu, 18 Nov 2021 17:21:03 +0000
Subject: [PATCH 13/17] clarifying why we do nothing when the state is locked
 on voice recording ended

---
 .../home/room/detail/RoomDetailFragment.kt         | 14 ++++++++++----
 1 file changed, 10 insertions(+), 4 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
index dc538bd72b..8f532413e9 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
@@ -709,10 +709,16 @@ class RoomDetailFragment @Inject constructor(
             }
 
             override fun onVoiceRecordingEnded(lastKnownState: RecordingUiState?) {
-                if (lastKnownState != RecordingUiState.Locked) {
-                    val isCancelled = lastKnownState == RecordingUiState.Cancelled
-                    roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = isCancelled))
-                    updateRecordingUiState(RecordingUiState.None)
+                when (lastKnownState) {
+                    RecordingUiState.Locked -> {
+                        // do nothing,
+                        // onSendVoiceMessage, onDeleteVoiceMessage or onRecordingLimitReached will be triggered instead
+                    }
+                    else                    -> {
+                        val isCancelled = lastKnownState == RecordingUiState.Cancelled
+                        roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = isCancelled))
+                        updateRecordingUiState(RecordingUiState.None)
+                    }
                 }
             }
 

From 1afc1b51e5709817732f48b5a17368f011b26003 Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Thu, 18 Nov 2021 17:25:30 +0000
Subject: [PATCH 14/17] separating the cancelled and ended events to make the
 consumption simpler

---
 .../home/room/detail/RoomDetailFragment.kt     | 18 ++++++------------
 .../composer/voice/VoiceMessageRecorderView.kt | 15 +++++++++++++--
 2 files changed, 19 insertions(+), 14 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
index 8f532413e9..cea9641443 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
@@ -708,18 +708,12 @@ class RoomDetailFragment @Inject constructor(
                 roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseRecordingPlayback)
             }
 
-            override fun onVoiceRecordingEnded(lastKnownState: RecordingUiState?) {
-                when (lastKnownState) {
-                    RecordingUiState.Locked -> {
-                        // do nothing,
-                        // onSendVoiceMessage, onDeleteVoiceMessage or onRecordingLimitReached will be triggered instead
-                    }
-                    else                    -> {
-                        val isCancelled = lastKnownState == RecordingUiState.Cancelled
-                        roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = isCancelled))
-                        updateRecordingUiState(RecordingUiState.None)
-                    }
-                }
+            override fun onVoiceRecordingCancelled() {
+                onDeleteVoiceMessage()
+            }
+
+            override fun onVoiceRecordingEnded() {
+                onSendVoiceMessage()
             }
 
             override fun onUiStateChanged(state: RecordingUiState) {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
index 818a22107f..178e814550 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
@@ -41,8 +41,9 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
 
     interface Callback {
         fun onVoiceRecordingStarted()
-        fun onVoiceRecordingEnded(lastKnownState: RecordingUiState?)
+        fun onVoiceRecordingEnded()
         fun onVoicePlaybackButtonClicked()
+        fun onVoiceRecordingCancelled()
         fun onUiStateChanged(state: RecordingUiState)
         fun onSendVoiceMessage()
         fun onDeleteVoiceMessage()
@@ -72,7 +73,17 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
     private fun initListeners() {
         voiceMessageViews.start(object : VoiceMessageViews.Actions {
             override fun onRequestRecording() = callback.onVoiceRecordingStarted()
-            override fun onMicButtonReleased() = callback.onVoiceRecordingEnded(lastKnownState)
+            override fun onMicButtonReleased() {
+                when (lastKnownState) {
+                    RecordingUiState.Locked    -> {
+                        // do nothing,
+                        // onSendVoiceMessage, onDeleteVoiceMessage or onRecordingLimitReached will be triggered instead
+                    }
+                    RecordingUiState.Cancelled -> callback.onVoiceRecordingCancelled()
+                    else                       -> callback.onVoiceRecordingEnded()
+                }
+            }
+
             override fun onSendVoiceMessage() = callback.onSendVoiceMessage()
             override fun onDeleteVoiceMessage() = callback.onDeleteVoiceMessage()
             override fun onWaveformClicked() = callback.onRecordingWaveformClicked()

From 7d262ebc329665613cd761bd599f2e0b9e8bac4c Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Thu, 18 Nov 2021 17:28:08 +0000
Subject: [PATCH 15/17] removing no longer needed message delete on animation
 end, we delete the file straight away

---
 .../room/detail/composer/voice/VoiceMessageRecorderView.kt    | 2 +-
 .../home/room/detail/composer/voice/VoiceMessageViews.kt      | 4 +---
 2 files changed, 2 insertions(+), 4 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
index 178e814550..c673ecfc16 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
@@ -131,7 +131,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
             }
             RecordingUiState.Cancelled -> {
                 stopRecordingTicker()
-                voiceMessageViews.hideRecordingViews(recordingState) { callback.onDeleteVoiceMessage() }
+                voiceMessageViews.hideRecordingViews(recordingState)
                 vibrate(context)
             }
             RecordingUiState.Locked    -> {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
index 16a12aae35..7aeb665486 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
@@ -151,7 +151,7 @@ class VoiceMessageViews(
         views.voiceMessageSendButton.isVisible = false
     }
 
-    fun hideRecordingViews(recordingState: RecordingUiState, onVoiceRecordingEnded: () -> Unit = {}) {
+    fun hideRecordingViews(recordingState: RecordingUiState) {
         // We need to animate the lock image first
         if (recordingState != RecordingUiState.Locked) {
             views.voiceMessageLockImage.isVisible = false
@@ -180,7 +180,6 @@ class VoiceMessageViews(
                     .setDuration(150)
                     .withEndAction {
                         resetMicButtonUi()
-                        onVoiceRecordingEnded()
                     }
                     .start()
         } else {
@@ -192,7 +191,6 @@ class VoiceMessageViews(
                 translationX = 0f
                 translationY = 0f
             }
-            onVoiceRecordingEnded()
         }
 
         // Hide toasts if user cancelled recording before the timeout of the toast.

From 331bcbfc8adebcb62de8ab5b42fc48222704924f Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Fri, 19 Nov 2021 11:31:10 +0000
Subject: [PATCH 16/17] separating the drag state from the main UI state in
 order to clarify which actions should be handled in each layer

---
 .../home/room/detail/RoomDetailFragment.kt    | 11 +--
 .../detail/composer/TextComposerViewState.kt  |  2 -
 .../composer/voice/DraggableStateProcessor.kt | 15 ++--
 .../voice/VoiceMessageRecorderView.kt         | 76 ++++++++++---------
 .../composer/voice/VoiceMessageViews.kt       |  9 ++-
 5 files changed, 59 insertions(+), 54 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
index cea9641443..84e8260a97 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt
@@ -709,17 +709,18 @@ class RoomDetailFragment @Inject constructor(
             }
 
             override fun onVoiceRecordingCancelled() {
-                onDeleteVoiceMessage()
+                roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true))
+                updateRecordingUiState(RecordingUiState.Cancelled)
+            }
+
+            override fun onVoiceRecordingLocked() {
+                updateRecordingUiState(RecordingUiState.Locked)
             }
 
             override fun onVoiceRecordingEnded() {
                 onSendVoiceMessage()
             }
 
-            override fun onUiStateChanged(state: RecordingUiState) {
-                updateRecordingUiState(state)
-            }
-
             override fun onSendVoiceMessage() {
                 roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = false))
                 updateRecordingUiState(RecordingUiState.None)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewState.kt
index 4eb70138bb..fa19d129e9 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewState.kt
@@ -54,8 +54,6 @@ data class TextComposerViewState(
         VoiceMessageRecorderView.RecordingUiState.None,
         VoiceMessageRecorderView.RecordingUiState.Cancelled,
         VoiceMessageRecorderView.RecordingUiState.Playback -> false
-        is VoiceMessageRecorderView.DraggingState.Cancelling,
-        is VoiceMessageRecorderView.DraggingState.Locking,
         VoiceMessageRecorderView.RecordingUiState.Locked,
         VoiceMessageRecorderView.RecordingUiState.Started  -> true
     }
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
index a8b19d6f6a..088070ceb9 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt
@@ -21,7 +21,6 @@ import android.view.MotionEvent
 import im.vector.app.R
 import im.vector.app.core.utils.DimensionConverter
 import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.DraggingState
-import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState
 
 class DraggableStateProcessor(
         resources: Resources,
@@ -44,37 +43,37 @@ class DraggableStateProcessor(
         lastDistanceY = 0F
     }
 
-    fun process(event: MotionEvent, recordingState: RecordingUiState): RecordingUiState {
+    fun process(event: MotionEvent, draggingState: DraggingState): DraggingState {
         val currentX = event.rawX
         val currentY = event.rawY
         val distanceX = firstX - currentX
         val distanceY = firstY - currentY
-        return recordingState.nextRecordingState(currentX, currentY, distanceX, distanceY).also {
+        return draggingState.nextDragState(currentX, currentY, distanceX, distanceY).also {
             lastDistanceX = distanceX
             lastDistanceY = distanceY
         }
     }
 
-    private fun RecordingUiState.nextRecordingState(currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): RecordingUiState {
+    private fun DraggingState.nextDragState(currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): DraggingState {
         return when (this) {
-            RecordingUiState.Started    -> {
+            DraggingState.Ready         -> {
                 when {
                     isDraggingToCancel(currentX, distanceX, distanceY) -> DraggingState.Cancelling(distanceX)
                     isDraggingToLock(currentY, distanceX, distanceY)   -> DraggingState.Locking(distanceY)
-                    else                                               -> this
+                    else                                               -> DraggingState.Ready
                 }
             }
             is DraggingState.Cancelling -> {
                 when {
                     isDraggingToLock(currentY, distanceX, distanceY) -> DraggingState.Locking(distanceY)
-                    shouldCancelRecording(distanceX)                 -> RecordingUiState.Cancelled
+                    shouldCancelRecording(distanceX)                 -> DraggingState.Cancel
                     else                                             -> DraggingState.Cancelling(distanceX)
                 }
             }
             is DraggingState.Locking    -> {
                 when {
                     isDraggingToCancel(currentX, distanceX, distanceY) -> DraggingState.Cancelling(distanceX)
-                    shouldLockRecording(distanceY)                     -> RecordingUiState.Locked
+                    shouldLockRecording(distanceY)                     -> DraggingState.Lock
                     else                                               -> DraggingState.Locking(distanceY)
                 }
             }
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
index c673ecfc16..d212e800a8 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt
@@ -44,7 +44,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
         fun onVoiceRecordingEnded()
         fun onVoicePlaybackButtonClicked()
         fun onVoiceRecordingCancelled()
-        fun onUiStateChanged(state: RecordingUiState)
+        fun onVoiceRecordingLocked()
         fun onSendVoiceMessage()
         fun onDeleteVoiceMessage()
         fun onRecordingLimitReached()
@@ -58,6 +58,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
 
     private var recordingTicker: CountUpTimer? = null
     private var lastKnownState: RecordingUiState? = null
+    private var dragState: DraggingState = DraggingState.Ignored
 
     init {
         inflate(this.context, R.layout.view_voice_message_recorder, this)
@@ -74,13 +75,13 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
         voiceMessageViews.start(object : VoiceMessageViews.Actions {
             override fun onRequestRecording() = callback.onVoiceRecordingStarted()
             override fun onMicButtonReleased() {
-                when (lastKnownState) {
-                    RecordingUiState.Locked    -> {
+                when (dragState) {
+                    DraggingState.Lock   -> {
                         // do nothing,
                         // onSendVoiceMessage, onDeleteVoiceMessage or onRecordingLimitReached will be triggered instead
                     }
-                    RecordingUiState.Cancelled -> callback.onVoiceRecordingCancelled()
-                    else                       -> callback.onVoiceRecordingEnded()
+                    DraggingState.Cancel -> callback.onVoiceRecordingCancelled()
+                    else                 -> callback.onVoiceRecordingEnded()
                 }
             }
 
@@ -88,21 +89,8 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
             override fun onDeleteVoiceMessage() = callback.onDeleteVoiceMessage()
             override fun onWaveformClicked() = callback.onRecordingWaveformClicked()
             override fun onVoicePlaybackButtonClicked() = callback.onVoicePlaybackButtonClicked()
-            override fun onMicButtonDrag(updater: (RecordingUiState) -> RecordingUiState) {
-                when (val currentState = lastKnownState) {
-                    null, RecordingUiState.None -> {
-                        // ignore drag events when the view is idle
-                    }
-                    else                        -> {
-                        updater(currentState).also { newState ->
-                            when (newState) {
-                                // display drag events directly without leaving the view for faster UI feedback
-                                is DraggingState -> display(newState)
-                                else             -> callback.onUiStateChanged(newState)
-                            }
-                        }
-                    }
-                }
+            override fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState) {
+                onDrag(dragState, newDragState = nextDragStateCreator(dragState))
             }
         })
     }
@@ -117,21 +105,19 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
 
     fun display(recordingState: RecordingUiState) {
         if (lastKnownState == recordingState) return
-        val previousState = lastKnownState
         lastKnownState = recordingState
         when (recordingState) {
             RecordingUiState.None      -> {
-                stopRecordingTicker()
-                voiceMessageViews.initViews()
+                reset()
             }
             RecordingUiState.Started   -> {
                 startRecordingTicker()
                 voiceMessageViews.renderToast(context.getString(R.string.voice_message_release_to_send_toast))
                 voiceMessageViews.showRecordingViews()
+                dragState = DraggingState.Ready
             }
             RecordingUiState.Cancelled -> {
-                stopRecordingTicker()
-                voiceMessageViews.hideRecordingViews(recordingState)
+                reset()
                 vibrate(context)
             }
             RecordingUiState.Locked    -> {
@@ -144,18 +130,34 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
                 stopRecordingTicker()
                 voiceMessageViews.showPlaybackViews()
             }
-            is DraggingState           -> when (recordingState) {
-                is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(recordingState.distanceX)
-                is DraggingState.Locking    -> {
-                    if (previousState is DraggingState.Cancelling) {
-                        voiceMessageViews.showRecordingViews()
-                    }
-                    voiceMessageViews.renderLocking(recordingState.distanceY)
-                }
-            }.exhaustive
         }
     }
 
+    private fun reset() {
+        stopRecordingTicker()
+        voiceMessageViews.initViews()
+        dragState = DraggingState.Ignored
+    }
+
+    private fun onDrag(currentDragState: DraggingState, newDragState: DraggingState) {
+        when (newDragState) {
+            is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(newDragState.distanceX)
+            is DraggingState.Locking    -> {
+                if (currentDragState is DraggingState.Cancelling) {
+                    voiceMessageViews.showRecordingViews()
+                }
+                voiceMessageViews.renderLocking(newDragState.distanceY)
+            }
+            DraggingState.Cancel        -> callback.onVoiceRecordingCancelled()
+            DraggingState.Lock          -> callback.onVoiceRecordingLocked()
+            DraggingState.Ignored,
+            DraggingState.Ready         -> {
+                // do nothing
+            }
+        }.exhaustive
+        dragState = newDragState
+    }
+
     private fun startRecordingTicker() {
         recordingTicker?.stop()
         recordingTicker = CountUpTimer().apply {
@@ -214,8 +216,12 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
         object Playback : RecordingUiState
     }
 
-    sealed interface DraggingState : RecordingUiState {
+    sealed interface DraggingState {
+        object Ready : DraggingState
+        object Ignored : DraggingState
         data class Cancelling(val distanceX: Float) : DraggingState
         data class Locking(val distanceY: Float) : DraggingState
+        object Cancel : DraggingState
+        object Lock : DraggingState
     }
 }
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
index 7aeb665486..32f21a3177 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
@@ -32,6 +32,7 @@ import im.vector.app.core.extensions.setAttributeTintedBackground
 import im.vector.app.core.extensions.setAttributeTintedImageResource
 import im.vector.app.core.utils.DimensionConverter
 import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
+import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.DraggingState
 import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState
 import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
 
@@ -68,11 +69,11 @@ class VoiceMessageViews(
 
     @SuppressLint("ClickableViewAccessibility")
     private fun observeMicButton(actions: Actions) {
-        val positions = DraggableStateProcessor(resources, dimensionConverter)
+        val draggableStateProcessor = DraggableStateProcessor(resources, dimensionConverter)
         views.voiceMessageMicButton.setOnTouchListener { _, event ->
             when (event.action) {
                 MotionEvent.ACTION_DOWN -> {
-                    positions.initialize(event)
+                    draggableStateProcessor.initialize(event)
                     actions.onRequestRecording()
                     true
                 }
@@ -81,7 +82,7 @@ class VoiceMessageViews(
                     true
                 }
                 MotionEvent.ACTION_MOVE -> {
-                    actions.onMicButtonDrag { currentState -> positions.process(event, currentState) }
+                    actions.onMicButtonDrag { currentState -> draggableStateProcessor.process(event, currentState) }
                     true
                 }
                 else                    -> false
@@ -339,7 +340,7 @@ class VoiceMessageViews(
     interface Actions {
         fun onRequestRecording()
         fun onMicButtonReleased()
-        fun onMicButtonDrag(updater: (RecordingUiState) -> RecordingUiState)
+        fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState)
         fun onSendVoiceMessage()
         fun onDeleteVoiceMessage()
         fun onWaveformClicked()

From 7d0d105e823b97af6dcdaa823cc07a2ee5dbd490 Mon Sep 17 00:00:00 2001
From: Adam Brown <adampsbrown@gmail.com>
Date: Fri, 19 Nov 2021 14:53:20 +0000
Subject: [PATCH 17/17] adding changelog entry

---
 changelog.d/4515.misc | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 changelog.d/4515.misc

diff --git a/changelog.d/4515.misc b/changelog.d/4515.misc
new file mode 100644
index 0000000000..f47ace25d4
--- /dev/null
+++ b/changelog.d/4515.misc
@@ -0,0 +1 @@
+Voice recording mic button refactor with small animation tweaks in preparation for voice drafts
\ No newline at end of file