lifting current recording state out of the view

This commit is contained in:
Adam Brown 2021-11-11 15:50:00 +00:00
parent f2690552a2
commit 40d762c37d
4 changed files with 49 additions and 600 deletions

View File

@ -506,7 +506,7 @@ class RoomDetailFragment @Inject constructor(
private fun onCannotRecord() { private fun onCannotRecord() {
// Update the UI, cancel the animation // Update the UI, cancel the animation
views.voiceMessageRecorderView.initVoiceRecordingViews() views.voiceMessageRecorderView.display(RecordingUiState.None)
} }
private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) { private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) {
@ -698,12 +698,16 @@ class RoomDetailFragment @Inject constructor(
private var currentUiState: RecordingUiState = RecordingUiState.None private var currentUiState: RecordingUiState = RecordingUiState.None
init {
display(currentUiState)
}
override fun onVoiceRecordingStarted() { override fun onVoiceRecordingStarted() {
if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) { if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage) roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(true)) textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(true))
vibrate(requireContext()) vibrate(requireContext())
views.voiceMessageRecorderView.display(RecordingUiState.Started) display(RecordingUiState.Started)
} }
} }
@ -721,27 +725,39 @@ class RoomDetailFragment @Inject constructor(
} }
override fun onRecordingStopped() { override fun onRecordingStopped() {
if (currentUiState != RecordingUiState.Locked && currentUiState != RecordingUiState.None) { if (currentUiState != RecordingUiState.Locked) {
views.voiceMessageRecorderView.display(RecordingUiState.None) display(RecordingUiState.None)
} }
} }
override fun onUiStateChanged(state: RecordingUiState) { override fun onUiStateChanged(state: RecordingUiState) {
currentUiState = state display(state)
views.voiceMessageRecorderView.display(state)
} }
override fun sendVoiceMessage() { override fun sendVoiceMessage() {
views.voiceMessageRecorderView.display(RecordingUiState.None) display(RecordingUiState.None)
} }
override fun deleteVoiceMessage() { override fun deleteVoiceMessage() {
views.voiceMessageRecorderView.display(RecordingUiState.None) display(RecordingUiState.Cancelled)
} }
override fun onRecordingLimitReached() { override fun onRecordingLimitReached() {
views.voiceMessageRecorderView.display(RecordingUiState.Playback) display(RecordingUiState.Playback)
} }
override fun recordingWaveformClicked() {
display(RecordingUiState.Playback)
}
private fun display(state: RecordingUiState) {
if (currentUiState != state) {
views.voiceMessageRecorderView.display(state)
}
currentUiState = state
}
override fun currentState() = currentUiState
} }
} }
@ -1132,7 +1148,7 @@ class RoomDetailFragment @Inject constructor(
// We should improve the UX to support going into playback mode when paused and delete the media when the view is destroyed. // 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)) roomDetailViewModel.handle(RoomDetailAction.EndAllVoiceActions(deleteRecord = false))
views.voiceMessageRecorderView.initVoiceRecordingViews() views.voiceMessageRecorderView.display(RecordingUiState.None)
} }
private val attachmentFileActivityResultLauncher = registerStartForActivityResult { private val attachmentFileActivityResultLauncher = registerStartForActivityResult {

View File

@ -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<Int>) {
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<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
}
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<MarginLayoutParams> {
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)
}
}
}
}

View File

@ -49,15 +49,15 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
fun sendVoiceMessage() fun sendVoiceMessage()
fun deleteVoiceMessage() fun deleteVoiceMessage()
fun onRecordingLimitReached() fun onRecordingLimitReached()
fun recordingWaveformClicked()
fun currentState(): RecordingUiState
} }
// We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22. // 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") @Suppress("UNNECESSARY_LATEINIT")
private lateinit var voiceMessageViews: VoiceMessageViews private lateinit var voiceMessageViews: VoiceMessageViews
lateinit var callback: Callback
var callback: Callback? = null
private var currentUiState: RecordingUiState = RecordingUiState.None
private var recordingTicker: CountUpTimer? = null private var recordingTicker: CountUpTimer? = null
init { init {
@ -68,7 +68,6 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
ViewVoiceMessageRecorderBinding.bind(this), ViewVoiceMessageRecorderBinding.bind(this),
dimensionConverter dimensionConverter
) )
initVoiceRecordingViews()
initListeners() initListeners()
} }
@ -80,65 +79,49 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
voiceMessageViews.renderVisibilityChanged(parentChanged, visibility) voiceMessageViews.renderVisibilityChanged(parentChanged, visibility)
} }
fun initVoiceRecordingViews() {
stopRecordingTicker()
voiceMessageViews.initViews(onVoiceRecordingEnded = {})
}
private fun initListeners() { private fun initListeners() {
voiceMessageViews.start(object : VoiceMessageViews.Actions { voiceMessageViews.start(object : VoiceMessageViews.Actions {
override fun onRequestRecording() { override fun onRequestRecording() {
callback?.onVoiceRecordingStarted() callback.onVoiceRecordingStarted()
} }
override fun onRecordingStopped() { override fun onRecordingStopped() {
callback?.onRecordingStopped() callback.onRecordingStopped()
} }
override fun isActive() = currentUiState != RecordingUiState.Cancelled override fun isActive() = callback.currentState() != RecordingUiState.Cancelled
override fun updateState(updater: (RecordingUiState) -> RecordingUiState) { override fun updateState(updater: (RecordingUiState) -> RecordingUiState) {
updater(currentUiState).also { newState -> updater(callback.currentState()).also { newState ->
when (newState) { callback.onUiStateChanged(newState)
is DraggingState -> display(newState)
else -> {
if (newState != currentUiState) {
callback?.onUiStateChanged(newState)
}
}
}
} }
} }
override fun sendMessage() { override fun sendMessage() {
callback?.sendVoiceMessage() callback.sendVoiceMessage()
} }
override fun delete() { override fun delete() {
// this was previously marked as cancelled true // this was previously marked as cancelled true
callback?.deleteVoiceMessage() callback.deleteVoiceMessage()
} }
override fun waveformClicked() { override fun waveformClicked() {
display(RecordingUiState.Playback) callback.recordingWaveformClicked()
} }
override fun onVoicePlaybackButtonClicked() { override fun onVoicePlaybackButtonClicked() {
callback?.onVoicePlaybackButtonClicked() callback.onVoicePlaybackButtonClicked()
} }
}) })
} }
fun display(recordingState: RecordingUiState) { fun display(recordingState: RecordingUiState) {
if (recordingState == this.currentUiState) return
val previousState = this.currentUiState
this.currentUiState = recordingState
when (recordingState) { when (recordingState) {
RecordingUiState.None -> { RecordingUiState.None -> {
val isCancelled = previousState == RecordingUiState.Cancelled
voiceMessageViews.hideRecordingViews(recordingState, isCancelled = isCancelled) { callback?.onVoiceRecordingEnded(it) }
stopRecordingTicker() stopRecordingTicker()
voiceMessageViews.initViews()
callback.onVoiceRecordingEnded(false)
} }
RecordingUiState.Started -> { RecordingUiState.Started -> {
startRecordingTicker() startRecordingTicker()
@ -146,19 +129,20 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
voiceMessageViews.showRecordingViews() voiceMessageViews.showRecordingViews()
} }
RecordingUiState.Cancelled -> { RecordingUiState.Cancelled -> {
voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback?.onVoiceRecordingEnded(it) } stopRecordingTicker()
voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback.onVoiceRecordingEnded(it) }
vibrate(context) vibrate(context)
} }
RecordingUiState.Locked -> { RecordingUiState.Locked -> {
voiceMessageViews.renderLocked() voiceMessageViews.renderLocked()
postDelayed({ postDelayed({
voiceMessageViews.showRecordingLockedViews(recordingState) { callback?.onVoiceRecordingEnded(it) } voiceMessageViews.showRecordingLockedViews(recordingState) { callback.onVoiceRecordingEnded(it) }
}, 500) }, 500)
} }
RecordingUiState.Playback -> { RecordingUiState.Playback -> {
stopRecordingTicker() stopRecordingTicker()
voiceMessageViews.showPlaybackViews() voiceMessageViews.showPlaybackViews()
callback?.onVoiceRecordingPlaybackModeOn() callback.onVoiceRecordingPlaybackModeOn()
} }
is DraggingState -> when (recordingState) { is DraggingState -> when (recordingState) {
is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(recordingState.distanceX) is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(recordingState.distanceX)
@ -181,11 +165,11 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
} }
private fun onRecordingTick(milliseconds: Long) { private fun onRecordingTick(milliseconds: Long) {
voiceMessageViews.renderRecordingTimer(currentUiState, milliseconds / 1_000) voiceMessageViews.renderRecordingTimer(callback.currentState(), milliseconds / 1_000)
val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
if (timeDiffToRecordingLimit <= 0) { if (timeDiffToRecordingLimit <= 0) {
post { post {
callback?.onRecordingLimitReached() callback.onRecordingLimitReached()
} }
} else if (timeDiffToRecordingLimit in 10_000..10_999) { } else if (timeDiffToRecordingLimit in 10_000..10_999) {
post { post {
@ -203,7 +187,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
/** /**
* Returns true if the voice message is recording or is in playback mode * Returns true if the voice message is recording or is in playback mode
*/ */
fun isActive() = currentUiState !in listOf(RecordingUiState.None, RecordingUiState.Cancelled) fun isActive() = callback.currentState() !in listOf(RecordingUiState.None, RecordingUiState.Cancelled)
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) { override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
when (state) { when (state) {

View File

@ -282,8 +282,8 @@ class VoiceMessageViews(
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
} }
fun initViews(onVoiceRecordingEnded: (Boolean) -> Unit) { fun initViews() {
hideRecordingViews(RecordingUiState.None, null, onVoiceRecordingEnded) hideRecordingViews(RecordingUiState.None, null, onVoiceRecordingEnded = {})
views.voiceMessageMicButton.isVisible = true views.voiceMessageMicButton.isVisible = true
views.voiceMessageSendButton.isVisible = false views.voiceMessageSendButton.isVisible = false
views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() } views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }