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 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 dce4c9a61b..3fcdefaf56 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,8 @@ 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.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 @@ -505,7 +506,7 @@ class RoomDetailFragment @Inject constructor( private fun onCannotRecord() { // Update the UI, cancel the animation - views.voiceMessageRecorderView.initVoiceRecordingViews() + textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingUiStateChanged(RecordingUiState.None)) } private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) { @@ -692,33 +693,57 @@ 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)) { + + 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 + updateRecordingUiState(RecordingUiState.Started) } } - override fun onVoiceRecordingEnded(isCancelled: Boolean) { - roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled)) - textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(false)) - } - - override fun onVoiceRecordingPlaybackModeOn() { - roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage) - } - override fun onVoicePlaybackButtonClicked() { roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseRecordingPlayback) } + + override fun onVoiceRecordingCancelled() { + roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true)) + updateRecordingUiState(RecordingUiState.Cancelled) + } + + override fun onVoiceRecordingLocked() { + updateRecordingUiState(RecordingUiState.Locked) + } + + override fun onVoiceRecordingEnded() { + onSendVoiceMessage() + } + + override fun onSendVoiceMessage() { + roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = false)) + updateRecordingUiState(RecordingUiState.None) + } + + override fun onDeleteVoiceMessage() { + roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true)) + updateRecordingUiState(RecordingUiState.None) + } + + override fun onRecordingLimitReached() { + roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage) + updateRecordingUiState(RecordingUiState.Playback) + } + + override fun onRecordingWaveformClicked() { + roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage) + updateRecordingUiState(RecordingUiState.Playback) + } + + private fun updateRecordingUiState(state: RecordingUiState) { + textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingUiStateChanged(state)) + } } } @@ -1112,7 +1137,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 { @@ -1408,6 +1433,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) { @@ -1962,7 +1988,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) @@ -1972,7 +1998,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/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..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 @@ -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,13 +45,27 @@ 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 + VoiceMessageRecorderView.RecordingUiState.Locked, + 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/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/DraggableStateProcessor.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt new file mode 100644 index 0000000000..088070ceb9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/DraggableStateProcessor.kt @@ -0,0 +1,101 @@ +/* + * 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 + +class DraggableStateProcessor( + resources: Resources, + 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) + + private var firstX: Float = 0f + private var firstY: Float = 0f + private var lastDistanceX: Float = 0f + private var lastDistanceY: Float = 0f + + fun initialize(event: MotionEvent) { + firstX = event.rawX + firstY = event.rawY + lastDistanceX = 0F + lastDistanceY = 0F + } + + fun process(event: MotionEvent, draggingState: DraggingState): DraggingState { + val currentX = event.rawX + val currentY = event.rawY + val distanceX = firstX - currentX + val distanceY = firstY - currentY + return draggingState.nextDragState(currentX, currentY, distanceX, distanceY).also { + lastDistanceX = distanceX + lastDistanceY = distanceY + } + } + + private fun DraggingState.nextDragState(currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): DraggingState { + return when (this) { + DraggingState.Ready -> { + when { + isDraggingToCancel(currentX, distanceX, distanceY) -> DraggingState.Cancelling(distanceX) + isDraggingToLock(currentY, distanceX, distanceY) -> DraggingState.Locking(distanceY) + else -> DraggingState.Ready + } + } + is DraggingState.Cancelling -> { + when { + isDraggingToLock(currentY, distanceX, distanceY) -> DraggingState.Locking(distanceY) + shouldCancelRecording(distanceX) -> DraggingState.Cancel + else -> DraggingState.Cancelling(distanceX) + } + } + is DraggingState.Locking -> { + when { + isDraggingToCancel(currentX, distanceX, distanceY) -> DraggingState.Cancelling(distanceX) + shouldLockRecording(distanceY) -> DraggingState.Lock + else -> DraggingState.Locking(distanceY) + } + } + else -> { + this + } + } + } + + private fun isDraggingToLock(currentY: Float, distanceX: Float, distanceY: Float) = (currentY < firstY) && + distanceY > distanceX && distanceY > lastDistanceY + + 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 + } + + 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..d212e800a8 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt @@ -0,0 +1,227 @@ +/* + * 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 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 { + fun onVoiceRecordingStarted() + fun onVoiceRecordingEnded() + fun onVoicePlaybackButtonClicked() + fun onVoiceRecordingCancelled() + fun onVoiceRecordingLocked() + fun onSendVoiceMessage() + fun onDeleteVoiceMessage() + fun onRecordingLimitReached() + 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. + @Suppress("UNNECESSARY_LATEINIT") + private lateinit var voiceMessageViews: VoiceMessageViews + lateinit var callback: Callback + + 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) + val dimensionConverter = DimensionConverter(this.context.resources) + voiceMessageViews = VoiceMessageViews( + this.context.resources, + ViewVoiceMessageRecorderBinding.bind(this), + dimensionConverter + ) + initListeners() + } + + private fun initListeners() { + voiceMessageViews.start(object : VoiceMessageViews.Actions { + override fun onRequestRecording() = callback.onVoiceRecordingStarted() + override fun onMicButtonReleased() { + when (dragState) { + DraggingState.Lock -> { + // do nothing, + // onSendVoiceMessage, onDeleteVoiceMessage or onRecordingLimitReached will be triggered instead + } + DraggingState.Cancel -> callback.onVoiceRecordingCancelled() + else -> callback.onVoiceRecordingEnded() + } + } + + override fun onSendVoiceMessage() = callback.onSendVoiceMessage() + override fun onDeleteVoiceMessage() = callback.onDeleteVoiceMessage() + override fun onWaveformClicked() = callback.onRecordingWaveformClicked() + override fun onVoicePlaybackButtonClicked() = callback.onVoicePlaybackButtonClicked() + override fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState) { + onDrag(dragState, newDragState = nextDragStateCreator(dragState)) + } + }) + } + + 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 + lastKnownState = recordingState + when (recordingState) { + RecordingUiState.None -> { + reset() + } + RecordingUiState.Started -> { + startRecordingTicker() + voiceMessageViews.renderToast(context.getString(R.string.voice_message_release_to_send_toast)) + voiceMessageViews.showRecordingViews() + dragState = DraggingState.Ready + } + RecordingUiState.Cancelled -> { + reset() + vibrate(context) + } + RecordingUiState.Locked -> { + voiceMessageViews.renderLocked() + postDelayed({ + voiceMessageViews.showRecordingLockedViews(recordingState) + }, 500) + } + RecordingUiState.Playback -> { + stopRecordingTicker() + voiceMessageViews.showPlaybackViews() + } + } + } + + 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 { + tickListener = object : CountUpTimer.TickListener { + override fun onTick(milliseconds: Long) { + onRecordingTick(milliseconds) + } + } + resume() + } + onRecordingTick(0L) + } + + private fun onRecordingTick(milliseconds: Long) { + val currentState = lastKnownState ?: return + voiceMessageViews.renderRecordingTimer(currentState, milliseconds / 1_000) + val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds + if (timeDiffToRecordingLimit <= 0) { + post { + callback.onRecordingLimitReached() + } + } else if (timeDiffToRecordingLimit in 10_000..10_999) { + post { + val secondsRemaining = floor(timeDiffToRecordingLimit / 1000f).toInt() + voiceMessageViews.renderToast(context.getString(R.string.voice_message_n_seconds_warning_toast, secondsRemaining)) + vibrate(context) + } + } + } + + private fun stopRecordingTicker() { + recordingTicker?.stop() + recordingTicker = null + } + + 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 RecordingUiState { + object None : RecordingUiState + object Started : RecordingUiState + object Cancelled : RecordingUiState + object Locked : RecordingUiState + object Playback : 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 new file mode 100644 index 0000000000..32f21a3177 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt @@ -0,0 +1,349 @@ +/* + * 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.DraggingState +import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState +import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker + +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.onSendVoiceMessage() + } + + views.voiceMessageDeletePlayback.setOnClickListener { + views.voiceMessageSendButton.isVisible = false + actions.onDeleteVoiceMessage() + } + + views.voicePlaybackWaveform.setOnClickListener { + actions.onWaveformClicked() + } + + views.voicePlaybackControlButton.setOnClickListener { + actions.onVoicePlaybackButtonClicked() + } + observeMicButton(actions) + } + + @SuppressLint("ClickableViewAccessibility") + private fun observeMicButton(actions: Actions) { + val draggableStateProcessor = DraggableStateProcessor(resources, dimensionConverter) + views.voiceMessageMicButton.setOnTouchListener { _, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + draggableStateProcessor.initialize(event) + actions.onRequestRecording() + true + } + MotionEvent.ACTION_UP -> { + actions.onMicButtonReleased() + true + } + MotionEvent.ACTION_MOVE -> { + actions.onMicButtonDrag { currentState -> draggableStateProcessor.process(event, currentState) } + true + } + 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 + views.voiceMessageSlideToCancelDivider.isVisible = true + // 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: RecordingUiState) { + // We need to animate the lock image first + if (recordingState != RecordingUiState.Locked) { + views.voiceMessageLockImage.isVisible = false + views.voiceMessageLockImage.animate().translationY(0f).start() + views.voiceMessageLockBackground.isVisible = false + views.voiceMessageLockBackground.animate().translationY(0f).start() + } 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 + .animate() + .scaleX(1f) + .scaleY(1f) + .translationX(0f) + .translationY(0f) + .setDuration(150) + .withEndAction { + resetMicButtonUi() + } + .start() + } else { + views.voiceMessageTimerIndicator.isVisible = false + views.voiceMessageTimer.isVisible = false + views.voiceMessageMicButton.apply { + scaleX = 1f + scaleY = 1f + translationX = 0f + translationY = 0f + } + } + + // Hide toasts if user cancelled recording before the timeout of the toast. + if (recordingState == RecordingUiState.Cancelled || recordingState == RecordingUiState.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: RecordingUiState) { + hideRecordingViews(recordingState) + 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() { + hideRecordingViews(RecordingUiState.None) + 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: RecordingUiState, recordingTimeMillis: Long) { + val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis) + if (recordingState == RecordingUiState.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 onMicButtonReleased() + fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState) + fun onSendVoiceMessage() + fun onDeleteVoiceMessage() + fun onWaveformClicked() + 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" /> -