inverting and splitting the voice message view into logic and views

- creates a display entry point which will be called externally
This commit is contained in:
Adam Brown 2021-11-11 14:15:01 +00:00
parent bd0cd169c4
commit f0ef9e9706
5 changed files with 706 additions and 4 deletions

View File

@ -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)) {

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,358 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.composer.voice
import android.annotation.SuppressLint
import android.content.res.Resources
import android.text.format.DateUtils
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import im.vector.app.R
import im.vector.app.core.extensions.setAttributeBackground
import im.vector.app.core.extensions.setAttributeTintedBackground
import im.vector.app.core.extensions.setAttributeTintedImageResource
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingState
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import org.matrix.android.sdk.api.extensions.orFalse
class VoiceMessageViews(
private val resources: Resources,
private val views: ViewVoiceMessageRecorderBinding,
private val dimensionConverter: DimensionConverter,
) {
private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
private val rtlXMultiplier = resources.getInteger(R.integer.rtl_x_multiplier)
fun start(actions: Actions) {
views.voiceMessageSendButton.setOnClickListener {
views.voiceMessageSendButton.isVisible = false
actions.sendMessage()
}
views.voiceMessageDeletePlayback.setOnClickListener {
views.voiceMessageSendButton.isVisible = false
actions.delete()
}
views.voicePlaybackWaveform.setOnClickListener {
actions.waveformClicked()
}
views.voicePlaybackControlButton.setOnClickListener {
actions.onVoicePlaybackButtonClicked()
}
observeMicButton(actions)
}
@SuppressLint("ClickableViewAccessibility")
private fun observeMicButton(actions: Actions) {
val positions = DraggableStateProcessor(resources, dimensionConverter)
views.voiceMessageMicButton.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
positions.reset(event)
actions.onRequestRecording()
true
}
MotionEvent.ACTION_UP -> {
actions.onRecordingStopped()
true
}
MotionEvent.ACTION_MOVE -> {
if (actions.isActive()) {
actions.updateState { currentState -> positions.process(event, currentState) }
true
} else {
false
}
}
else -> false
}
}
}
fun renderStarted(distanceX: Float) {
val translationAmount = distanceX.coerceAtMost(distanceToCancel)
views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
}
fun renderLocked() {
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_locked)
}
fun renderLocking(distanceY: Float) {
views.voiceMessageLockImage.setAttributeTintedImageResource(R.drawable.ic_voice_message_locked, R.attr.colorPrimary)
val translationAmount = -distanceY.coerceIn(0F, distanceToLock)
views.voiceMessageMicButton.translationY = translationAmount
views.voiceMessageLockArrow.translationY = translationAmount
views.voiceMessageLockArrow.alpha = 1 - (-translationAmount / distanceToLock)
// Reset X translations
views.voiceMessageMicButton.translationX = 0F
views.voiceMessageSlideToCancel.translationX = 0F
}
fun renderCancelling(distanceX: Float) {
val translationAmount = distanceX.coerceAtMost(distanceToCancel)
views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
val reducedAlpha = (1 - translationAmount / distanceToCancel / 1.5).toFloat()
views.voiceMessageSlideToCancel.alpha = reducedAlpha
views.voiceMessageTimerIndicator.alpha = reducedAlpha
views.voiceMessageTimer.alpha = reducedAlpha
views.voiceMessageLockBackground.isVisible = false
views.voiceMessageLockImage.isVisible = false
views.voiceMessageLockArrow.isVisible = false
// Reset Y translations
views.voiceMessageMicButton.translationY = 0F
views.voiceMessageLockArrow.translationY = 0F
}
fun showRecordingViews() {
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic_recording)
views.voiceMessageMicButton.setAttributeTintedBackground(R.drawable.circle_with_halo, R.attr.colorPrimary)
views.voiceMessageMicButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
setMargins(0, 0, 0, 0)
}
views.voiceMessageMicButton.animate().scaleX(1.5f).scaleY(1.5f).setDuration(300).start()
views.voiceMessageLockBackground.isVisible = true
views.voiceMessageLockBackground.animate().setDuration(300).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
views.voiceMessageLockImage.isVisible = true
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_unlocked)
views.voiceMessageLockImage.animate().setDuration(500).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
views.voiceMessageLockArrow.isVisible = true
views.voiceMessageLockArrow.alpha = 1f
views.voiceMessageSlideToCancel.isVisible = true
views.voiceMessageTimerIndicator.isVisible = true
views.voiceMessageTimer.isVisible = true
views.voiceMessageSlideToCancel.alpha = 1f
views.voiceMessageTimerIndicator.alpha = 1f
views.voiceMessageTimer.alpha = 1f
views.voiceMessageSendButton.isVisible = false
}
fun hideRecordingViews(recordingState: RecordingState, isCancelled: Boolean?, onVoiceRecordingEnded: (Boolean) -> Unit) {
// We need to animate the lock image first
if (recordingState != RecordingState.Locked || isCancelled.orFalse()) {
views.voiceMessageLockImage.isVisible = false
views.voiceMessageLockImage.animate().translationY(0f).start()
views.voiceMessageLockBackground.isVisible = false
views.voiceMessageLockBackground.animate().translationY(0f).start()
} else {
animateLockImageWithBackground()
}
views.voiceMessageLockArrow.isVisible = false
views.voiceMessageLockArrow.animate().translationY(0f).start()
views.voiceMessageSlideToCancel.isVisible = false
views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
views.voiceMessagePlaybackLayout.isVisible = false
if (recordingState != RecordingState.Locked) {
views.voiceMessageMicButton
.animate()
.scaleX(1f)
.scaleY(1f)
.translationX(0f)
.translationY(0f)
.setDuration(150)
.withEndAction {
views.voiceMessageTimerIndicator.isVisible = false
views.voiceMessageTimer.isVisible = false
resetMicButtonUi()
isCancelled?.let {
onVoiceRecordingEnded(it)
}
}
.start()
} else {
views.voiceMessageTimerIndicator.isVisible = false
views.voiceMessageTimer.isVisible = false
views.voiceMessageMicButton.apply {
scaleX = 1f
scaleY = 1f
translationX = 0f
translationY = 0f
}
isCancelled?.let {
onVoiceRecordingEnded(it)
}
}
// Hide toasts if user cancelled recording before the timeout of the toast.
if (recordingState == RecordingState.Cancelled || recordingState == RecordingState.None) {
hideToast()
}
}
fun animateLockImageWithBackground() {
views.voiceMessageLockBackground.updateLayoutParams {
height = dimensionConverter.dpToPx(78)
}
views.voiceMessageLockBackground.apply {
animate()
.scaleX(0f)
.scaleY(0f)
.setDuration(400L)
.withEndAction {
updateLayoutParams {
height = dimensionConverter.dpToPx(180)
}
isVisible = false
scaleX = 1f
scaleY = 1f
animate().translationY(0f).start()
}
.start()
}
// Lock image animation
views.voiceMessageMicButton.isInvisible = true
views.voiceMessageLockImage.apply {
isVisible = true
animate()
.scaleX(0f)
.scaleY(0f)
.setDuration(400L)
.withEndAction {
isVisible = false
scaleX = 1f
scaleY = 1f
translationY = 0f
resetMicButtonUi()
}
.start()
}
}
fun resetMicButtonUi() {
views.voiceMessageMicButton.isVisible = true
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic)
views.voiceMessageMicButton.setAttributeBackground(android.R.attr.selectableItemBackgroundBorderless)
views.voiceMessageMicButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
if (rtlXMultiplier == -1) {
// RTL
setMargins(dimensionConverter.dpToPx(12), 0, 0, dimensionConverter.dpToPx(12))
} else {
setMargins(0, 0, dimensionConverter.dpToPx(12), dimensionConverter.dpToPx(12))
}
}
}
fun hideToast() {
views.voiceMessageToast.isVisible = false
}
fun showRecordingLockedViews(recordingState: RecordingState, onVoiceRecordingEnded: (Boolean) -> Unit) {
hideRecordingViews(recordingState, null, onVoiceRecordingEnded)
views.voiceMessagePlaybackLayout.isVisible = true
views.voiceMessagePlaybackTimerIndicator.isVisible = true
views.voicePlaybackControlButton.isVisible = false
views.voiceMessageSendButton.isVisible = true
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
renderToast(resources.getString(R.string.voice_message_tap_to_stop_toast))
}
fun showPlaybackViews() {
views.voiceMessagePlaybackTimerIndicator.isVisible = false
views.voicePlaybackControlButton.isVisible = true
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}
fun initViews(onVoiceRecordingEnded: (Boolean) -> Unit) {
hideRecordingViews(RecordingState.None, null, onVoiceRecordingEnded)
views.voiceMessageMicButton.isVisible = true
views.voiceMessageSendButton.isVisible = false
views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }
}
fun renderPlaying(state: VoiceMessagePlaybackTracker.Listener.State.Playing) {
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_pause_voice_message)
val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong())
views.voicePlaybackTime.text = formattedTimerText
}
fun renderIdle() {
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_play_voice_message)
}
fun renderToast(message: String) {
views.voiceMessageToast.removeCallbacks(hideToastRunnable)
views.voiceMessageToast.text = message
views.voiceMessageToast.isVisible = true
views.voiceMessageToast.postDelayed(hideToastRunnable, 2_000)
}
private val hideToastRunnable = Runnable {
views.voiceMessageToast.isVisible = false
}
fun renderRecordingTimer(recordingState: RecordingState, recordingTimeMillis: Long) {
val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis)
if (recordingState == RecordingState.Locked) {
views.voicePlaybackTime.apply {
post {
text = formattedTimerText
}
}
} else {
views.voiceMessageTimer.post {
views.voiceMessageTimer.text = formattedTimerText
}
}
}
fun renderRecordingWaveform(amplitudeList: Array<Int>) {
views.voicePlaybackWaveform.post {
views.voicePlaybackWaveform.apply {
amplitudeList.iterator().forEach {
update(it)
}
}
}
}
fun renderVisibilityChanged(parentChanged: Boolean, visibility: Int) {
if (parentChanged && visibility == ConstraintLayout.VISIBLE) {
views.voiceMessageMicButton.contentDescription = resources.getString(R.string.a11y_start_voice_message)
} else {
views.voiceMessageMicButton.contentDescription = ""
}
}
interface Actions {
fun onRequestRecording()
fun onRecordingStopped()
fun isActive(): Boolean
fun updateState(updater: (RecordingState) -> RecordingState)
fun sendMessage()
fun delete()
fun waveformClicked()
fun onVoicePlaybackButtonClicked()
}
}

View File

@ -212,7 +212,7 @@
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" />
<im.vector.app.features.home.room.detail.composer.VoiceMessageRecorderView
<im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
android:id="@+id/voiceMessageRecorderView"
android:layout_width="0dp"
android:layout_height="wrap_content"