From f0ef9e97066f5d8cbfb4d20161763b00c137a455 Mon Sep 17 00:00:00 2001 From: Adam Brown 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 { + 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 { + 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) { + 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" /> - 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 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) { - 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 { - 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 { - 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 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 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 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 @@ 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 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 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 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 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 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 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 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 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 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 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