Issue 2790, allows for continuous voice recording on swipe up, and for previewing messages

- I did a bunch of UI logic in ChatActivity and view_message_input, mainly in setting up the recording interface
- I created a custom component, MicInputCloud, under the hood it's 3 ovals, with a hole cut in the center for the icon. The ovals are at around 50% opacity, and they each have their own rotations and size changes animated. General rotation speed and colors can be overridden by the activity implementing it.
- I also added a floating action button to activity_chat, to show when the voice recording is locked or not.
- I can replay or pause preview voice recordings before sending or deleting
- Preview voice recording is now smoother and click boxes are bigger and well defined
Signed-off-by: Julius Linus <julius.linus@nextcloud.com>
This commit is contained in:
Julius Linus 2023-06-29 13:39:39 -05:00 committed by Andy Scherzinger
parent a4e7a278cf
commit f0ba16a275
No known key found for this signature in database
GPG Key ID: 6CADC7E3523C308B
8 changed files with 969 additions and 68 deletions

View File

@ -29,6 +29,7 @@
package com.nextcloud.talk.chat
import android.Manifest
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
@ -41,6 +42,8 @@ import android.database.Cursor
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaPlayer
import android.media.MediaRecorder
import android.net.Uri
@ -63,15 +66,21 @@ import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.view.View.OnTouchListener
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.AccelerateInterpolator
import android.view.animation.AlphaAnimation
import android.view.animation.Animation
import android.view.animation.LinearInterpolator
import android.widget.AbsListView
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.RelativeLayout
import android.widget.RelativeLayout.BELOW
import android.widget.RelativeLayout.LayoutParams
import android.widget.SeekBar
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
@ -79,6 +88,7 @@ import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.PermissionChecker
import androidx.core.content.PermissionChecker.PERMISSION_GRANTED
import androidx.core.graphics.drawable.toBitmap
import androidx.core.text.bold
import androidx.core.widget.doAfterTextChanged
@ -100,6 +110,7 @@ import coil.request.ImageRequest
import coil.target.Target
import coil.transform.CircleCropTransformation
import com.google.android.flexbox.FlexboxLayout
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import com.nextcloud.talk.BuildConfig
@ -166,6 +177,7 @@ import com.nextcloud.talk.shareditems.activities.SharedItemsActivity
import com.nextcloud.talk.signaling.SignalingMessageReceiver
import com.nextcloud.talk.signaling.SignalingMessageSender
import com.nextcloud.talk.translate.ui.TranslateActivity
import com.nextcloud.talk.ui.MicInputCloud
import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet
import com.nextcloud.talk.ui.dialog.AttachmentDialog
import com.nextcloud.talk.ui.dialog.MessageActionsDialog
@ -233,6 +245,8 @@ import javax.inject.Inject
import kotlin.collections.set
import kotlin.math.roundToInt
private const val l = 50
@AutoInjector(NextcloudTalkApplication::class)
class ChatActivity :
BaseActivity(),
@ -312,13 +326,22 @@ class ChatActivity :
private lateinit var sharedText: String
var isVoiceRecordingInProgress: Boolean = false
var currentVoiceRecordFile: String = ""
var isVoiceRecordingLocked: Boolean = false
private var isVoicePreviewPlaying: Boolean = false
private var recorder: MediaRecorder? = null
private var voicePreviewMediaPlayer: MediaPlayer? = null
private var voicePreviewObjectAnimator: ObjectAnimator? = null
var mediaPlayer: MediaPlayer? = null
lateinit var mediaPlayerHandler: Handler
private var currentlyPlayedVoiceMessage: ChatMessage? = null
private lateinit var micInputAudioRecorder: AudioRecord
private var micInputAudioRecordThread: Thread? = null
private var isMicInputAudioThreadRunning: Boolean = false
private val BUFFER_SIZE = AudioRecord.getMinBufferSize(
8000,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
private lateinit var participantPermissions: ParticipantPermissions
private var videoURI: Uri? = null
@ -461,6 +484,16 @@ class ChatActivity :
override fun onStop() {
super.onStop()
active = false
stopPreviewVoicePlaying()
if (isMicInputAudioThreadRunning) {
stopMicInputRecordingAnimation()
}
if (isVoiceRecordingInProgress) {
stopAudioRecording()
}
if (currentlyPlayedVoiceMessage != null) {
stopMediaPlayer(currentlyPlayedVoiceMessage!!)
}
}
@Suppress("LongMethod")
@ -593,6 +626,33 @@ class ChatActivity :
.themeImageButton(it)
}
binding.messageInputView.findViewById<MaterialButton>(R.id.playPauseBtn)?.let {
viewThemeUtils.material.colorMaterialButtonText(it)
}
binding.messageInputView.findViewById<SeekBar>(R.id.seekbar)?.let {
viewThemeUtils.platform.themeHorizontalSeekBar(it)
}
binding.messageInputView.findViewById<ImageView>(R.id.deleteVoiceRecording)?.let {
viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY)
}
binding.messageInputView.findViewById<ImageView>(R.id.sendVoiceRecording)?.let {
viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY)
}
binding.messageInputView.findViewById<ImageView>(R.id.microphoneEnabledInfo)?.let {
viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY)
}
binding.messageInputView.findViewById<LinearLayout>(R.id.voice_preview_container)?.let {
viewThemeUtils.talk.themeOutgoingMessageBubble(it, true, false)
}
binding.messageInputView.findViewById<MicInputCloud>(R.id.micInputCloud)?.let {
viewThemeUtils.talk.themeMicInputCloud(it)
}
cancelNotificationsForCurrentConversation()
chatViewModel.getRoom(conversationUser!!, roomToken)
@ -626,6 +686,8 @@ class ChatActivity :
binding.let { viewThemeUtils.material.colorMaterialButtonPrimaryTonal(it.scrollDownButton) }
binding.let { viewThemeUtils.material.themeFAB(it.voiceRecordingLock) }
binding.let { viewThemeUtils.material.colorMaterialButtonPrimaryFilled(it.popupBubbleView) }
binding.messageInputView.setPadding(0, 0, 0, 0)
@ -890,10 +952,18 @@ class ChatActivity :
@SuppressLint("ClickableViewAccessibility")
private fun initVoiceRecordButton() {
showMicrophoneButton(true)
if (!isVoiceRecordingLocked) {
showMicrophoneButton(true)
} else if (isVoiceRecordingInProgress) {
binding.messageInputView.playPauseBtn.visibility = View.GONE
binding.messageInputView.seekBar.visibility = View.GONE
} else {
binding.messageInputView.micInputCloud.setState(MicInputCloud.ViewState.PAUSED_STATE)
}
binding.messageInputView.messageInput?.doAfterTextChanged {
if (binding.messageInputView.messageInput?.text?.isEmpty() == true) {
isVoicePreviewPlaying = false
binding.messageInputView.messageInput.doAfterTextChanged {
if (binding.messageInputView.messageInput.text?.isEmpty() == true) {
showMicrophoneButton(true)
} else {
showMicrophoneButton(false)
@ -902,12 +972,103 @@ class ChatActivity :
var sliderInitX = 0F
var downX = 0f
var originY = 0f
var deltaX = 0f
var deltaY = 0f
var voiceRecordStartTime = 0L
var voiceRecordEndTime = 0L
var voiceRecordPauseTime = 0L
val micInputCloudLayoutParams: LayoutParams = binding.messageInputView.micInputCloud
.layoutParams as LayoutParams
binding.messageInputView.recordAudioButton?.setOnTouchListener(object : View.OnTouchListener {
val deleteVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.deleteVoiceRecording
.layoutParams as LayoutParams
val sendVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.sendVoiceRecording
.layoutParams as LayoutParams
// this is so that the seekbar is no longer draggable
binding.messageInputView.seekBar.setOnTouchListener(OnTouchListener { _, _ -> true })
binding.messageInputView.micInputCloud.setOnClickListener {
if (isVoiceRecordingInProgress) {
recorder?.stop()
stopMicInputRecordingAnimation()
voiceRecordPauseTime = binding.messageInputView.audioRecordDuration.base - SystemClock.elapsedRealtime()
binding.messageInputView.audioRecordDuration.stop()
binding.messageInputView.audioRecordDuration.visibility = View.GONE
binding.messageInputView.playPauseBtn.visibility = View.VISIBLE
binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable(
context,
R.drawable.ic_baseline_play_arrow_voice_message_24
)
binding.messageInputView.seekBar.visibility = View.VISIBLE
binding.messageInputView.seekBar.progress = 0
binding.messageInputView.seekBar.max = 0
micInputCloudLayoutParams.removeRule(BELOW)
micInputCloudLayoutParams.addRule(BELOW, R.id.voice_preview_container)
deleteVoiceRecordingLayoutParams.removeRule(BELOW)
deleteVoiceRecordingLayoutParams.addRule(BELOW, R.id.voice_preview_container)
sendVoiceRecordingLayoutParams.removeRule(BELOW)
sendVoiceRecordingLayoutParams.addRule(BELOW, R.id.voice_preview_container)
} else {
restartAudio()
startMicInputRecordingAnimation()
binding.messageInputView.audioRecordDuration.base = SystemClock.elapsedRealtime()
binding.messageInputView.audioRecordDuration.start()
binding.messageInputView.playPauseBtn.visibility = View.GONE
binding.messageInputView.seekBar.visibility = View.GONE
binding.messageInputView.audioRecordDuration.visibility = View.VISIBLE
micInputCloudLayoutParams.removeRule(BELOW)
micInputCloudLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
deleteVoiceRecordingLayoutParams.removeRule(BELOW)
deleteVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
sendVoiceRecordingLayoutParams.removeRule(BELOW)
sendVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
}
isVoiceRecordingInProgress = !isVoiceRecordingInProgress
}
binding.messageInputView.deleteVoiceRecording.setOnClickListener {
stopAndDiscardAudioRecording()
endVoiceRecordingUI()
stopMicInputRecordingAnimation()
binding.messageInputView.slideToCancelDescription.x = sliderInitX
}
binding.messageInputView.sendVoiceRecording.setOnClickListener {
stopAndSendAudioRecording()
endVoiceRecordingUI()
stopMicInputRecordingAnimation()
binding.messageInputView.slideToCancelDescription.x = sliderInitX
}
binding.messageInputView.playPauseBtn.setOnClickListener {
Log.d(TAG, "is voice preview playing $isVoicePreviewPlaying")
if (isVoicePreviewPlaying) {
Log.d(TAG, "Paused")
pausePreviewVoicePlaying()
binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable(
context,
R.drawable
.ic_baseline_play_arrow_voice_message_24
)
isVoicePreviewPlaying = false
} else {
Log.d(TAG, "Started")
startPreviewVoicePlaying()
binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable(
context,
R.drawable
.ic_baseline_pause_voice_message_24
)
isVoicePreviewPlaying = true
}
}
binding.messageInputView.recordAudioButton.setOnTouchListener(object : OnTouchListener {
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
v?.performClick() // ?????????
when (event?.action) {
@ -926,6 +1087,7 @@ class ChatActivity :
setVoiceRecordFileName()
startAudioRecording(currentVoiceRecordFile)
downX = event.x
originY = event.y
showRecordAudioUi(true)
}
@ -936,13 +1098,16 @@ class ChatActivity :
}
stopAndDiscardAudioRecording()
showRecordAudioUi(false)
binding.messageInputView.slideToCancelDescription?.x = sliderInitX
endVoiceRecordingUI()
binding.messageInputView.slideToCancelDescription.x = sliderInitX
}
MotionEvent.ACTION_UP -> {
Log.d(TAG, "ACTION_UP. stop recording??")
if (!isVoiceRecordingInProgress || !isRecordAudioPermissionGranted()) {
if (!isVoiceRecordingInProgress ||
!isRecordAudioPermissionGranted() ||
isVoiceRecordingLocked
) {
return true
}
showRecordAudioUi(false)
@ -964,7 +1129,7 @@ class ChatActivity :
stopAndSendAudioRecording()
}
binding.messageInputView.slideToCancelDescription?.x = sliderInitX
binding.messageInputView.slideToCancelDescription.x = sliderInitX
}
MotionEvent.ACTION_MOVE -> {
@ -977,28 +1142,41 @@ class ChatActivity :
showRecordAudioUi(true)
val movedX: Float = event.x
val movedY: Float = event.y
deltaX = movedX - downX
deltaY = movedY - originY
binding.voiceRecordingLock.translationY.let {
if (it < VOICE_RECORD_LOCK_BUTTON_Y) {
Log.d(TAG, "Voice Recording Locked")
isVoiceRecordingLocked = true
showVoiceRecordingLocked(true)
showVoiceRecordingLockedInterface(true)
} else if (deltaY < 0f) {
binding.voiceRecordingLock.translationY = deltaY
}
}
// only allow slide to left
binding.messageInputView.slideToCancelDescription?.x?.let {
binding.messageInputView.slideToCancelDescription.x.let {
if (sliderInitX == 0.0F) {
sliderInitX = it
}
if (it > sliderInitX) {
binding.messageInputView.slideToCancelDescription?.x = sliderInitX
binding.messageInputView.slideToCancelDescription.x = sliderInitX
}
}
binding.messageInputView.slideToCancelDescription?.x?.let {
binding.messageInputView.slideToCancelDescription.x.let {
if (it < VOICE_RECORD_CANCEL_SLIDER_X) {
Log.d(TAG, "stopping recording because slider was moved to left")
stopAndDiscardAudioRecording()
showRecordAudioUi(false)
binding.messageInputView.slideToCancelDescription?.x = sliderInitX
endVoiceRecordingUI()
binding.messageInputView.slideToCancelDescription.x = sliderInitX
return true
} else {
binding.messageInputView.slideToCancelDescription?.x = it + deltaX
binding.messageInputView.slideToCancelDescription.x = it + deltaX
downX = movedX
}
}
@ -1010,6 +1188,161 @@ class ChatActivity :
})
}
private fun initPreviewVoiceRecording() {
voicePreviewMediaPlayer = MediaPlayer().apply {
Log.e(TAG, currentVoiceRecordFile)
setDataSource(currentVoiceRecordFile)
prepare()
setOnPreparedListener {
Log.d(TAG, "Julius the duration is ${it.duration}")
binding.messageInputView.seekBar.progress = 0
binding.messageInputView.seekBar.max = it.duration
voicePreviewObjectAnimator = ObjectAnimator.ofInt(
binding.messageInputView.seekBar,
"progress",
0,
it.duration
).apply {
duration = it.duration.toLong()
interpolator = LinearInterpolator()
}
voicePreviewMediaPlayer!!.start()
voicePreviewObjectAnimator!!.start()
}
setOnCompletionListener {
stopPreviewVoicePlaying()
}
}
}
private fun startPreviewVoicePlaying() {
Log.d(TAG, "started preview voice recording")
if (voicePreviewMediaPlayer == null) {
initPreviewVoiceRecording()
} else {
voicePreviewMediaPlayer!!.start()
voicePreviewObjectAnimator!!.resume()
}
}
private fun pausePreviewVoicePlaying() {
Log.d(TAG, "paused preview voice recording")
voicePreviewMediaPlayer!!.pause()
voicePreviewObjectAnimator!!.pause()
}
private fun stopPreviewVoicePlaying() {
if (voicePreviewMediaPlayer != null) {
isVoicePreviewPlaying = false
binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable(context, R.drawable.ic_refresh)
voicePreviewObjectAnimator!!.end()
voicePreviewObjectAnimator = null
binding.messageInputView.seekBar.clearAnimation()
voicePreviewMediaPlayer!!.stop()
voicePreviewMediaPlayer!!.release()
voicePreviewMediaPlayer = null
}
}
private fun restartAudio() {
recorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setOutputFile(currentVoiceRecordFile)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioSamplingRate(VOICE_MESSAGE_SAMPLING_RATE)
setAudioEncodingBitRate(VOICE_MESSAGE_ENCODING_BIT_RATE)
setAudioChannels(VOICE_MESSAGE_CHANNELS)
prepare()
start()
}
}
private fun endVoiceRecordingUI() {
stopPreviewVoicePlaying()
showRecordAudioUi(false)
binding.voiceRecordingLock.translationY = 0f
isVoiceRecordingLocked = false
showVoiceRecordingLocked(false)
showVoiceRecordingLockedInterface(false)
}
private fun showVoiceRecordingLocked(value: Boolean) {
if (value) {
binding.voiceRecordingLock.setImageDrawable(
ContextCompat.getDrawable(context, R.drawable.ic_lock_grey600_24px)
)
binding.voiceRecordingLock.alpha = 1f
binding.voiceRecordingLock.animate().alpha(0f).setDuration(500)
.setInterpolator(AccelerateInterpolator()).start()
} else {
binding.voiceRecordingLock.setImageDrawable(
ContextCompat.getDrawable(context, R.drawable.ic_lock_open_grey600_24dp)
)
binding.voiceRecordingLock.alpha = 1f
}
}
private fun showVoiceRecordingLockedInterface(value: Boolean) {
val audioDurationLayoutParams: LayoutParams = binding.messageInputView.audioRecordDuration
.layoutParams as LayoutParams
val micInputCloudLayoutParams: LayoutParams = binding.messageInputView.micInputCloud
.layoutParams as LayoutParams
val deleteVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.deleteVoiceRecording
.layoutParams as LayoutParams
val sendVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.sendVoiceRecording
.layoutParams as LayoutParams
val standardQuarterMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
resources.getDimension(R.dimen.standard_quarter_margin),
resources
.displayMetrics
).toInt()
binding.messageInputView.button.isEnabled = true
if (value) {
binding.messageInputView.slideToCancelDescription.visibility = View.GONE
binding.messageInputView.deleteVoiceRecording.visibility = View.VISIBLE
binding.messageInputView.sendVoiceRecording.visibility = View.VISIBLE
binding.messageInputView.micInputCloud.visibility = View.VISIBLE
binding.messageInputView.recordAudioButton.visibility = View.GONE
binding.messageInputView.microphoneEnabledInfo.clearAnimation()
binding.messageInputView.microphoneEnabledInfo.visibility = View.GONE
binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.GONE
binding.messageInputView.recordAudioButton.visibility = View.GONE
micInputCloudLayoutParams.removeRule(BELOW)
micInputCloudLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
deleteVoiceRecordingLayoutParams.removeRule(BELOW)
deleteVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
sendVoiceRecordingLayoutParams.removeRule(BELOW)
sendVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
audioDurationLayoutParams.removeRule(RelativeLayout.CENTER_VERTICAL)
audioDurationLayoutParams.removeRule(RelativeLayout.END_OF)
audioDurationLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL, R.bool.value_true)
audioDurationLayoutParams.setMargins(0, standardQuarterMargin, 0, 0)
startMicInputRecordingAnimation()
Log.d(TAG, "MicInputRecording Started")
} else {
stopMicInputRecordingAnimation()
binding.messageInputView.deleteVoiceRecording.visibility = View.GONE
binding.messageInputView.micInputCloud.visibility = View.GONE
binding.messageInputView.recordAudioButton.visibility = View.VISIBLE
binding.messageInputView.sendVoiceRecording.visibility = View.GONE
binding.messageInputView.playPauseBtn.visibility = View.GONE
binding.messageInputView.seekBar.visibility = View.GONE
audioDurationLayoutParams.addRule(RelativeLayout.CENTER_VERTICAL, R.bool.value_true)
audioDurationLayoutParams.addRule(RelativeLayout.END_OF, R.id.microphoneEnabledInfo)
audioDurationLayoutParams.removeRule(RelativeLayout.CENTER_HORIZONTAL)
audioDurationLayoutParams.setMargins(0, 0, 0, 0)
}
}
private fun initSmileyKeyboardToggler() {
val smileyButton = binding.messageInputView.findViewById<ImageButton>(R.id.smileyButton)
@ -1452,6 +1785,7 @@ class ChatActivity :
try {
mediaPlayer?.let {
if (it.isPlaying) {
Log.d(TAG, "media player is stopped")
it.stop()
}
}
@ -1546,24 +1880,83 @@ class ChatActivity :
private fun showRecordAudioUi(show: Boolean) {
if (show) {
binding.messageInputView.microphoneEnabledInfo?.visibility = View.VISIBLE
binding.messageInputView.microphoneEnabledInfoBackground?.visibility = View.VISIBLE
binding.messageInputView.audioRecordDuration?.visibility = View.VISIBLE
binding.messageInputView.slideToCancelDescription?.visibility = View.VISIBLE
binding.messageInputView.attachmentButton?.visibility = View.GONE
binding.messageInputView.smileyButton?.visibility = View.GONE
binding.messageInputView.messageInput?.visibility = View.GONE
binding.messageInputView.messageInput?.hint = ""
binding.messageInputView.microphoneEnabledInfo.visibility = View.VISIBLE
binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.VISIBLE
binding.messageInputView.audioRecordDuration.visibility = View.VISIBLE
binding.messageInputView.slideToCancelDescription.visibility = View.VISIBLE
binding.messageInputView.attachmentButton.visibility = View.GONE
binding.messageInputView.smileyButton.visibility = View.GONE
binding.messageInputView.messageInput.visibility = View.GONE
binding.messageInputView.messageInput.hint = ""
binding.voiceRecordingLock.visibility = View.VISIBLE
} else {
binding.messageInputView.microphoneEnabledInfo?.visibility = View.GONE
binding.messageInputView.microphoneEnabledInfoBackground?.visibility = View.GONE
binding.messageInputView.audioRecordDuration?.visibility = View.GONE
binding.messageInputView.slideToCancelDescription?.visibility = View.GONE
binding.messageInputView.attachmentButton?.visibility = View.VISIBLE
binding.messageInputView.smileyButton?.visibility = View.VISIBLE
binding.messageInputView.messageInput?.visibility = View.VISIBLE
binding.messageInputView.messageInput?.hint =
binding.messageInputView.microphoneEnabledInfo.visibility = View.GONE
binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.GONE
binding.messageInputView.audioRecordDuration.visibility = View.GONE
binding.messageInputView.slideToCancelDescription.visibility = View.GONE
binding.messageInputView.attachmentButton.visibility = View.VISIBLE
binding.messageInputView.smileyButton.visibility = View.VISIBLE
binding.messageInputView.messageInput.visibility = View.VISIBLE
binding.messageInputView.messageInput.hint =
context.resources?.getString(R.string.nc_hint_enter_a_message)
binding.voiceRecordingLock.visibility = View.GONE
}
}
private fun startMicInputRecordingAnimation() {
val permissionCheck = ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
)
if (micInputAudioRecordThread == null && permissionCheck == PERMISSION_GRANTED) {
Log.d(TAG, "Mic Animation Started")
micInputAudioRecorder = AudioRecord(
MediaRecorder.AudioSource.MIC,
8000,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
BUFFER_SIZE
)
isMicInputAudioThreadRunning = true
micInputAudioRecorder.startRecording()
micInputAudioRecordThread = Thread(
Runnable {
while (isMicInputAudioThreadRunning) {
val byteArr = ByteArray(BUFFER_SIZE / 2)
micInputAudioRecorder.read(byteArr, 0, byteArr.size)
val d = Math.abs(byteArr[0].toDouble())
if (d > 40) {
binding.messageInputView.micInputCloud.setRotationSpeed(
Math.log10(d).toFloat(),
MicInputCloud.MAXIMUM_RADIUS
)
} else if (d > 20) {
binding.messageInputView.micInputCloud.setRotationSpeed(
Math.log10(d).toFloat(),
MicInputCloud.EXTENDED_RADIUS
)
} else {
binding.messageInputView.micInputCloud.setRotationSpeed(
1f,
MicInputCloud.DEFAULT_RADIUS
)
}
Thread.sleep(50)
}
}
)
micInputAudioRecordThread!!.start()
}
}
private fun stopMicInputRecordingAnimation() {
if (micInputAudioRecordThread != null) {
Log.d(TAG, "Mic Animation Ended")
micInputAudioRecorder.stop()
micInputAudioRecorder.release()
isMicInputAudioThreadRunning = false
micInputAudioRecordThread = null
}
}
@ -1571,19 +1964,19 @@ class ChatActivity :
return PermissionChecker.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
) == PermissionChecker.PERMISSION_GRANTED
) == PERMISSION_GRANTED
}
private fun startAudioRecording(file: String) {
binding.messageInputView.audioRecordDuration?.base = SystemClock.elapsedRealtime()
binding.messageInputView.audioRecordDuration?.start()
binding.messageInputView.audioRecordDuration.base = SystemClock.elapsedRealtime()
binding.messageInputView.audioRecordDuration.start()
val animation: Animation = AlphaAnimation(1.0f, 0.0f)
animation.duration = ANIMATION_DURATION
animation.interpolator = LinearInterpolator()
animation.repeatCount = Animation.INFINITE
animation.repeatMode = Animation.REVERSE
binding.messageInputView.microphoneEnabledInfo?.startAnimation(animation)
binding.messageInputView.microphoneEnabledInfo.startAnimation(animation)
recorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
@ -1612,13 +2005,18 @@ class ChatActivity :
}
private fun stopAndSendAudioRecording() {
stopAudioRecording()
if (isVoiceRecordingInProgress) {
stopAudioRecording()
}
val uri = Uri.fromFile(File(currentVoiceRecordFile))
uploadFile(uri.toString(), true)
}
private fun stopAndDiscardAudioRecording() {
stopAudioRecording()
if (isVoiceRecordingInProgress) {
stopAudioRecording()
}
val cachedFile = File(currentVoiceRecordFile)
cachedFile.delete()
@ -1626,26 +2024,22 @@ class ChatActivity :
@Suppress("Detekt.TooGenericExceptionCaught")
private fun stopAudioRecording() {
binding.messageInputView.audioRecordDuration?.stop()
binding.messageInputView.microphoneEnabledInfo?.clearAnimation()
binding.messageInputView.audioRecordDuration.stop()
binding.messageInputView.microphoneEnabledInfo.clearAnimation()
if (isVoiceRecordingInProgress) {
recorder?.apply {
try {
stop()
release()
isVoiceRecordingInProgress = false
Log.d(TAG, "stopped recorder. isVoiceRecordingInProgress = false")
} catch (e: RuntimeException) {
Log.w(TAG, "error while stopping recorder!")
}
VibrationUtils.vibrateShort(context)
recorder?.apply {
try {
stop()
release()
isVoiceRecordingInProgress = false
Log.d(TAG, "stopped recorder. isVoiceRecordingInProgress = false")
} catch (e: RuntimeException) {
Log.w(TAG, "error while stopping recorder!")
}
recorder = null
} else {
Log.e(TAG, "tried to stop audio recorder but it was not recording")
VibrationUtils.vibrateShort(context)
}
recorder = null
}
private fun requestRecordAudioPermissions() {
@ -1761,7 +2155,7 @@ class ChatActivity :
ConversationUtils.isLobbyViewApplicable(currentConversation!!, conversationUser!!)
) {
if (shouldShowLobby()) {
binding.lobby.lobbyView?.visibility = View.VISIBLE
binding.lobby.lobbyView.visibility = View.VISIBLE
binding.messagesListView.visibility = View.GONE
binding.messageInputView.visibility = View.GONE
binding.progressBar.visibility = View.GONE
@ -1785,9 +2179,9 @@ class ChatActivity :
}
sb.append(currentConversation!!.description)
binding.lobby.lobbyTextView?.text = sb.toString()
binding.lobby.lobbyTextView.text = sb.toString()
} else {
binding.lobby.lobbyView?.visibility = View.GONE
binding.lobby.lobbyView.visibility = View.GONE
binding.messagesListView.visibility = View.VISIBLE
binding.messageInputView.inputEditText?.visibility = View.VISIBLE
if (isFirstMessagesProcessing && pastPreconditionFailed) {
@ -1799,7 +2193,7 @@ class ChatActivity :
}
}
} else {
binding.lobby.lobbyView?.visibility = View.GONE
binding.lobby.lobbyView.visibility = View.GONE
binding.messagesListView.visibility = View.VISIBLE
binding.messageInputView.inputEditText?.visibility = View.VISIBLE
}
@ -2774,7 +3168,7 @@ class ChatActivity :
private fun modifyMessageCount(shouldAddNewMessagesNotice: Boolean, shouldScroll: Boolean) {
if (!shouldAddNewMessagesNotice && !shouldScroll) {
binding.popupBubbleView.isShown?.let {
binding.popupBubbleView.isShown.let {
if (it) {
newMessagesCount++
} else {
@ -3455,11 +3849,11 @@ class ChatActivity :
private fun showMicrophoneButton(show: Boolean) {
if (show && CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "voice-message-sharing")) {
binding.messageInputView.messageSendButton?.visibility = View.GONE
binding.messageInputView.recordAudioButton?.visibility = View.VISIBLE
binding.messageInputView.messageSendButton.visibility = View.GONE
binding.messageInputView.recordAudioButton.visibility = View.VISIBLE
} else {
binding.messageInputView.messageSendButton?.visibility = View.VISIBLE
binding.messageInputView.recordAudioButton?.visibility = View.GONE
binding.messageInputView.messageSendButton.visibility = View.VISIBLE
binding.messageInputView.recordAudioButton.visibility = View.GONE
}
}
@ -3727,6 +4121,7 @@ class ChatActivity :
private const val OBJECT_MESSAGE: String = "{object}"
private const val MINIMUM_VOICE_RECORD_DURATION: Int = 1000
private const val VOICE_RECORD_CANCEL_SLIDER_X: Int = -50
private const val VOICE_RECORD_LOCK_BUTTON_Y: Int = -130
private const val VOICE_MESSAGE_META_DATA = "{\"messageType\":\"voice-message\"}"
private const val VOICE_MESSAGE_FILE_SUFFIX = ".mp3"

View File

@ -25,8 +25,10 @@ import android.util.AttributeSet
import android.widget.Chronometer
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.SeekBar
import android.widget.TextView
import androidx.emoji2.widget.EmojiEditText
import com.google.android.material.button.MaterialButton
import com.nextcloud.talk.R
import com.stfalcon.chatkit.messages.MessageInput
@ -37,6 +39,11 @@ class MessageInput : MessageInput {
lateinit var microphoneEnabledInfo: ImageView
lateinit var microphoneEnabledInfoBackground: ImageView
lateinit var smileyButton: ImageButton
lateinit var deleteVoiceRecording: ImageView
lateinit var sendVoiceRecording: ImageView
lateinit var micInputCloud: MicInputCloud
lateinit var playPauseBtn: MaterialButton
lateinit var seekBar: SeekBar
constructor(context: Context?) : super(context) {
init()
@ -57,6 +64,11 @@ class MessageInput : MessageInput {
microphoneEnabledInfo = findViewById(R.id.microphoneEnabledInfo)
microphoneEnabledInfoBackground = findViewById(R.id.microphoneEnabledInfoBackground)
smileyButton = findViewById(R.id.smileyButton)
deleteVoiceRecording = findViewById(R.id.deleteVoiceRecording)
sendVoiceRecording = findViewById(R.id.sendVoiceRecording)
micInputCloud = findViewById(R.id.micInputCloud)
playPauseBtn = findViewById(R.id.playPauseBtn)
seekBar = findViewById(R.id.seekbar)
}
var messageInput: EmojiEditText

View File

@ -0,0 +1,369 @@
/*
* Nextcloud Talk application
*
* @author Julius Linus
* Copyright (C) 2023 Julius Linus <julius.linus@nextcloud.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.talk.ui
import android.animation.ValueAnimator
import android.content.Context
import android.content.res.Resources
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Paint.ANTI_ALIAS_FLAG
import android.graphics.Path
import android.graphics.Rect
import android.graphics.drawable.VectorDrawable
import android.util.AttributeSet
import android.view.View
import android.view.animation.LinearInterpolator
import androidx.annotation.ColorInt
import com.nextcloud.talk.R
import kotlin.math.roundToInt
class MicInputCloud(context: Context, attrs: AttributeSet) : View(context, attrs) {
/**
* State Descriptions:
* - PAUSED_STATE: Animation speed is set to zero
* - PLAY_STATE: Animation speed is set to default, but can be overridden
*/
enum class ViewState {
PAUSED_STATE,
PLAY_STATE
}
@ColorInt
private var primaryColor: Int = Color.WHITE
private var pauseIcon: VectorDrawable? = null
private var playIcon: VectorDrawable? = null
init {
context.theme.obtainStyledAttributes(
attrs,
R.styleable.MicInputCloud,
0,
0
).apply {
try {
pauseIcon = getDrawable(R.styleable.MicInputCloud_pauseIcon) as VectorDrawable
playIcon = getDrawable(R.styleable.MicInputCloud_playIcon) as VectorDrawable
} finally {
recycle()
}
}
}
private var state: ViewState = ViewState.PLAY_STATE
private var ovalOneAnimator: ValueAnimator? = null
private var ovalTwoAnimator: ValueAnimator? = null
private var ovalThreeAnimator: ValueAnimator? = null
private var r1 = OVAL_ONE_DEFAULT_ROTATION
private var r2 = OVAL_TWO_DEFAULT_ROTATION
private var r3 = OVAL_THREE_DEFAULT_ROTATION
private var o1h = OVAL_ONE_DEFAULT_HEIGHT
private var o1w = OVAL_ONE_DEFAULT_WIDTH
private var o2h = OVAL_TWO_DEFAULT_HEIGHT
private var o2w = OVAL_TWO_DEFAULT_WIDTH
private var o3h = OVAL_THREE_DEFAULT_HEIGHT
private var o3w = OVAL_THREE_DEFAULT_WIDTH
private var rotationSpeedMultiplier: Float = DEFAULT_ROTATION_SPEED_MULTIPLIER
private var radius: Float = DEFAULT_RADIUS
private var centerX: Float = 0f
private var centerY: Float = 0f
private val bottomCirclePaint = Paint(ANTI_ALIAS_FLAG).apply {
color = primaryColor
style = Paint.Style.FILL
alpha = DEFAULT_OPACITY
}
private val topCircleBounds = Rect(0, 0, 0, 0)
override fun onVisibilityChanged(changedView: View, visibility: Int) {
super.onVisibilityChanged(changedView, visibility)
if (visibility == VISIBLE) {
createAnimators()
} else {
state = ViewState.PLAY_STATE
destroyAnimators()
}
}
private fun createAnimators() {
ovalOneAnimator = ValueAnimator.ofInt(
o1h,
OVAL_ONE_DEFAULT_HEIGHT + ANIMATION_CAP,
o1h
).apply {
duration = OVAL_ONE_ANIMATION_LENGTH
interpolator = LinearInterpolator()
repeatCount = ValueAnimator.INFINITE
addUpdateListener { valueAnimator ->
o1h = valueAnimator.animatedValue as Int
}
}
ovalTwoAnimator = ValueAnimator.ofInt(
o2h,
OVAL_TWO_DEFAULT_HEIGHT + ANIMATION_CAP,
o2h
).apply {
duration = OVAL_TWO_ANIMATION_LENGTH
interpolator = LinearInterpolator()
repeatCount = ValueAnimator.INFINITE
addUpdateListener { valueAnimator ->
o2h = valueAnimator.animatedValue as Int
}
}
ovalThreeAnimator = ValueAnimator.ofInt(
o3h,
OVAL_THREE_DEFAULT_HEIGHT + ANIMATION_CAP,
o3h
).apply {
duration = OVAL_THREE_ANIMATION_LENGTH
interpolator = LinearInterpolator()
repeatCount = ValueAnimator.INFINITE
addUpdateListener { valueAnimator ->
o3h = valueAnimator.animatedValue as Int
invalidate() // needed to animate the other listeners as well
}
}
ovalOneAnimator?.start()
ovalTwoAnimator?.start()
ovalThreeAnimator?.start()
}
private fun destroyAnimators() {
ovalOneAnimator?.cancel()
ovalOneAnimator?.removeAllUpdateListeners()
ovalTwoAnimator?.cancel()
ovalTwoAnimator?.removeAllUpdateListeners()
ovalThreeAnimator?.cancel()
ovalThreeAnimator?.removeAllUpdateListeners()
}
private val circlePath: Path = Path()
private val ovalOnePath: Path = Path()
private val ovalTwoPath: Path = Path()
private val ovalThreePath: Path = Path()
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
circlePath.apply {
addCircle(centerX, centerY, DEFAULT_RADIUS, Path.Direction.CCW)
}
ovalOnePath.apply {
addOval(
centerX - (radius + o1w),
centerY - o1h,
centerX + (radius + o1w),
centerY + o1h,
Path.Direction.CCW
)
op(this, circlePath, Path.Op.DIFFERENCE)
}
ovalTwoPath.apply {
addOval(
centerX - (radius + o2w),
centerY - o2h,
centerX + (radius + o2w),
centerY + o2h,
Path.Direction.CCW
)
op(this, circlePath, Path.Op.DIFFERENCE)
}
ovalThreePath.apply {
addOval(
centerX - (radius + o3w),
centerY - o3h,
centerX + (radius + o3w),
centerY + o3h,
Path.Direction.CCW
)
op(this, circlePath, Path.Op.DIFFERENCE)
}
drawMicInputCloud(canvas)
if (state == ViewState.PLAY_STATE) {
r1 += OVAL_ONE_ANIMATION_SPEED * rotationSpeedMultiplier
r2 -= OVAL_TWO_ANIMATION_SPEED * rotationSpeedMultiplier
r3 += OVAL_THREE_ANIMATION_SPEED * rotationSpeedMultiplier
invalidate()
}
}
private fun drawMicInputCloud(canvas: Canvas?) {
canvas?.apply {
save()
rotate(r1, centerX, centerY)
drawPath(ovalOnePath, bottomCirclePaint)
restore()
save()
rotate(r2, centerX, centerY)
drawPath(ovalTwoPath, bottomCirclePaint)
restore()
save()
rotate(r3, centerX, centerY)
drawPath(ovalThreePath, bottomCirclePaint)
restore()
circlePath.reset()
ovalOnePath.reset()
ovalTwoPath.reset()
ovalThreePath.reset()
if (state == ViewState.PLAY_STATE) {
pauseIcon?.apply {
bounds = topCircleBounds
setTint(primaryColor)
draw(canvas)
}
} else {
playIcon?.apply {
bounds = topCircleBounds
setTint(primaryColor)
draw(canvas)
}
}
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredWidth = DEFAULT_SIZE.dp
val desiredHeight = DEFAULT_SIZE.dp
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val width: Int = when (widthMode) {
MeasureSpec.EXACTLY -> {
widthSize
}
MeasureSpec.AT_MOST -> {
desiredWidth.coerceAtMost(widthSize)
}
else -> {
desiredWidth
}
}
val height: Int = when (heightMode) {
MeasureSpec.EXACTLY -> {
heightSize
}
MeasureSpec.AT_MOST -> {
desiredHeight.coerceAtMost(heightSize)
}
else -> {
desiredHeight
}
}
centerX = (width / 2).toFloat()
centerY = (height / 2).toFloat()
topCircleBounds.left = (centerX - DEFAULT_RADIUS).toInt()
topCircleBounds.top = (centerY - DEFAULT_RADIUS).toInt()
topCircleBounds.right = (centerX + DEFAULT_RADIUS).toInt()
topCircleBounds.bottom = (centerY + DEFAULT_RADIUS).toInt()
setMeasuredDimension(width, height)
}
override fun performClick(): Boolean {
state = if (state == ViewState.PAUSED_STATE) {
ovalOneAnimator?.resume()
ovalTwoAnimator?.resume()
ovalThreeAnimator?.resume()
ViewState.PLAY_STATE
} else {
ovalOneAnimator?.pause()
ovalTwoAnimator?.pause()
ovalThreeAnimator?.pause()
ViewState.PAUSED_STATE
}
invalidate()
return super.performClick()
}
/**
* Sets the color of the cloud to the parameter, opacity is still set to 50%
*/
fun setColor(primary: Int) {
primaryColor = primary
bottomCirclePaint.apply {
color = primary
style = Paint.Style.FILL
alpha = DEFAULT_OPACITY
}
invalidate()
}
/**
* Sets state of the component to the parameter
*/
fun setState(s: ViewState) {
state = s
invalidate()
}
/**
* Sets the rotation speed and radius to the parameters, defaults are left unchanged
*/
fun setRotationSpeed(speed: Float, r: Float) {
rotationSpeedMultiplier = speed
radius = r
invalidate()
}
companion object {
val TAG: String? = MicInputCloud::class.simpleName
const val DEFAULT_RADIUS: Float = 70f
const val EXTENDED_RADIUS: Float = 75f
const val MAXIMUM_RADIUS: Float = 80f
private const val DEFAULT_SIZE: Int = 110
private const val DEFAULT_OPACITY: Int = 108
private const val DEFAULT_ROTATION_SPEED_MULTIPLIER: Float = 0.5f
private const val OVAL_ONE_DEFAULT_ROTATION: Float = 105f
private const val OVAL_ONE_DEFAULT_HEIGHT: Int = 85
private const val OVAL_ONE_DEFAULT_WIDTH: Int = 30
private const val OVAL_ONE_ANIMATION_LENGTH: Long = 2000
private const val OVAL_ONE_ANIMATION_SPEED: Float = 2.3f
private const val OVAL_TWO_DEFAULT_ROTATION: Float = 138f
private const val OVAL_TWO_DEFAULT_HEIGHT: Int = 70
private const val OVAL_TWO_DEFAULT_WIDTH: Int = 25
private const val OVAL_TWO_ANIMATION_LENGTH: Long = 1000
private const val OVAL_TWO_ANIMATION_SPEED: Float = 1.75f
private const val OVAL_THREE_DEFAULT_ROTATION: Float = 63f
private const val OVAL_THREE_DEFAULT_HEIGHT: Int = 80
private const val OVAL_THREE_DEFAULT_WIDTH: Int = 40
private const val OVAL_THREE_ANIMATION_LENGTH: Long = 1500
private const val OVAL_THREE_ANIMATION_SPEED: Float = 1f
private const val ANIMATION_CAP: Int = 15
private val Int.dp: Int
get() = (this * Resources.getSystem().displayMetrics.density).roundToInt()
}
}

View File

@ -46,6 +46,7 @@ import com.nextcloud.android.common.ui.theme.MaterialSchemes
import com.nextcloud.android.common.ui.theme.ViewThemeUtilsBase
import com.nextcloud.android.common.ui.theme.utils.AndroidXViewThemeUtils
import com.nextcloud.talk.R
import com.nextcloud.talk.ui.MicInputCloud
import com.nextcloud.talk.utils.DisplayUtils
import com.nextcloud.talk.utils.DrawableUtils
import com.vanniktech.emoji.EmojiTextView
@ -243,6 +244,12 @@ class TalkSpecificViewThemeUtils @Inject constructor(
}
}
fun themeMicInputCloud(micInputCloud: MicInputCloud) {
withScheme(micInputCloud) { scheme ->
micInputCloud.setColor(scheme.primary)
}
}
companion object {
private val THEMEABLE_PLACEHOLDER_IDS = listOf(
R.drawable.ic_mimetype_package_x_generic,

View File

@ -0,0 +1,27 @@
<!--
@author Google LLC
Copyright (C) 2023 Google LLC
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.
-->
<vector android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M6,6h12v12H6z" />
</vector>

View File

@ -132,6 +132,18 @@
app:cornerRadius="@dimen/button_corner_radius"
app:icon="@drawable/ic_baseline_arrow_downward_24px" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/voice_recording_lock"
android:layout_width="48dp"
android:layout_height="48dp"
style="?attr/floatingActionButtonSmallStyle"
android:layout_marginEnd="@dimen/standard_margin"
android:layout_marginBottom="@dimen/standard_margin"
android:layout_alignParentBottom="true"
android:layout_alignParentEnd="true"
app:srcCompat="@drawable/ic_lock_open_grey600_24dp"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/scrollDownButton"
style="@style/Widget.AppTheme.Button.ElevatedButton"

View File

@ -109,6 +109,47 @@
tools:visibility="gone"
android:contentDescription="@null" />
<com.nextcloud.talk.ui.MicInputCloud
android:id="@+id/micInputCloud"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/audioRecordDuration"
android:layout_centerInParent="true"
app:playIcon="@drawable/ic_refresh"
app:pauseIcon="@drawable/baseline_stop_24"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:visibility="gone" />
<ImageView
android:id="@+id/deleteVoiceRecording"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_alignParentStart="true"
android:layout_alignBottom="@id/micInputCloud"
android:layout_below="@id/audioRecordDuration"
android:layout_marginVertical="@dimen/standard_margin"
android:layout_marginStart="@dimen/standard_double_margin"
android:scaleType="centerInside"
android:src="@drawable/ic_delete"
android:contentDescription="@null"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:visibility="gone" />
<ImageView
android:id="@+id/sendVoiceRecording"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_alignParentEnd="true"
android:layout_alignBottom="@id/micInputCloud"
android:layout_below="@id/audioRecordDuration"
android:layout_marginVertical="@dimen/standard_margin"
android:layout_marginEnd="@dimen/standard_double_margin"
android:scaleType="centerInside"
android:src="@drawable/ic_send"
android:contentDescription="@null"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:visibility="gone" />
<!-- the height of this ImageView is used to define the overall height of the
parent layout whenever the voice recording mode is enabled. parent layout has
height=wrap_content because it must enlarge whenever user types a message with
@ -123,7 +164,43 @@
android:src="@drawable/ic_baseline_mic_red_24"
android:contentDescription="@null"
android:visibility="gone"
tools:visibility="gone"/>
tools:visibility="gone" />
<LinearLayout
android:id="@+id/voice_preview_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/quotedChatMessageView"
android:layout_marginTop="@dimen/standard_margin"
android:gravity="center"
android:orientation="horizontal"
android:layout_marginHorizontal="@dimen/standard_margin"
android:background="@drawable/shape_grouped_outcoming_message">
<com.google.android.material.button.MaterialButton
android:id="@+id/playPauseBtn"
style="@style/Widget.AppTheme.Button.IconButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="@dimen/standard_margin"
android:contentDescription="@string/play_pause_voice_message"
android:visibility="gone"
app:cornerRadius="@dimen/button_corner_radius"
app:icon="@drawable/ic_baseline_play_arrow_voice_message_24"
app:iconSize="30dp"
app:iconTint="@color/high_emphasis_text"
app:rippleColor="#1FFFFFFF" />
<SeekBar
android:id="@+id/seekbar"
style="@style/Nextcloud.Material.Outgoing.SeekBar"
android:layout_width="match_parent"
android:layout_height="30dp"
android:layout_marginEnd="@dimen/standard_margin"
android:thumb="@drawable/voice_message_outgoing_seek_bar_slider"
android:visibility="gone"
tools:progress="50" />
</LinearLayout>
<Chronometer
android:id="@+id/audioRecordDuration"

View File

@ -20,7 +20,9 @@
-->
<resources>
<declare-styleable name="MaterialPreferenceCategory">
<attr name="mpc_action" format="string" />
<declare-styleable name="MicInputCloud">
<attr name="primaryColor" format="color" />
<attr name="pauseIcon" format="reference" />
<attr name="playIcon" format="reference" />
</declare-styleable>
</resources>