allows locking and cancelling to occur after choosing either option

- fixes other quirks caused by porting to the inverted display logic
This commit is contained in:
Adam Brown 2021-11-17 15:31:41 +00:00
parent e895dbd923
commit 9ae03b76cd
6 changed files with 69 additions and 67 deletions

View file

@ -712,9 +712,10 @@ class RoomDetailFragment @Inject constructor(
roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseRecordingPlayback) roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseRecordingPlayback)
} }
override fun onRecordingStopped() { override fun onVoiceRecordingEnded(lastKnownState: RecordingUiState?) {
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true)) if (lastKnownState != RecordingUiState.Locked) {
if (currentState() != RecordingUiState.Locked) { val isCancelled = lastKnownState == RecordingUiState.Cancelled
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = isCancelled))
display(RecordingUiState.None) display(RecordingUiState.None)
} }
} }
@ -729,7 +730,7 @@ class RoomDetailFragment @Inject constructor(
override fun deleteVoiceMessage() { override fun deleteVoiceMessage() {
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true)) roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true))
display(RecordingUiState.Cancelled) display(RecordingUiState.None)
} }
override fun onRecordingLimitReached() { override fun onRecordingLimitReached() {
@ -743,8 +744,6 @@ class RoomDetailFragment @Inject constructor(
private fun display(state: RecordingUiState) { private fun display(state: RecordingUiState) {
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingUiStateChanged(state)) 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)) roomDetailViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add))
} }
is EventSharedAction.Edit -> { is EventSharedAction.Edit -> {
if (!views.voiceMessageRecorderView.isActive()) { if (withState(textComposerViewModel) { it.isVoiceMessageIdle }) {
textComposerViewModel.handle(TextComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString())) textComposerViewModel.handle(TextComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString()))
} else { } else {
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) 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())) textComposerViewModel.handle(TextComposerAction.EnterQuoteMode(action.eventId, views.composerLayout.text.toString()))
} }
is EventSharedAction.Reply -> { is EventSharedAction.Reply -> {
if (!views.voiceMessageRecorderView.isActive()) { if (withState(textComposerViewModel) { it.isVoiceMessageIdle }) {
textComposerViewModel.handle(TextComposerAction.EnterReplyMode(action.eventId, views.composerLayout.text.toString())) textComposerViewModel.handle(TextComposerAction.EnterReplyMode(action.eventId, views.composerLayout.text.toString()))
} else { } else {
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)

View file

@ -60,8 +60,14 @@ data class TextComposerViewState(
VoiceMessageRecorderView.RecordingUiState.Started -> true VoiceMessageRecorderView.RecordingUiState.Started -> true
} }
val isVoiceMessageIdle = when (voiceRecordingUiState) {
VoiceMessageRecorderView.RecordingUiState.None, VoiceMessageRecorderView.RecordingUiState.Cancelled -> false
else -> true
}
val isComposerVisible = canSendMessage && !isVoiceRecording val isComposerVisible = canSendMessage && !isVoiceRecording
val isVoiceMessageRecorderVisible = canSendMessage && !isSendButtonVisible val isVoiceMessageRecorderVisible = canSendMessage && !isSendButtonVisible
@Suppress("UNUSED") // needed by mavericks
constructor(args: RoomDetailArgs) : this(roomId = args.roomId) constructor(args: RoomDetailArgs) : this(roomId = args.roomId)
} }

View file

@ -28,23 +28,18 @@ class DraggableStateProcessor(
dimensionConverter: DimensionConverter, dimensionConverter: DimensionConverter,
) { ) {
private val minimumMove = dimensionConverter.dpToPx(16)
private val distanceToLock = dimensionConverter.dpToPx(48).toFloat() private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat() private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
private val rtlXMultiplier = resources.getInteger(R.integer.rtl_x_multiplier) private val rtlXMultiplier = resources.getInteger(R.integer.rtl_x_multiplier)
private var firstX: Float = 0f private var firstX: Float = 0f
private var firstY: Float = 0f private var firstY: Float = 0f
private var lastX: Float = 0f
private var lastY: Float = 0f
private var lastDistanceX: Float = 0f private var lastDistanceX: Float = 0f
private var lastDistanceY: Float = 0f private var lastDistanceY: Float = 0f
fun reset(event: MotionEvent) { fun initialize(event: MotionEvent) {
firstX = event.rawX firstX = event.rawX
firstY = event.rawY firstY = event.rawY
lastX = firstX
lastY = firstY
lastDistanceX = 0F lastDistanceX = 0F
lastDistanceY = 0F lastDistanceY = 0F
} }
@ -54,49 +49,48 @@ class DraggableStateProcessor(
val currentY = event.rawY val currentY = event.rawY
val distanceX = firstX - currentX val distanceX = firstX - currentX
val distanceY = firstY - currentY val distanceY = firstY - currentY
return nextRecordingState(recordingState, currentX, currentY, distanceX, distanceY).also { return recordingState.nextRecordingState(currentX, currentY, distanceX, distanceY).also {
lastX = currentX
lastY = currentY
lastDistanceX = distanceX lastDistanceX = distanceX
lastDistanceY = distanceY lastDistanceY = distanceY
} }
} }
private fun nextRecordingState(recordingState: RecordingUiState, currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): RecordingUiState { private fun RecordingUiState.nextRecordingState(currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): RecordingUiState {
return when (recordingState) { return when (this) {
RecordingUiState.Started -> { RecordingUiState.Started -> {
// Determine if cancelling or locking for the first move action.
when { when {
(isSlidingToCancel(currentX)) && distanceX > distanceY && distanceX > lastDistanceX -> DraggingState.Cancelling(distanceX) isDraggingToCancel(currentX, distanceX, distanceY) -> DraggingState.Cancelling(distanceX)
isSlidingToLock(currentY) && distanceY > distanceX && distanceY > lastDistanceY -> DraggingState.Locking(distanceY) isDraggingToLock(currentY, distanceX, distanceY) -> DraggingState.Locking(distanceY)
else -> recordingState else -> this
} }
} }
is DraggingState.Cancelling -> { is DraggingState.Cancelling -> {
// Check if cancelling conditions met, also check if it should be initial state
when { when {
distanceX < minimumMove && distanceX < lastDistanceX -> RecordingUiState.Started isDraggingToLock(currentY, distanceX, distanceY) -> DraggingState.Locking(distanceY)
shouldCancelRecording(distanceX) -> RecordingUiState.Cancelled shouldCancelRecording(distanceX) -> RecordingUiState.Cancelled
else -> DraggingState.Cancelling(distanceX) else -> DraggingState.Cancelling(distanceX)
} }
} }
is DraggingState.Locking -> { is DraggingState.Locking -> {
// Check if locking conditions met, also check if it should be initial state
when { when {
distanceY < minimumMove && distanceY < lastDistanceY -> RecordingUiState.Started isDraggingToCancel(currentX, distanceX, distanceY) -> DraggingState.Cancelling(distanceX)
shouldLockRecording(distanceY) -> RecordingUiState.Locked shouldLockRecording(distanceY) -> RecordingUiState.Locked
else -> DraggingState.Locking(distanceY) else -> DraggingState.Locking(distanceY)
} }
} }
else -> { 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 { private fun shouldCancelRecording(distanceX: Float): Boolean {
return distanceX >= distanceToCancel return distanceX >= distanceToCancel
@ -106,4 +100,3 @@ class DraggableStateProcessor(
return distanceY >= distanceToLock return distanceY >= distanceToLock
} }
} }

View file

@ -43,13 +43,12 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
fun onVoiceRecordingStarted() fun onVoiceRecordingStarted()
fun onVoiceRecordingPlaybackModeOn() fun onVoiceRecordingPlaybackModeOn()
fun onVoicePlaybackButtonClicked() fun onVoicePlaybackButtonClicked()
fun onRecordingStopped()
fun onUiStateChanged(state: RecordingUiState) fun onUiStateChanged(state: RecordingUiState)
fun sendVoiceMessage() fun sendVoiceMessage()
fun deleteVoiceMessage() fun deleteVoiceMessage()
fun onRecordingLimitReached() fun onRecordingLimitReached()
fun recordingWaveformClicked() 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. // 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() callback.onVoiceRecordingStarted()
} }
override fun onRecordingStopped() { override fun onMicButtonReleased() {
callback.onRecordingStopped() callback.onVoiceRecordingEnded(lastKnownState)
} }
override fun isActive() = callback.currentState() != RecordingUiState.Cancelled
override fun updateState(updater: (RecordingUiState) -> RecordingUiState) { override fun updateState(updater: (RecordingUiState) -> RecordingUiState) {
updater(lastKnownState ?: RecordingUiState.None).also { newState -> when (val currentState = lastKnownState) {
when (newState) { null, RecordingUiState.None -> {
is DraggingState -> display(newState) // ignore drag events when the view is idle
else -> callback.onUiStateChanged(newState) }
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) { fun display(recordingState: RecordingUiState) {
if (lastKnownState == recordingState) return if (lastKnownState == recordingState) return
val previousState = lastKnownState
lastKnownState = recordingState lastKnownState = recordingState
when (recordingState) { when (recordingState) {
RecordingUiState.None -> { RecordingUiState.None -> {
@ -151,7 +157,12 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
} }
is DraggingState -> when (recordingState) { is DraggingState -> when (recordingState) {
is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(recordingState.distanceX) 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 }.exhaustive
} }
} }
@ -170,7 +181,8 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
} }
private fun onRecordingTick(milliseconds: Long) { 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 val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
if (timeDiffToRecordingLimit <= 0) { if (timeDiffToRecordingLimit <= 0) {
post { post {
@ -178,7 +190,8 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
} }
} else if (timeDiffToRecordingLimit in 10_000..10_999) { } else if (timeDiffToRecordingLimit in 10_000..10_999) {
post { 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) vibrate(context)
} }
} }
@ -189,11 +202,6 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
recordingTicker = null 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) { override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
when (state) { when (state) {
is VoiceMessagePlaybackTracker.Listener.State.Recording -> { is VoiceMessagePlaybackTracker.Listener.State.Recording -> {

View file

@ -19,7 +19,6 @@ package im.vector.app.features.home.room.detail.composer.voice
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.res.Resources import android.content.res.Resources
import android.text.format.DateUtils import android.text.format.DateUtils
import android.util.Log
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -74,22 +73,17 @@ class VoiceMessageViews(
views.voiceMessageMicButton.setOnTouchListener { _, event -> views.voiceMessageMicButton.setOnTouchListener { _, event ->
when (event.action) { when (event.action) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
Log.e("!!!", "event down: $event") positions.initialize(event)
positions.reset(event)
actions.onRequestRecording() actions.onRequestRecording()
true true
} }
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP -> {
actions.onRecordingStopped() actions.onMicButtonReleased()
true true
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
if (actions.isActive()) { actions.updateState { currentState -> positions.process(event, currentState) }
actions.updateState { currentState -> positions.process(event, currentState) } true
true
} else {
false
}
} }
else -> false else -> false
} }
@ -128,6 +122,7 @@ class VoiceMessageViews(
views.voiceMessageLockBackground.isVisible = false views.voiceMessageLockBackground.isVisible = false
views.voiceMessageLockImage.isVisible = false views.voiceMessageLockImage.isVisible = false
views.voiceMessageLockArrow.isVisible = false views.voiceMessageLockArrow.isVisible = false
views.voiceMessageSlideToCancelDivider.isVisible = true
// Reset Y translations // Reset Y translations
views.voiceMessageMicButton.translationY = 0F views.voiceMessageMicButton.translationY = 0F
views.voiceMessageLockArrow.translationY = 0F views.voiceMessageLockArrow.translationY = 0F
@ -167,11 +162,14 @@ class VoiceMessageViews(
} else { } else {
animateLockImageWithBackground() animateLockImageWithBackground()
} }
views.voiceMessageSlideToCancelDivider.isVisible = false
views.voiceMessageLockArrow.isVisible = false views.voiceMessageLockArrow.isVisible = false
views.voiceMessageLockArrow.animate().translationY(0f).start() views.voiceMessageLockArrow.animate().translationY(0f).start()
views.voiceMessageSlideToCancel.isVisible = false views.voiceMessageSlideToCancel.isVisible = false
views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start() views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
views.voiceMessagePlaybackLayout.isVisible = false views.voiceMessagePlaybackLayout.isVisible = false
views.voiceMessageTimerIndicator.isVisible = false
views.voiceMessageTimer.isVisible = false
if (recordingState != RecordingUiState.Locked) { if (recordingState != RecordingUiState.Locked) {
views.voiceMessageMicButton views.voiceMessageMicButton
@ -182,8 +180,6 @@ class VoiceMessageViews(
.translationY(0f) .translationY(0f)
.setDuration(150) .setDuration(150)
.withEndAction { .withEndAction {
views.voiceMessageTimerIndicator.isVisible = false
views.voiceMessageTimer.isVisible = false
resetMicButtonUi() resetMicButtonUi()
isCancelled?.let { isCancelled?.let {
onVoiceRecordingEnded(it) onVoiceRecordingEnded(it)
@ -349,8 +345,7 @@ class VoiceMessageViews(
interface Actions { interface Actions {
fun onRequestRecording() fun onRequestRecording()
fun onRecordingStopped() fun onMicButtonReleased()
fun isActive(): Boolean
fun updateState(updater: (RecordingUiState) -> RecordingUiState) fun updateState(updater: (RecordingUiState) -> RecordingUiState)
fun sendMessage() fun sendMessage()
fun delete() fun delete()

View file

@ -95,6 +95,7 @@
<!-- Slide to cancel text should go under this view --> <!-- Slide to cancel text should go under this view -->
<View <View
android:id="@+id/voiceMessageSlideToCancelDivider"
android:layout_width="48dp" android:layout_width="48dp"
android:layout_height="0dp" android:layout_height="0dp"
android:background="?android:colorBackground" android:background="?android:colorBackground"