From f0ef9e97066f5d8cbfb4d20161763b00c137a455 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 11 Nov 2021 14:15:01 +0000 Subject: [PATCH] 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" /> -