Merge pull request #4515 from vector-im/feature/adm/voice-state
Voice recording UI state in ViewModel
This commit is contained in:
commit
35f9bef94a
1
changelog.d/4515.misc
Normal file
1
changelog.d/4515.misc
Normal file
@ -0,0 +1 @@
|
|||||||
|
Voice recording mic button refactor with small animation tweaks in preparation for voice drafts
|
@ -138,7 +138,8 @@ import im.vector.app.features.home.room.detail.composer.TextComposerView
|
|||||||
import im.vector.app.features.home.room.detail.composer.TextComposerViewEvents
|
import im.vector.app.features.home.room.detail.composer.TextComposerViewEvents
|
||||||
import im.vector.app.features.home.room.detail.composer.TextComposerViewModel
|
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.TextComposerViewState
|
||||||
import im.vector.app.features.home.room.detail.composer.VoiceMessageRecorderView
|
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
|
||||||
|
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState
|
||||||
import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet
|
import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet
|
||||||
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
|
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
|
||||||
import im.vector.app.features.home.room.detail.timeline.action.EventSharedAction
|
import im.vector.app.features.home.room.detail.timeline.action.EventSharedAction
|
||||||
@ -505,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()
|
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingUiStateChanged(RecordingUiState.None))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) {
|
private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) {
|
||||||
@ -692,33 +693,57 @@ class RoomDetailFragment @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setupVoiceMessageView() {
|
private fun setupVoiceMessageView() {
|
||||||
views.voiceMessageRecorderView.voiceMessagePlaybackTracker = voiceMessagePlaybackTracker
|
voiceMessagePlaybackTracker.track(VoiceMessagePlaybackTracker.RECORDING_ID, views.voiceMessageRecorderView)
|
||||||
|
|
||||||
views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback {
|
views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback {
|
||||||
override fun onVoiceRecordingStarted(): Boolean {
|
|
||||||
return if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
|
override fun onVoiceRecordingStarted() {
|
||||||
|
if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
|
||||||
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
|
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
|
||||||
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(true))
|
|
||||||
vibrate(requireContext())
|
vibrate(requireContext())
|
||||||
true
|
updateRecordingUiState(RecordingUiState.Started)
|
||||||
} else {
|
|
||||||
// Permission dialog is displayed
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onVoiceRecordingEnded(isCancelled: Boolean) {
|
|
||||||
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled))
|
|
||||||
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onVoiceRecordingPlaybackModeOn() {
|
|
||||||
roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onVoicePlaybackButtonClicked() {
|
override fun onVoicePlaybackButtonClicked() {
|
||||||
roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseRecordingPlayback)
|
roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseRecordingPlayback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onVoiceRecordingCancelled() {
|
||||||
|
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true))
|
||||||
|
updateRecordingUiState(RecordingUiState.Cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVoiceRecordingLocked() {
|
||||||
|
updateRecordingUiState(RecordingUiState.Locked)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVoiceRecordingEnded() {
|
||||||
|
onSendVoiceMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSendVoiceMessage() {
|
||||||
|
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = false))
|
||||||
|
updateRecordingUiState(RecordingUiState.None)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDeleteVoiceMessage() {
|
||||||
|
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled = true))
|
||||||
|
updateRecordingUiState(RecordingUiState.None)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRecordingLimitReached() {
|
||||||
|
roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
|
||||||
|
updateRecordingUiState(RecordingUiState.Playback)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRecordingWaveformClicked() {
|
||||||
|
roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
|
||||||
|
updateRecordingUiState(RecordingUiState.Playback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRecordingUiState(state: RecordingUiState) {
|
||||||
|
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingUiStateChanged(state))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1112,7 +1137,7 @@ class RoomDetailFragment @Inject constructor(
|
|||||||
|
|
||||||
// We should improve the UX to support going into playback mode when paused and delete the media when the view is destroyed.
|
// 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 {
|
||||||
@ -1408,6 +1433,7 @@ class RoomDetailFragment @Inject constructor(
|
|||||||
views.composerLayout.isInvisible = !textComposerState.isComposerVisible
|
views.composerLayout.isInvisible = !textComposerState.isComposerVisible
|
||||||
views.voiceMessageRecorderView.isVisible = textComposerState.isVoiceMessageRecorderVisible
|
views.voiceMessageRecorderView.isVisible = textComposerState.isVoiceMessageRecorderVisible
|
||||||
views.composerLayout.views.sendButton.isInvisible = !textComposerState.isSendButtonVisible
|
views.composerLayout.views.sendButton.isInvisible = !textComposerState.isSendButtonVisible
|
||||||
|
views.voiceMessageRecorderView.display(textComposerState.voiceRecordingUiState)
|
||||||
views.composerLayout.setRoomEncrypted(summary.isEncrypted)
|
views.composerLayout.setRoomEncrypted(summary.isEncrypted)
|
||||||
// views.composerLayout.alwaysShowSendButton = false
|
// views.composerLayout.alwaysShowSendButton = false
|
||||||
if (textComposerState.canSendMessage) {
|
if (textComposerState.canSendMessage) {
|
||||||
@ -1962,7 +1988,7 @@ class RoomDetailFragment @Inject constructor(
|
|||||||
roomDetailViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add))
|
roomDetailViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add))
|
||||||
}
|
}
|
||||||
is EventSharedAction.Edit -> {
|
is EventSharedAction.Edit -> {
|
||||||
if (!views.voiceMessageRecorderView.isActive()) {
|
if (withState(textComposerViewModel) { it.isVoiceMessageIdle }) {
|
||||||
textComposerViewModel.handle(TextComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString()))
|
textComposerViewModel.handle(TextComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString()))
|
||||||
} else {
|
} else {
|
||||||
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
|
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
|
||||||
@ -1972,7 +1998,7 @@ class RoomDetailFragment @Inject constructor(
|
|||||||
textComposerViewModel.handle(TextComposerAction.EnterQuoteMode(action.eventId, views.composerLayout.text.toString()))
|
textComposerViewModel.handle(TextComposerAction.EnterQuoteMode(action.eventId, views.composerLayout.text.toString()))
|
||||||
}
|
}
|
||||||
is EventSharedAction.Reply -> {
|
is EventSharedAction.Reply -> {
|
||||||
if (!views.voiceMessageRecorderView.isActive()) {
|
if (withState(textComposerViewModel) { it.isVoiceMessageIdle }) {
|
||||||
textComposerViewModel.handle(TextComposerAction.EnterReplyMode(action.eventId, views.composerLayout.text.toString()))
|
textComposerViewModel.handle(TextComposerAction.EnterReplyMode(action.eventId, views.composerLayout.text.toString()))
|
||||||
} else {
|
} else {
|
||||||
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
|
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
package im.vector.app.features.home.room.detail.composer
|
package im.vector.app.features.home.room.detail.composer
|
||||||
|
|
||||||
import im.vector.app.core.platform.VectorViewModelAction
|
import im.vector.app.core.platform.VectorViewModelAction
|
||||||
|
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
|
||||||
|
|
||||||
sealed class TextComposerAction : VectorViewModelAction {
|
sealed class TextComposerAction : VectorViewModelAction {
|
||||||
data class SaveDraft(val draft: String) : TextComposerAction()
|
data class SaveDraft(val draft: String) : TextComposerAction()
|
||||||
@ -27,5 +28,5 @@ sealed class TextComposerAction : VectorViewModelAction {
|
|||||||
data class EnterRegularMode(val text: String, val fromSharing: Boolean) : TextComposerAction()
|
data class EnterRegularMode(val text: String, val fromSharing: Boolean) : TextComposerAction()
|
||||||
data class UserIsTyping(val isTyping: Boolean) : TextComposerAction()
|
data class UserIsTyping(val isTyping: Boolean) : TextComposerAction()
|
||||||
data class OnTextChanged(val text: CharSequence) : TextComposerAction()
|
data class OnTextChanged(val text: CharSequence) : TextComposerAction()
|
||||||
data class OnVoiceRecordingStateChanged(val isRecording: Boolean) : TextComposerAction()
|
data class OnVoiceRecordingUiStateChanged(val uiState: VoiceMessageRecorderView.RecordingUiState) : TextComposerAction()
|
||||||
}
|
}
|
||||||
|
@ -77,20 +77,20 @@ class TextComposerViewModel @AssistedInject constructor(
|
|||||||
override fun handle(action: TextComposerAction) {
|
override fun handle(action: TextComposerAction) {
|
||||||
Timber.v("Handle action: $action")
|
Timber.v("Handle action: $action")
|
||||||
when (action) {
|
when (action) {
|
||||||
is TextComposerAction.EnterEditMode -> handleEnterEditMode(action)
|
is TextComposerAction.EnterEditMode -> handleEnterEditMode(action)
|
||||||
is TextComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action)
|
is TextComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action)
|
||||||
is TextComposerAction.EnterRegularMode -> handleEnterRegularMode(action)
|
is TextComposerAction.EnterRegularMode -> handleEnterRegularMode(action)
|
||||||
is TextComposerAction.EnterReplyMode -> handleEnterReplyMode(action)
|
is TextComposerAction.EnterReplyMode -> handleEnterReplyMode(action)
|
||||||
is TextComposerAction.SaveDraft -> handleSaveDraft(action)
|
is TextComposerAction.SaveDraft -> handleSaveDraft(action)
|
||||||
is TextComposerAction.SendMessage -> handleSendMessage(action)
|
is TextComposerAction.SendMessage -> handleSendMessage(action)
|
||||||
is TextComposerAction.UserIsTyping -> handleUserIsTyping(action)
|
is TextComposerAction.UserIsTyping -> handleUserIsTyping(action)
|
||||||
is TextComposerAction.OnTextChanged -> handleOnTextChanged(action)
|
is TextComposerAction.OnTextChanged -> handleOnTextChanged(action)
|
||||||
is TextComposerAction.OnVoiceRecordingStateChanged -> handleOnVoiceRecordingStateChanged(action)
|
is TextComposerAction.OnVoiceRecordingUiStateChanged -> handleOnVoiceRecordingUiStateChanged(action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleOnVoiceRecordingStateChanged(action: TextComposerAction.OnVoiceRecordingStateChanged) = setState {
|
private fun handleOnVoiceRecordingUiStateChanged(action: TextComposerAction.OnVoiceRecordingUiStateChanged) = setState {
|
||||||
copy(isVoiceRecording = action.isRecording)
|
copy(voiceRecordingUiState = action.uiState)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleOnTextChanged(action: TextComposerAction.OnTextChanged) {
|
private fun handleOnTextChanged(action: TextComposerAction.OnTextChanged) {
|
||||||
|
@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.composer
|
|||||||
|
|
||||||
import com.airbnb.mvrx.MavericksState
|
import com.airbnb.mvrx.MavericksState
|
||||||
import im.vector.app.features.home.room.detail.RoomDetailArgs
|
import im.vector.app.features.home.room.detail.RoomDetailArgs
|
||||||
|
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
|
||||||
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
|
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -44,13 +45,27 @@ sealed class SendMode(open val text: String) {
|
|||||||
data class TextComposerViewState(
|
data class TextComposerViewState(
|
||||||
val roomId: String,
|
val roomId: String,
|
||||||
val canSendMessage: Boolean = true,
|
val canSendMessage: Boolean = true,
|
||||||
val isVoiceRecording: Boolean = false,
|
|
||||||
val isSendButtonVisible: Boolean = false,
|
val isSendButtonVisible: Boolean = false,
|
||||||
val sendMode: SendMode = SendMode.REGULAR("", false)
|
val sendMode: SendMode = SendMode.REGULAR("", false),
|
||||||
|
val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.None
|
||||||
) : MavericksState {
|
) : MavericksState {
|
||||||
|
|
||||||
|
val isVoiceRecording = when (voiceRecordingUiState) {
|
||||||
|
VoiceMessageRecorderView.RecordingUiState.None,
|
||||||
|
VoiceMessageRecorderView.RecordingUiState.Cancelled,
|
||||||
|
VoiceMessageRecorderView.RecordingUiState.Playback -> false
|
||||||
|
VoiceMessageRecorderView.RecordingUiState.Locked,
|
||||||
|
VoiceMessageRecorderView.RecordingUiState.Started -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
val isVoiceMessageIdle = when (voiceRecordingUiState) {
|
||||||
|
VoiceMessageRecorderView.RecordingUiState.None, VoiceMessageRecorderView.RecordingUiState.Cancelled -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
val isComposerVisible = canSendMessage && !isVoiceRecording
|
val isComposerVisible = canSendMessage && !isVoiceRecording
|
||||||
val isVoiceMessageRecorderVisible = canSendMessage && !isSendButtonVisible
|
val isVoiceMessageRecorderVisible = canSendMessage && !isSendButtonVisible
|
||||||
|
|
||||||
|
@Suppress("UNUSED") // needed by mavericks
|
||||||
constructor(args: RoomDetailArgs) : this(roomId = args.roomId)
|
constructor(args: RoomDetailArgs) : this(roomId = args.roomId)
|
||||||
}
|
}
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2021 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package im.vector.app.features.home.room.detail.composer.voice
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import im.vector.app.R
|
||||||
|
import im.vector.app.core.utils.DimensionConverter
|
||||||
|
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.DraggingState
|
||||||
|
|
||||||
|
class DraggableStateProcessor(
|
||||||
|
resources: Resources,
|
||||||
|
dimensionConverter: DimensionConverter,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
|
||||||
|
private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
|
||||||
|
private val rtlXMultiplier = resources.getInteger(R.integer.rtl_x_multiplier)
|
||||||
|
|
||||||
|
private var firstX: Float = 0f
|
||||||
|
private var firstY: Float = 0f
|
||||||
|
private var lastDistanceX: Float = 0f
|
||||||
|
private var lastDistanceY: Float = 0f
|
||||||
|
|
||||||
|
fun initialize(event: MotionEvent) {
|
||||||
|
firstX = event.rawX
|
||||||
|
firstY = event.rawY
|
||||||
|
lastDistanceX = 0F
|
||||||
|
lastDistanceY = 0F
|
||||||
|
}
|
||||||
|
|
||||||
|
fun process(event: MotionEvent, draggingState: DraggingState): DraggingState {
|
||||||
|
val currentX = event.rawX
|
||||||
|
val currentY = event.rawY
|
||||||
|
val distanceX = firstX - currentX
|
||||||
|
val distanceY = firstY - currentY
|
||||||
|
return draggingState.nextDragState(currentX, currentY, distanceX, distanceY).also {
|
||||||
|
lastDistanceX = distanceX
|
||||||
|
lastDistanceY = distanceY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DraggingState.nextDragState(currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): DraggingState {
|
||||||
|
return when (this) {
|
||||||
|
DraggingState.Ready -> {
|
||||||
|
when {
|
||||||
|
isDraggingToCancel(currentX, distanceX, distanceY) -> DraggingState.Cancelling(distanceX)
|
||||||
|
isDraggingToLock(currentY, distanceX, distanceY) -> DraggingState.Locking(distanceY)
|
||||||
|
else -> DraggingState.Ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is DraggingState.Cancelling -> {
|
||||||
|
when {
|
||||||
|
isDraggingToLock(currentY, distanceX, distanceY) -> DraggingState.Locking(distanceY)
|
||||||
|
shouldCancelRecording(distanceX) -> DraggingState.Cancel
|
||||||
|
else -> DraggingState.Cancelling(distanceX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is DraggingState.Locking -> {
|
||||||
|
when {
|
||||||
|
isDraggingToCancel(currentX, distanceX, distanceY) -> DraggingState.Cancelling(distanceX)
|
||||||
|
shouldLockRecording(distanceY) -> DraggingState.Lock
|
||||||
|
else -> DraggingState.Locking(distanceY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isDraggingToLock(currentY: Float, distanceX: Float, distanceY: Float) = (currentY < firstY) &&
|
||||||
|
distanceY > distanceX && distanceY > lastDistanceY
|
||||||
|
|
||||||
|
private fun isDraggingToCancel(currentX: Float, distanceX: Float, distanceY: Float) = isDraggingHorizontal(currentX) &&
|
||||||
|
distanceX > distanceY && distanceX > lastDistanceX
|
||||||
|
|
||||||
|
private fun isDraggingHorizontal(currentX: Float) = (currentX < firstX && rtlXMultiplier == 1) || (currentX > firstX && rtlXMultiplier == -1)
|
||||||
|
|
||||||
|
private fun shouldCancelRecording(distanceX: Float): Boolean {
|
||||||
|
return distanceX >= distanceToCancel
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldLockRecording(distanceY: Float): Boolean {
|
||||||
|
return distanceY >= distanceToLock
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,227 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2021 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package im.vector.app.features.home.room.detail.composer.voice
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import im.vector.app.BuildConfig
|
||||||
|
import im.vector.app.R
|
||||||
|
import im.vector.app.core.extensions.exhaustive
|
||||||
|
import im.vector.app.core.hardware.vibrate
|
||||||
|
import im.vector.app.core.utils.CountUpTimer
|
||||||
|
import im.vector.app.core.utils.DimensionConverter
|
||||||
|
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
|
||||||
|
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
|
||||||
|
import kotlin.math.floor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encapsulates the voice message recording view and animations.
|
||||||
|
*/
|
||||||
|
class VoiceMessageRecorderView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = 0
|
||||||
|
) : ConstraintLayout(context, attrs, defStyleAttr), VoiceMessagePlaybackTracker.Listener {
|
||||||
|
|
||||||
|
interface Callback {
|
||||||
|
fun onVoiceRecordingStarted()
|
||||||
|
fun onVoiceRecordingEnded()
|
||||||
|
fun onVoicePlaybackButtonClicked()
|
||||||
|
fun onVoiceRecordingCancelled()
|
||||||
|
fun onVoiceRecordingLocked()
|
||||||
|
fun onSendVoiceMessage()
|
||||||
|
fun onDeleteVoiceMessage()
|
||||||
|
fun onRecordingLimitReached()
|
||||||
|
fun onRecordingWaveformClicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
|
||||||
|
@Suppress("UNNECESSARY_LATEINIT")
|
||||||
|
private lateinit var voiceMessageViews: VoiceMessageViews
|
||||||
|
lateinit var callback: Callback
|
||||||
|
|
||||||
|
private var recordingTicker: CountUpTimer? = null
|
||||||
|
private var lastKnownState: RecordingUiState? = null
|
||||||
|
private var dragState: DraggingState = DraggingState.Ignored
|
||||||
|
|
||||||
|
init {
|
||||||
|
inflate(this.context, R.layout.view_voice_message_recorder, this)
|
||||||
|
val dimensionConverter = DimensionConverter(this.context.resources)
|
||||||
|
voiceMessageViews = VoiceMessageViews(
|
||||||
|
this.context.resources,
|
||||||
|
ViewVoiceMessageRecorderBinding.bind(this),
|
||||||
|
dimensionConverter
|
||||||
|
)
|
||||||
|
initListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initListeners() {
|
||||||
|
voiceMessageViews.start(object : VoiceMessageViews.Actions {
|
||||||
|
override fun onRequestRecording() = callback.onVoiceRecordingStarted()
|
||||||
|
override fun onMicButtonReleased() {
|
||||||
|
when (dragState) {
|
||||||
|
DraggingState.Lock -> {
|
||||||
|
// do nothing,
|
||||||
|
// onSendVoiceMessage, onDeleteVoiceMessage or onRecordingLimitReached will be triggered instead
|
||||||
|
}
|
||||||
|
DraggingState.Cancel -> callback.onVoiceRecordingCancelled()
|
||||||
|
else -> callback.onVoiceRecordingEnded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSendVoiceMessage() = callback.onSendVoiceMessage()
|
||||||
|
override fun onDeleteVoiceMessage() = callback.onDeleteVoiceMessage()
|
||||||
|
override fun onWaveformClicked() = callback.onRecordingWaveformClicked()
|
||||||
|
override fun onVoicePlaybackButtonClicked() = callback.onVoicePlaybackButtonClicked()
|
||||||
|
override fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState) {
|
||||||
|
onDrag(dragState, newDragState = nextDragStateCreator(dragState))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVisibilityChanged(changedView: View, visibility: Int) {
|
||||||
|
super.onVisibilityChanged(changedView, visibility)
|
||||||
|
// onVisibilityChanged is called by constructor on api 21 and 22.
|
||||||
|
if (!this::voiceMessageViews.isInitialized) return
|
||||||
|
val parentChanged = changedView == this
|
||||||
|
voiceMessageViews.renderVisibilityChanged(parentChanged, visibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun display(recordingState: RecordingUiState) {
|
||||||
|
if (lastKnownState == recordingState) return
|
||||||
|
lastKnownState = recordingState
|
||||||
|
when (recordingState) {
|
||||||
|
RecordingUiState.None -> {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
RecordingUiState.Started -> {
|
||||||
|
startRecordingTicker()
|
||||||
|
voiceMessageViews.renderToast(context.getString(R.string.voice_message_release_to_send_toast))
|
||||||
|
voiceMessageViews.showRecordingViews()
|
||||||
|
dragState = DraggingState.Ready
|
||||||
|
}
|
||||||
|
RecordingUiState.Cancelled -> {
|
||||||
|
reset()
|
||||||
|
vibrate(context)
|
||||||
|
}
|
||||||
|
RecordingUiState.Locked -> {
|
||||||
|
voiceMessageViews.renderLocked()
|
||||||
|
postDelayed({
|
||||||
|
voiceMessageViews.showRecordingLockedViews(recordingState)
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
RecordingUiState.Playback -> {
|
||||||
|
stopRecordingTicker()
|
||||||
|
voiceMessageViews.showPlaybackViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reset() {
|
||||||
|
stopRecordingTicker()
|
||||||
|
voiceMessageViews.initViews()
|
||||||
|
dragState = DraggingState.Ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDrag(currentDragState: DraggingState, newDragState: DraggingState) {
|
||||||
|
when (newDragState) {
|
||||||
|
is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(newDragState.distanceX)
|
||||||
|
is DraggingState.Locking -> {
|
||||||
|
if (currentDragState is DraggingState.Cancelling) {
|
||||||
|
voiceMessageViews.showRecordingViews()
|
||||||
|
}
|
||||||
|
voiceMessageViews.renderLocking(newDragState.distanceY)
|
||||||
|
}
|
||||||
|
DraggingState.Cancel -> callback.onVoiceRecordingCancelled()
|
||||||
|
DraggingState.Lock -> callback.onVoiceRecordingLocked()
|
||||||
|
DraggingState.Ignored,
|
||||||
|
DraggingState.Ready -> {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
}.exhaustive
|
||||||
|
dragState = newDragState
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startRecordingTicker() {
|
||||||
|
recordingTicker?.stop()
|
||||||
|
recordingTicker = CountUpTimer().apply {
|
||||||
|
tickListener = object : CountUpTimer.TickListener {
|
||||||
|
override fun onTick(milliseconds: Long) {
|
||||||
|
onRecordingTick(milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resume()
|
||||||
|
}
|
||||||
|
onRecordingTick(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onRecordingTick(milliseconds: Long) {
|
||||||
|
val currentState = lastKnownState ?: return
|
||||||
|
voiceMessageViews.renderRecordingTimer(currentState, milliseconds / 1_000)
|
||||||
|
val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
|
||||||
|
if (timeDiffToRecordingLimit <= 0) {
|
||||||
|
post {
|
||||||
|
callback.onRecordingLimitReached()
|
||||||
|
}
|
||||||
|
} else if (timeDiffToRecordingLimit in 10_000..10_999) {
|
||||||
|
post {
|
||||||
|
val secondsRemaining = floor(timeDiffToRecordingLimit / 1000f).toInt()
|
||||||
|
voiceMessageViews.renderToast(context.getString(R.string.voice_message_n_seconds_warning_toast, secondsRemaining))
|
||||||
|
vibrate(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopRecordingTicker() {
|
||||||
|
recordingTicker?.stop()
|
||||||
|
recordingTicker = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
|
||||||
|
when (state) {
|
||||||
|
is VoiceMessagePlaybackTracker.Listener.State.Recording -> {
|
||||||
|
voiceMessageViews.renderRecordingWaveform(state.amplitudeList.toTypedArray())
|
||||||
|
}
|
||||||
|
is VoiceMessagePlaybackTracker.Listener.State.Playing -> {
|
||||||
|
voiceMessageViews.renderPlaying(state)
|
||||||
|
}
|
||||||
|
is VoiceMessagePlaybackTracker.Listener.State.Paused,
|
||||||
|
is VoiceMessagePlaybackTracker.Listener.State.Idle -> {
|
||||||
|
voiceMessageViews.renderIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface RecordingUiState {
|
||||||
|
object None : RecordingUiState
|
||||||
|
object Started : RecordingUiState
|
||||||
|
object Cancelled : RecordingUiState
|
||||||
|
object Locked : RecordingUiState
|
||||||
|
object Playback : RecordingUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface DraggingState {
|
||||||
|
object Ready : DraggingState
|
||||||
|
object Ignored : DraggingState
|
||||||
|
data class Cancelling(val distanceX: Float) : DraggingState
|
||||||
|
data class Locking(val distanceY: Float) : DraggingState
|
||||||
|
object Cancel : DraggingState
|
||||||
|
object Lock : DraggingState
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,349 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2021 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package im.vector.app.features.home.room.detail.composer.voice
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.core.view.isInvisible
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
|
import im.vector.app.R
|
||||||
|
import im.vector.app.core.extensions.setAttributeBackground
|
||||||
|
import im.vector.app.core.extensions.setAttributeTintedBackground
|
||||||
|
import im.vector.app.core.extensions.setAttributeTintedImageResource
|
||||||
|
import im.vector.app.core.utils.DimensionConverter
|
||||||
|
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
|
||||||
|
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.DraggingState
|
||||||
|
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState
|
||||||
|
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
|
||||||
|
|
||||||
|
class VoiceMessageViews(
|
||||||
|
private val resources: Resources,
|
||||||
|
private val views: ViewVoiceMessageRecorderBinding,
|
||||||
|
private val dimensionConverter: DimensionConverter,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
|
||||||
|
private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
|
||||||
|
private val rtlXMultiplier = resources.getInteger(R.integer.rtl_x_multiplier)
|
||||||
|
|
||||||
|
fun start(actions: Actions) {
|
||||||
|
views.voiceMessageSendButton.setOnClickListener {
|
||||||
|
views.voiceMessageSendButton.isVisible = false
|
||||||
|
actions.onSendVoiceMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
views.voiceMessageDeletePlayback.setOnClickListener {
|
||||||
|
views.voiceMessageSendButton.isVisible = false
|
||||||
|
actions.onDeleteVoiceMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
views.voicePlaybackWaveform.setOnClickListener {
|
||||||
|
actions.onWaveformClicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
views.voicePlaybackControlButton.setOnClickListener {
|
||||||
|
actions.onVoicePlaybackButtonClicked()
|
||||||
|
}
|
||||||
|
observeMicButton(actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
private fun observeMicButton(actions: Actions) {
|
||||||
|
val draggableStateProcessor = DraggableStateProcessor(resources, dimensionConverter)
|
||||||
|
views.voiceMessageMicButton.setOnTouchListener { _, event ->
|
||||||
|
when (event.action) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
draggableStateProcessor.initialize(event)
|
||||||
|
actions.onRequestRecording()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP -> {
|
||||||
|
actions.onMicButtonReleased()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
actions.onMicButtonDrag { currentState -> draggableStateProcessor.process(event, currentState) }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renderStarted(distanceX: Float) {
|
||||||
|
val translationAmount = distanceX.coerceAtMost(distanceToCancel)
|
||||||
|
views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
|
||||||
|
views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renderLocked() {
|
||||||
|
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_locked)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renderLocking(distanceY: Float) {
|
||||||
|
views.voiceMessageLockImage.setAttributeTintedImageResource(R.drawable.ic_voice_message_locked, R.attr.colorPrimary)
|
||||||
|
val translationAmount = -distanceY.coerceIn(0F, distanceToLock)
|
||||||
|
views.voiceMessageMicButton.translationY = translationAmount
|
||||||
|
views.voiceMessageLockArrow.translationY = translationAmount
|
||||||
|
views.voiceMessageLockArrow.alpha = 1 - (-translationAmount / distanceToLock)
|
||||||
|
// Reset X translations
|
||||||
|
views.voiceMessageMicButton.translationX = 0F
|
||||||
|
views.voiceMessageSlideToCancel.translationX = 0F
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renderCancelling(distanceX: Float) {
|
||||||
|
val translationAmount = distanceX.coerceAtMost(distanceToCancel)
|
||||||
|
views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
|
||||||
|
views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
|
||||||
|
val reducedAlpha = (1 - translationAmount / distanceToCancel / 1.5).toFloat()
|
||||||
|
views.voiceMessageSlideToCancel.alpha = reducedAlpha
|
||||||
|
views.voiceMessageTimerIndicator.alpha = reducedAlpha
|
||||||
|
views.voiceMessageTimer.alpha = reducedAlpha
|
||||||
|
views.voiceMessageLockBackground.isVisible = false
|
||||||
|
views.voiceMessageLockImage.isVisible = false
|
||||||
|
views.voiceMessageLockArrow.isVisible = false
|
||||||
|
views.voiceMessageSlideToCancelDivider.isVisible = true
|
||||||
|
// Reset Y translations
|
||||||
|
views.voiceMessageMicButton.translationY = 0F
|
||||||
|
views.voiceMessageLockArrow.translationY = 0F
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showRecordingViews() {
|
||||||
|
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic_recording)
|
||||||
|
views.voiceMessageMicButton.setAttributeTintedBackground(R.drawable.circle_with_halo, R.attr.colorPrimary)
|
||||||
|
views.voiceMessageMicButton.updateLayoutParams<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: RecordingUiState) {
|
||||||
|
// We need to animate the lock image first
|
||||||
|
if (recordingState != RecordingUiState.Locked) {
|
||||||
|
views.voiceMessageLockImage.isVisible = false
|
||||||
|
views.voiceMessageLockImage.animate().translationY(0f).start()
|
||||||
|
views.voiceMessageLockBackground.isVisible = false
|
||||||
|
views.voiceMessageLockBackground.animate().translationY(0f).start()
|
||||||
|
} else {
|
||||||
|
animateLockImageWithBackground()
|
||||||
|
}
|
||||||
|
views.voiceMessageSlideToCancelDivider.isVisible = false
|
||||||
|
views.voiceMessageLockArrow.isVisible = false
|
||||||
|
views.voiceMessageLockArrow.animate().translationY(0f).start()
|
||||||
|
views.voiceMessageSlideToCancel.isVisible = false
|
||||||
|
views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
|
||||||
|
views.voiceMessagePlaybackLayout.isVisible = false
|
||||||
|
views.voiceMessageTimerIndicator.isVisible = false
|
||||||
|
views.voiceMessageTimer.isVisible = false
|
||||||
|
|
||||||
|
if (recordingState != RecordingUiState.Locked) {
|
||||||
|
views.voiceMessageMicButton
|
||||||
|
.animate()
|
||||||
|
.scaleX(1f)
|
||||||
|
.scaleY(1f)
|
||||||
|
.translationX(0f)
|
||||||
|
.translationY(0f)
|
||||||
|
.setDuration(150)
|
||||||
|
.withEndAction {
|
||||||
|
resetMicButtonUi()
|
||||||
|
}
|
||||||
|
.start()
|
||||||
|
} else {
|
||||||
|
views.voiceMessageTimerIndicator.isVisible = false
|
||||||
|
views.voiceMessageTimer.isVisible = false
|
||||||
|
views.voiceMessageMicButton.apply {
|
||||||
|
scaleX = 1f
|
||||||
|
scaleY = 1f
|
||||||
|
translationX = 0f
|
||||||
|
translationY = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide toasts if user cancelled recording before the timeout of the toast.
|
||||||
|
if (recordingState == RecordingUiState.Cancelled || recordingState == RecordingUiState.None) {
|
||||||
|
hideToast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun animateLockImageWithBackground() {
|
||||||
|
views.voiceMessageLockBackground.updateLayoutParams {
|
||||||
|
height = dimensionConverter.dpToPx(78)
|
||||||
|
}
|
||||||
|
views.voiceMessageLockBackground.apply {
|
||||||
|
animate()
|
||||||
|
.scaleX(0f)
|
||||||
|
.scaleY(0f)
|
||||||
|
.setDuration(400L)
|
||||||
|
.withEndAction {
|
||||||
|
updateLayoutParams {
|
||||||
|
height = dimensionConverter.dpToPx(180)
|
||||||
|
}
|
||||||
|
isVisible = false
|
||||||
|
scaleX = 1f
|
||||||
|
scaleY = 1f
|
||||||
|
animate().translationY(0f).start()
|
||||||
|
}
|
||||||
|
.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock image animation
|
||||||
|
views.voiceMessageMicButton.isInvisible = true
|
||||||
|
views.voiceMessageLockImage.apply {
|
||||||
|
isVisible = true
|
||||||
|
animate()
|
||||||
|
.scaleX(0f)
|
||||||
|
.scaleY(0f)
|
||||||
|
.setDuration(400L)
|
||||||
|
.withEndAction {
|
||||||
|
isVisible = false
|
||||||
|
scaleX = 1f
|
||||||
|
scaleY = 1f
|
||||||
|
translationY = 0f
|
||||||
|
resetMicButtonUi()
|
||||||
|
}
|
||||||
|
.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetMicButtonUi() {
|
||||||
|
views.voiceMessageMicButton.isVisible = true
|
||||||
|
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic)
|
||||||
|
views.voiceMessageMicButton.setAttributeBackground(android.R.attr.selectableItemBackgroundBorderless)
|
||||||
|
views.voiceMessageMicButton.updateLayoutParams<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: RecordingUiState) {
|
||||||
|
hideRecordingViews(recordingState)
|
||||||
|
views.voiceMessagePlaybackLayout.isVisible = true
|
||||||
|
views.voiceMessagePlaybackTimerIndicator.isVisible = true
|
||||||
|
views.voicePlaybackControlButton.isVisible = false
|
||||||
|
views.voiceMessageSendButton.isVisible = true
|
||||||
|
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
|
||||||
|
renderToast(resources.getString(R.string.voice_message_tap_to_stop_toast))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showPlaybackViews() {
|
||||||
|
views.voiceMessagePlaybackTimerIndicator.isVisible = false
|
||||||
|
views.voicePlaybackControlButton.isVisible = true
|
||||||
|
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initViews() {
|
||||||
|
hideRecordingViews(RecordingUiState.None)
|
||||||
|
views.voiceMessageMicButton.isVisible = true
|
||||||
|
views.voiceMessageSendButton.isVisible = false
|
||||||
|
views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renderPlaying(state: VoiceMessagePlaybackTracker.Listener.State.Playing) {
|
||||||
|
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
|
||||||
|
views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_pause_voice_message)
|
||||||
|
val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong())
|
||||||
|
views.voicePlaybackTime.text = formattedTimerText
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renderIdle() {
|
||||||
|
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
|
||||||
|
views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_play_voice_message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renderToast(message: String) {
|
||||||
|
views.voiceMessageToast.removeCallbacks(hideToastRunnable)
|
||||||
|
views.voiceMessageToast.text = message
|
||||||
|
views.voiceMessageToast.isVisible = true
|
||||||
|
views.voiceMessageToast.postDelayed(hideToastRunnable, 2_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val hideToastRunnable = Runnable {
|
||||||
|
views.voiceMessageToast.isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renderRecordingTimer(recordingState: RecordingUiState, recordingTimeMillis: Long) {
|
||||||
|
val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis)
|
||||||
|
if (recordingState == RecordingUiState.Locked) {
|
||||||
|
views.voicePlaybackTime.apply {
|
||||||
|
post {
|
||||||
|
text = formattedTimerText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
views.voiceMessageTimer.post {
|
||||||
|
views.voiceMessageTimer.text = formattedTimerText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renderRecordingWaveform(amplitudeList: Array<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 onMicButtonReleased()
|
||||||
|
fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState)
|
||||||
|
fun onSendVoiceMessage()
|
||||||
|
fun onDeleteVoiceMessage()
|
||||||
|
fun onWaveformClicked()
|
||||||
|
fun onVoicePlaybackButtonClicked()
|
||||||
|
}
|
||||||
|
}
|
@ -212,7 +212,7 @@
|
|||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
tools:visibility="visible" />
|
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:id="@+id/voiceMessageRecorderView"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
@ -95,6 +95,7 @@
|
|||||||
|
|
||||||
<!-- Slide to cancel text should go under this view -->
|
<!-- Slide to cancel text should go under this view -->
|
||||||
<View
|
<View
|
||||||
|
android:id="@+id/voiceMessageSlideToCancelDivider"
|
||||||
android:layout_width="48dp"
|
android:layout_width="48dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:background="?android:colorBackground"
|
android:background="?android:colorBackground"
|
||||||
@ -135,11 +136,10 @@
|
|||||||
android:id="@+id/voiceMessagePlaybackLayout"
|
android:id="@+id/voiceMessagePlaybackLayout"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="44dp"
|
android:layout_height="44dp"
|
||||||
android:layout_marginEnd="16dp"
|
|
||||||
android:layout_marginBottom="4dp"
|
android:layout_marginBottom="4dp"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toStartOf="@id/voiceMessageMicButton"
|
app:layout_constraintEnd_toStartOf="@id/voiceMessageSendButton"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
tools:layout_marginBottom="120dp"
|
tools:layout_marginBottom="120dp"
|
||||||
tools:visibility="visible">
|
tools:visibility="visible">
|
||||||
|
Loading…
Reference in New Issue
Block a user