lifting voice display logic out of the view and to the layer above

This commit is contained in:
Adam Brown 2021-11-11 14:50:35 +00:00
parent f0ef9e9706
commit f2690552a2
4 changed files with 114 additions and 88 deletions

View file

@ -139,6 +139,7 @@ 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.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
@ -694,15 +695,15 @@ class RoomDetailFragment @Inject constructor(
private fun setupVoiceMessageView() {
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)) {
private var currentUiState: RecordingUiState = RecordingUiState.None
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
views.voiceMessageRecorderView.display(RecordingUiState.Started)
}
}
@ -718,6 +719,29 @@ class RoomDetailFragment @Inject constructor(
override fun onVoicePlaybackButtonClicked() {
roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseRecordingPlayback)
}
override fun onRecordingStopped() {
if (currentUiState != RecordingUiState.Locked && currentUiState != RecordingUiState.None) {
views.voiceMessageRecorderView.display(RecordingUiState.None)
}
}
override fun onUiStateChanged(state: RecordingUiState) {
currentUiState = state
views.voiceMessageRecorderView.display(state)
}
override fun sendVoiceMessage() {
views.voiceMessageRecorderView.display(RecordingUiState.None)
}
override fun deleteVoiceMessage() {
views.voiceMessageRecorderView.display(RecordingUiState.None)
}
override fun onRecordingLimitReached() {
views.voiceMessageRecorderView.display(RecordingUiState.Playback)
}
}
}

View file

@ -21,7 +21,7 @@ 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 im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState
import kotlin.math.abs
class DraggableStateProcessor(
@ -50,7 +50,7 @@ class DraggableStateProcessor(
lastDistanceY = 0F
}
fun process(event: MotionEvent, recordingState: RecordingState): RecordingState {
fun process(event: MotionEvent, recordingState: RecordingUiState): RecordingUiState {
val currentX = event.rawX
val currentY = event.rawY
val distanceX = abs(firstX - currentX)
@ -63,9 +63,9 @@ class DraggableStateProcessor(
}
}
private fun nextRecordingState(recordingState: RecordingState, currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): RecordingState {
private fun nextRecordingState(recordingState: RecordingUiState, currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): RecordingUiState {
return when (recordingState) {
RecordingState.Started -> {
RecordingUiState.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)
@ -78,9 +78,9 @@ class DraggableStateProcessor(
is DraggingState.Cancelling -> {
// Check if cancelling conditions met, also check if it should be initial state
if (distanceX < minimumMove && distanceX < lastDistanceX) {
RecordingState.Started
RecordingUiState.Started
} else if (shouldCancelRecording(distanceX)) {
RecordingState.Cancelled
RecordingUiState.Cancelled
} else {
DraggingState.Cancelling(distanceX)
}
@ -88,9 +88,9 @@ class DraggableStateProcessor(
is DraggingState.Locking -> {
// Check if locking conditions met, also check if it should be initial state
if (distanceY < minimumMove && distanceY < lastDistanceY) {
RecordingState.Started
RecordingUiState.Started
} else if (shouldLockRecording(distanceY)) {
RecordingState.Locked
RecordingUiState.Locked
} else {
DraggingState.Locking(distanceY)
}

View file

@ -28,7 +28,6 @@ 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
/**
@ -41,11 +40,15 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
) : ConstraintLayout(context, attrs, defStyleAttr), VoiceMessagePlaybackTracker.Listener {
interface Callback {
// Return true if the recording is started
fun onVoiceRecordingStarted(): Boolean
fun onVoiceRecordingStarted()
fun onVoiceRecordingEnded(isCancelled: Boolean)
fun onVoiceRecordingPlaybackModeOn()
fun onVoicePlaybackButtonClicked()
fun onRecordingStopped()
fun onUiStateChanged(state: RecordingUiState)
fun sendVoiceMessage()
fun deleteVoiceMessage()
fun onRecordingLimitReached()
}
// We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
@ -54,7 +57,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
var callback: Callback? = null
private var recordingState: RecordingState = RecordingState.None
private var currentUiState: RecordingUiState = RecordingUiState.None
private var recordingTicker: CountUpTimer? = null
init {
@ -78,7 +81,6 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
}
fun initVoiceRecordingViews() {
recordingState = RecordingState.None
stopRecordingTicker()
voiceMessageViews.initViews(onVoiceRecordingEnded = {})
}
@ -86,36 +88,39 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
private fun initListeners() {
voiceMessageViews.start(object : VoiceMessageViews.Actions {
override fun onRequestRecording() {
if (callback?.onVoiceRecordingStarted().orFalse()) {
display(RecordingState.Started)
}
callback?.onVoiceRecordingStarted()
}
override fun onRecordingStopped() {
if (recordingState != RecordingState.Locked && recordingState != RecordingState.None) {
display(RecordingState.None)
}
callback?.onRecordingStopped()
}
override fun isActive() = recordingState != RecordingState.Cancelled
override fun isActive() = currentUiState != RecordingUiState.Cancelled
override fun updateState(updater: (RecordingState) -> RecordingState) {
updater(recordingState).also {
display(it)
override fun updateState(updater: (RecordingUiState) -> RecordingUiState) {
updater(currentUiState).also { newState ->
when (newState) {
is DraggingState -> display(newState)
else -> {
if (newState != currentUiState) {
callback?.onUiStateChanged(newState)
}
}
}
}
}
override fun sendMessage() {
display(RecordingState.None)
callback?.sendVoiceMessage()
}
override fun delete() {
// this was previously marked as cancelled true
display(RecordingState.None)
callback?.deleteVoiceMessage()
}
override fun waveformClicked() {
display(RecordingState.Playback)
display(RecordingUiState.Playback)
}
override fun onVoicePlaybackButtonClicked() {
@ -124,43 +129,41 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
})
}
fun display(recordingState: RecordingState) {
val previousState = this.recordingState
val stateHasChanged = recordingState != this.recordingState
this.recordingState = recordingState
fun display(recordingState: RecordingUiState) {
if (recordingState == this.currentUiState) return
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
val previousState = this.currentUiState
this.currentUiState = recordingState
when (recordingState) {
RecordingUiState.None -> {
val isCancelled = previousState == RecordingUiState.Cancelled
voiceMessageViews.hideRecordingViews(recordingState, isCancelled = isCancelled) { callback?.onVoiceRecordingEnded(it) }
stopRecordingTicker()
}
RecordingUiState.Started -> {
startRecordingTicker()
voiceMessageViews.renderToast(context.getString(R.string.voice_message_release_to_send_toast))
voiceMessageViews.showRecordingViews()
}
RecordingUiState.Cancelled -> {
voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback?.onVoiceRecordingEnded(it) }
vibrate(context)
}
RecordingUiState.Locked -> {
voiceMessageViews.renderLocked()
postDelayed({
voiceMessageViews.showRecordingLockedViews(recordingState) { callback?.onVoiceRecordingEnded(it) }
}, 500)
}
RecordingUiState.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
}
}
@ -178,11 +181,11 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
}
private fun onRecordingTick(milliseconds: Long) {
voiceMessageViews.renderRecordingTimer(recordingState, milliseconds / 1_000)
voiceMessageViews.renderRecordingTimer(currentUiState, milliseconds / 1_000)
val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
if (timeDiffToRecordingLimit <= 0) {
post {
display(RecordingState.Playback)
callback?.onRecordingLimitReached()
}
} else if (timeDiffToRecordingLimit in 10_000..10_999) {
post {
@ -200,7 +203,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
/**
* Returns true if the voice message is recording or is in playback mode
*/
fun isActive() = recordingState !in listOf(RecordingState.None, RecordingState.Cancelled)
fun isActive() = currentUiState !in listOf(RecordingUiState.None, RecordingUiState.Cancelled)
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
when (state) {
@ -217,17 +220,16 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
}
}
sealed interface RecordingState {
object None : RecordingState
object Started : RecordingState
object Cancelled : RecordingState
object Locked : RecordingState
object Playback : RecordingState
sealed interface RecordingUiState {
object None : RecordingUiState
object Started : RecordingUiState
object Cancelled : RecordingUiState
object Locked : RecordingUiState
object Playback : RecordingUiState
}
sealed interface DraggingState : RecordingState {
sealed interface DraggingState : RecordingUiState {
data class Cancelling(val distanceX: Float) : DraggingState
data class Locking(val distanceY: Float) : DraggingState
}
}

View file

@ -32,7 +32,7 @@ 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.composer.voice.VoiceMessageRecorderView.RecordingUiState
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import org.matrix.android.sdk.api.extensions.orFalse
@ -155,9 +155,9 @@ class VoiceMessageViews(
views.voiceMessageSendButton.isVisible = false
}
fun hideRecordingViews(recordingState: RecordingState, isCancelled: Boolean?, onVoiceRecordingEnded: (Boolean) -> Unit) {
fun hideRecordingViews(recordingState: RecordingUiState, isCancelled: Boolean?, onVoiceRecordingEnded: (Boolean) -> Unit) {
// We need to animate the lock image first
if (recordingState != RecordingState.Locked || isCancelled.orFalse()) {
if (recordingState != RecordingUiState.Locked || isCancelled.orFalse()) {
views.voiceMessageLockImage.isVisible = false
views.voiceMessageLockImage.animate().translationY(0f).start()
views.voiceMessageLockBackground.isVisible = false
@ -171,7 +171,7 @@ class VoiceMessageViews(
views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
views.voiceMessagePlaybackLayout.isVisible = false
if (recordingState != RecordingState.Locked) {
if (recordingState != RecordingUiState.Locked) {
views.voiceMessageMicButton
.animate()
.scaleX(1f)
@ -203,7 +203,7 @@ class VoiceMessageViews(
}
// Hide toasts if user cancelled recording before the timeout of the toast.
if (recordingState == RecordingState.Cancelled || recordingState == RecordingState.None) {
if (recordingState == RecordingUiState.Cancelled || recordingState == RecordingUiState.None) {
hideToast()
}
}
@ -266,7 +266,7 @@ class VoiceMessageViews(
views.voiceMessageToast.isVisible = false
}
fun showRecordingLockedViews(recordingState: RecordingState, onVoiceRecordingEnded: (Boolean) -> Unit) {
fun showRecordingLockedViews(recordingState: RecordingUiState, onVoiceRecordingEnded: (Boolean) -> Unit) {
hideRecordingViews(recordingState, null, onVoiceRecordingEnded)
views.voiceMessagePlaybackLayout.isVisible = true
views.voiceMessagePlaybackTimerIndicator.isVisible = true
@ -283,7 +283,7 @@ class VoiceMessageViews(
}
fun initViews(onVoiceRecordingEnded: (Boolean) -> Unit) {
hideRecordingViews(RecordingState.None, null, onVoiceRecordingEnded)
hideRecordingViews(RecordingUiState.None, null, onVoiceRecordingEnded)
views.voiceMessageMicButton.isVisible = true
views.voiceMessageSendButton.isVisible = false
views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }
@ -312,9 +312,9 @@ class VoiceMessageViews(
views.voiceMessageToast.isVisible = false
}
fun renderRecordingTimer(recordingState: RecordingState, recordingTimeMillis: Long) {
fun renderRecordingTimer(recordingState: RecordingUiState, recordingTimeMillis: Long) {
val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis)
if (recordingState == RecordingState.Locked) {
if (recordingState == RecordingUiState.Locked) {
views.voicePlaybackTime.apply {
post {
text = formattedTimerText
@ -349,7 +349,7 @@ class VoiceMessageViews(
fun onRequestRecording()
fun onRecordingStopped()
fun isActive(): Boolean
fun updateState(updater: (RecordingState) -> RecordingState)
fun updateState(updater: (RecordingUiState) -> RecordingUiState)
fun sendMessage()
fun delete()
fun waveformClicked()