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 package com.nextcloud.talk.chat
import android.Manifest import android.Manifest
import android.animation.ObjectAnimator
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
@ -41,6 +42,8 @@ import android.database.Cursor
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaPlayer import android.media.MediaPlayer
import android.media.MediaRecorder import android.media.MediaRecorder
import android.net.Uri import android.net.Uri
@ -63,15 +66,21 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.View.OnTouchListener
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.AccelerateInterpolator
import android.view.animation.AlphaAnimation import android.view.animation.AlphaAnimation
import android.view.animation.Animation import android.view.animation.Animation
import android.view.animation.LinearInterpolator import android.view.animation.LinearInterpolator
import android.widget.AbsListView import android.widget.AbsListView
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.PopupMenu import android.widget.PopupMenu
import android.widget.RelativeLayout import android.widget.RelativeLayout
import android.widget.RelativeLayout.BELOW
import android.widget.RelativeLayout.LayoutParams
import android.widget.SeekBar
import android.widget.Toast import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
@ -79,6 +88,7 @@ import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.content.PermissionChecker import androidx.core.content.PermissionChecker
import androidx.core.content.PermissionChecker.PERMISSION_GRANTED
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.core.text.bold import androidx.core.text.bold
import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doAfterTextChanged
@ -100,6 +110,7 @@ import coil.request.ImageRequest
import coil.target.Target import coil.target.Target
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import com.google.android.flexbox.FlexboxLayout import com.google.android.flexbox.FlexboxLayout
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.android.common.ui.theme.utils.ColorRole
import com.nextcloud.talk.BuildConfig 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.SignalingMessageReceiver
import com.nextcloud.talk.signaling.SignalingMessageSender import com.nextcloud.talk.signaling.SignalingMessageSender
import com.nextcloud.talk.translate.ui.TranslateActivity 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.bottom.sheet.ProfileBottomSheet
import com.nextcloud.talk.ui.dialog.AttachmentDialog import com.nextcloud.talk.ui.dialog.AttachmentDialog
import com.nextcloud.talk.ui.dialog.MessageActionsDialog import com.nextcloud.talk.ui.dialog.MessageActionsDialog
@ -233,6 +245,8 @@ import javax.inject.Inject
import kotlin.collections.set import kotlin.collections.set
import kotlin.math.roundToInt import kotlin.math.roundToInt
private const val l = 50
@AutoInjector(NextcloudTalkApplication::class) @AutoInjector(NextcloudTalkApplication::class)
class ChatActivity : class ChatActivity :
BaseActivity(), BaseActivity(),
@ -312,13 +326,22 @@ class ChatActivity :
private lateinit var sharedText: String private lateinit var sharedText: String
var isVoiceRecordingInProgress: Boolean = false var isVoiceRecordingInProgress: Boolean = false
var currentVoiceRecordFile: String = "" var currentVoiceRecordFile: String = ""
var isVoiceRecordingLocked: Boolean = false
private var isVoicePreviewPlaying: Boolean = false
private var recorder: MediaRecorder? = null private var recorder: MediaRecorder? = null
private var voicePreviewMediaPlayer: MediaPlayer? = null
private var voicePreviewObjectAnimator: ObjectAnimator? = null
var mediaPlayer: MediaPlayer? = null var mediaPlayer: MediaPlayer? = null
lateinit var mediaPlayerHandler: Handler lateinit var mediaPlayerHandler: Handler
private var currentlyPlayedVoiceMessage: ChatMessage? = null 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 lateinit var participantPermissions: ParticipantPermissions
private var videoURI: Uri? = null private var videoURI: Uri? = null
@ -461,6 +484,16 @@ class ChatActivity :
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
active = false active = false
stopPreviewVoicePlaying()
if (isMicInputAudioThreadRunning) {
stopMicInputRecordingAnimation()
}
if (isVoiceRecordingInProgress) {
stopAudioRecording()
}
if (currentlyPlayedVoiceMessage != null) {
stopMediaPlayer(currentlyPlayedVoiceMessage!!)
}
} }
@Suppress("LongMethod") @Suppress("LongMethod")
@ -593,6 +626,33 @@ class ChatActivity :
.themeImageButton(it) .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() cancelNotificationsForCurrentConversation()
chatViewModel.getRoom(conversationUser!!, roomToken) chatViewModel.getRoom(conversationUser!!, roomToken)
@ -626,6 +686,8 @@ class ChatActivity :
binding.let { viewThemeUtils.material.colorMaterialButtonPrimaryTonal(it.scrollDownButton) } binding.let { viewThemeUtils.material.colorMaterialButtonPrimaryTonal(it.scrollDownButton) }
binding.let { viewThemeUtils.material.themeFAB(it.voiceRecordingLock) }
binding.let { viewThemeUtils.material.colorMaterialButtonPrimaryFilled(it.popupBubbleView) } binding.let { viewThemeUtils.material.colorMaterialButtonPrimaryFilled(it.popupBubbleView) }
binding.messageInputView.setPadding(0, 0, 0, 0) binding.messageInputView.setPadding(0, 0, 0, 0)
@ -890,10 +952,18 @@ class ChatActivity :
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
private fun initVoiceRecordButton() { 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 { isVoicePreviewPlaying = false
if (binding.messageInputView.messageInput?.text?.isEmpty() == true) { binding.messageInputView.messageInput.doAfterTextChanged {
if (binding.messageInputView.messageInput.text?.isEmpty() == true) {
showMicrophoneButton(true) showMicrophoneButton(true)
} else { } else {
showMicrophoneButton(false) showMicrophoneButton(false)
@ -902,12 +972,103 @@ class ChatActivity :
var sliderInitX = 0F var sliderInitX = 0F
var downX = 0f var downX = 0f
var originY = 0f
var deltaX = 0f var deltaX = 0f
var deltaY = 0f
var voiceRecordStartTime = 0L var voiceRecordStartTime = 0L
var voiceRecordEndTime = 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 { override fun onTouch(v: View?, event: MotionEvent?): Boolean {
v?.performClick() // ????????? v?.performClick() // ?????????
when (event?.action) { when (event?.action) {
@ -926,6 +1087,7 @@ class ChatActivity :
setVoiceRecordFileName() setVoiceRecordFileName()
startAudioRecording(currentVoiceRecordFile) startAudioRecording(currentVoiceRecordFile)
downX = event.x downX = event.x
originY = event.y
showRecordAudioUi(true) showRecordAudioUi(true)
} }
@ -936,13 +1098,16 @@ class ChatActivity :
} }
stopAndDiscardAudioRecording() stopAndDiscardAudioRecording()
showRecordAudioUi(false) endVoiceRecordingUI()
binding.messageInputView.slideToCancelDescription?.x = sliderInitX binding.messageInputView.slideToCancelDescription.x = sliderInitX
} }
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP -> {
Log.d(TAG, "ACTION_UP. stop recording??") Log.d(TAG, "ACTION_UP. stop recording??")
if (!isVoiceRecordingInProgress || !isRecordAudioPermissionGranted()) { if (!isVoiceRecordingInProgress ||
!isRecordAudioPermissionGranted() ||
isVoiceRecordingLocked
) {
return true return true
} }
showRecordAudioUi(false) showRecordAudioUi(false)
@ -964,7 +1129,7 @@ class ChatActivity :
stopAndSendAudioRecording() stopAndSendAudioRecording()
} }
binding.messageInputView.slideToCancelDescription?.x = sliderInitX binding.messageInputView.slideToCancelDescription.x = sliderInitX
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
@ -977,28 +1142,41 @@ class ChatActivity :
showRecordAudioUi(true) showRecordAudioUi(true)
val movedX: Float = event.x val movedX: Float = event.x
val movedY: Float = event.y
deltaX = movedX - downX 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 // only allow slide to left
binding.messageInputView.slideToCancelDescription?.x?.let { binding.messageInputView.slideToCancelDescription.x.let {
if (sliderInitX == 0.0F) { if (sliderInitX == 0.0F) {
sliderInitX = it sliderInitX = it
} }
if (it > sliderInitX) { 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) { if (it < VOICE_RECORD_CANCEL_SLIDER_X) {
Log.d(TAG, "stopping recording because slider was moved to left") Log.d(TAG, "stopping recording because slider was moved to left")
stopAndDiscardAudioRecording() stopAndDiscardAudioRecording()
showRecordAudioUi(false) endVoiceRecordingUI()
binding.messageInputView.slideToCancelDescription?.x = sliderInitX binding.messageInputView.slideToCancelDescription.x = sliderInitX
return true return true
} else { } else {
binding.messageInputView.slideToCancelDescription?.x = it + deltaX binding.messageInputView.slideToCancelDescription.x = it + deltaX
downX = movedX 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() { private fun initSmileyKeyboardToggler() {
val smileyButton = binding.messageInputView.findViewById<ImageButton>(R.id.smileyButton) val smileyButton = binding.messageInputView.findViewById<ImageButton>(R.id.smileyButton)
@ -1452,6 +1785,7 @@ class ChatActivity :
try { try {
mediaPlayer?.let { mediaPlayer?.let {
if (it.isPlaying) { if (it.isPlaying) {
Log.d(TAG, "media player is stopped")
it.stop() it.stop()
} }
} }
@ -1546,24 +1880,83 @@ class ChatActivity :
private fun showRecordAudioUi(show: Boolean) { private fun showRecordAudioUi(show: Boolean) {
if (show) { if (show) {
binding.messageInputView.microphoneEnabledInfo?.visibility = View.VISIBLE binding.messageInputView.microphoneEnabledInfo.visibility = View.VISIBLE
binding.messageInputView.microphoneEnabledInfoBackground?.visibility = View.VISIBLE binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.VISIBLE
binding.messageInputView.audioRecordDuration?.visibility = View.VISIBLE binding.messageInputView.audioRecordDuration.visibility = View.VISIBLE
binding.messageInputView.slideToCancelDescription?.visibility = View.VISIBLE binding.messageInputView.slideToCancelDescription.visibility = View.VISIBLE
binding.messageInputView.attachmentButton?.visibility = View.GONE binding.messageInputView.attachmentButton.visibility = View.GONE
binding.messageInputView.smileyButton?.visibility = View.GONE binding.messageInputView.smileyButton.visibility = View.GONE
binding.messageInputView.messageInput?.visibility = View.GONE binding.messageInputView.messageInput.visibility = View.GONE
binding.messageInputView.messageInput?.hint = "" binding.messageInputView.messageInput.hint = ""
binding.voiceRecordingLock.visibility = View.VISIBLE
} else { } else {
binding.messageInputView.microphoneEnabledInfo?.visibility = View.GONE binding.messageInputView.microphoneEnabledInfo.visibility = View.GONE
binding.messageInputView.microphoneEnabledInfoBackground?.visibility = View.GONE binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.GONE
binding.messageInputView.audioRecordDuration?.visibility = View.GONE binding.messageInputView.audioRecordDuration.visibility = View.GONE
binding.messageInputView.slideToCancelDescription?.visibility = View.GONE binding.messageInputView.slideToCancelDescription.visibility = View.GONE
binding.messageInputView.attachmentButton?.visibility = View.VISIBLE binding.messageInputView.attachmentButton.visibility = View.VISIBLE
binding.messageInputView.smileyButton?.visibility = View.VISIBLE binding.messageInputView.smileyButton.visibility = View.VISIBLE
binding.messageInputView.messageInput?.visibility = View.VISIBLE binding.messageInputView.messageInput.visibility = View.VISIBLE
binding.messageInputView.messageInput?.hint = binding.messageInputView.messageInput.hint =
context.resources?.getString(R.string.nc_hint_enter_a_message) 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( return PermissionChecker.checkSelfPermission(
context, context,
Manifest.permission.RECORD_AUDIO Manifest.permission.RECORD_AUDIO
) == PermissionChecker.PERMISSION_GRANTED ) == PERMISSION_GRANTED
} }
private fun startAudioRecording(file: String) { private fun startAudioRecording(file: String) {
binding.messageInputView.audioRecordDuration?.base = SystemClock.elapsedRealtime() binding.messageInputView.audioRecordDuration.base = SystemClock.elapsedRealtime()
binding.messageInputView.audioRecordDuration?.start() binding.messageInputView.audioRecordDuration.start()
val animation: Animation = AlphaAnimation(1.0f, 0.0f) val animation: Animation = AlphaAnimation(1.0f, 0.0f)
animation.duration = ANIMATION_DURATION animation.duration = ANIMATION_DURATION
animation.interpolator = LinearInterpolator() animation.interpolator = LinearInterpolator()
animation.repeatCount = Animation.INFINITE animation.repeatCount = Animation.INFINITE
animation.repeatMode = Animation.REVERSE animation.repeatMode = Animation.REVERSE
binding.messageInputView.microphoneEnabledInfo?.startAnimation(animation) binding.messageInputView.microphoneEnabledInfo.startAnimation(animation)
recorder = MediaRecorder().apply { recorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC) setAudioSource(MediaRecorder.AudioSource.MIC)
@ -1612,13 +2005,18 @@ class ChatActivity :
} }
private fun stopAndSendAudioRecording() { private fun stopAndSendAudioRecording() {
stopAudioRecording() if (isVoiceRecordingInProgress) {
stopAudioRecording()
}
val uri = Uri.fromFile(File(currentVoiceRecordFile)) val uri = Uri.fromFile(File(currentVoiceRecordFile))
uploadFile(uri.toString(), true) uploadFile(uri.toString(), true)
} }
private fun stopAndDiscardAudioRecording() { private fun stopAndDiscardAudioRecording() {
stopAudioRecording() if (isVoiceRecordingInProgress) {
stopAudioRecording()
}
val cachedFile = File(currentVoiceRecordFile) val cachedFile = File(currentVoiceRecordFile)
cachedFile.delete() cachedFile.delete()
@ -1626,26 +2024,22 @@ class ChatActivity :
@Suppress("Detekt.TooGenericExceptionCaught") @Suppress("Detekt.TooGenericExceptionCaught")
private fun stopAudioRecording() { private fun stopAudioRecording() {
binding.messageInputView.audioRecordDuration?.stop() binding.messageInputView.audioRecordDuration.stop()
binding.messageInputView.microphoneEnabledInfo?.clearAnimation() binding.messageInputView.microphoneEnabledInfo.clearAnimation()
if (isVoiceRecordingInProgress) { recorder?.apply {
recorder?.apply { try {
try { stop()
stop() release()
release() isVoiceRecordingInProgress = false
isVoiceRecordingInProgress = false Log.d(TAG, "stopped recorder. isVoiceRecordingInProgress = false")
Log.d(TAG, "stopped recorder. isVoiceRecordingInProgress = false") } catch (e: RuntimeException) {
} catch (e: RuntimeException) { Log.w(TAG, "error while stopping recorder!")
Log.w(TAG, "error while stopping recorder!")
}
VibrationUtils.vibrateShort(context)
} }
recorder = null
} else { VibrationUtils.vibrateShort(context)
Log.e(TAG, "tried to stop audio recorder but it was not recording")
} }
recorder = null
} }
private fun requestRecordAudioPermissions() { private fun requestRecordAudioPermissions() {
@ -1761,7 +2155,7 @@ class ChatActivity :
ConversationUtils.isLobbyViewApplicable(currentConversation!!, conversationUser!!) ConversationUtils.isLobbyViewApplicable(currentConversation!!, conversationUser!!)
) { ) {
if (shouldShowLobby()) { if (shouldShowLobby()) {
binding.lobby.lobbyView?.visibility = View.VISIBLE binding.lobby.lobbyView.visibility = View.VISIBLE
binding.messagesListView.visibility = View.GONE binding.messagesListView.visibility = View.GONE
binding.messageInputView.visibility = View.GONE binding.messageInputView.visibility = View.GONE
binding.progressBar.visibility = View.GONE binding.progressBar.visibility = View.GONE
@ -1785,9 +2179,9 @@ class ChatActivity :
} }
sb.append(currentConversation!!.description) sb.append(currentConversation!!.description)
binding.lobby.lobbyTextView?.text = sb.toString() binding.lobby.lobbyTextView.text = sb.toString()
} else { } else {
binding.lobby.lobbyView?.visibility = View.GONE binding.lobby.lobbyView.visibility = View.GONE
binding.messagesListView.visibility = View.VISIBLE binding.messagesListView.visibility = View.VISIBLE
binding.messageInputView.inputEditText?.visibility = View.VISIBLE binding.messageInputView.inputEditText?.visibility = View.VISIBLE
if (isFirstMessagesProcessing && pastPreconditionFailed) { if (isFirstMessagesProcessing && pastPreconditionFailed) {
@ -1799,7 +2193,7 @@ class ChatActivity :
} }
} }
} else { } else {
binding.lobby.lobbyView?.visibility = View.GONE binding.lobby.lobbyView.visibility = View.GONE
binding.messagesListView.visibility = View.VISIBLE binding.messagesListView.visibility = View.VISIBLE
binding.messageInputView.inputEditText?.visibility = View.VISIBLE binding.messageInputView.inputEditText?.visibility = View.VISIBLE
} }
@ -2774,7 +3168,7 @@ class ChatActivity :
private fun modifyMessageCount(shouldAddNewMessagesNotice: Boolean, shouldScroll: Boolean) { private fun modifyMessageCount(shouldAddNewMessagesNotice: Boolean, shouldScroll: Boolean) {
if (!shouldAddNewMessagesNotice && !shouldScroll) { if (!shouldAddNewMessagesNotice && !shouldScroll) {
binding.popupBubbleView.isShown?.let { binding.popupBubbleView.isShown.let {
if (it) { if (it) {
newMessagesCount++ newMessagesCount++
} else { } else {
@ -3455,11 +3849,11 @@ class ChatActivity :
private fun showMicrophoneButton(show: Boolean) { private fun showMicrophoneButton(show: Boolean) {
if (show && CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "voice-message-sharing")) { if (show && CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "voice-message-sharing")) {
binding.messageInputView.messageSendButton?.visibility = View.GONE binding.messageInputView.messageSendButton.visibility = View.GONE
binding.messageInputView.recordAudioButton?.visibility = View.VISIBLE binding.messageInputView.recordAudioButton.visibility = View.VISIBLE
} else { } else {
binding.messageInputView.messageSendButton?.visibility = View.VISIBLE binding.messageInputView.messageSendButton.visibility = View.VISIBLE
binding.messageInputView.recordAudioButton?.visibility = View.GONE binding.messageInputView.recordAudioButton.visibility = View.GONE
} }
} }
@ -3727,6 +4121,7 @@ class ChatActivity :
private const val OBJECT_MESSAGE: String = "{object}" private const val OBJECT_MESSAGE: String = "{object}"
private const val MINIMUM_VOICE_RECORD_DURATION: Int = 1000 private const val MINIMUM_VOICE_RECORD_DURATION: Int = 1000
private const val VOICE_RECORD_CANCEL_SLIDER_X: Int = -50 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_META_DATA = "{\"messageType\":\"voice-message\"}"
private const val VOICE_MESSAGE_FILE_SUFFIX = ".mp3" 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.Chronometer
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.SeekBar
import android.widget.TextView import android.widget.TextView
import androidx.emoji2.widget.EmojiEditText import androidx.emoji2.widget.EmojiEditText
import com.google.android.material.button.MaterialButton
import com.nextcloud.talk.R import com.nextcloud.talk.R
import com.stfalcon.chatkit.messages.MessageInput import com.stfalcon.chatkit.messages.MessageInput
@ -37,6 +39,11 @@ class MessageInput : MessageInput {
lateinit var microphoneEnabledInfo: ImageView lateinit var microphoneEnabledInfo: ImageView
lateinit var microphoneEnabledInfoBackground: ImageView lateinit var microphoneEnabledInfoBackground: ImageView
lateinit var smileyButton: ImageButton 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) { constructor(context: Context?) : super(context) {
init() init()
@ -57,6 +64,11 @@ class MessageInput : MessageInput {
microphoneEnabledInfo = findViewById(R.id.microphoneEnabledInfo) microphoneEnabledInfo = findViewById(R.id.microphoneEnabledInfo)
microphoneEnabledInfoBackground = findViewById(R.id.microphoneEnabledInfoBackground) microphoneEnabledInfoBackground = findViewById(R.id.microphoneEnabledInfoBackground)
smileyButton = findViewById(R.id.smileyButton) 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 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.ViewThemeUtilsBase
import com.nextcloud.android.common.ui.theme.utils.AndroidXViewThemeUtils import com.nextcloud.android.common.ui.theme.utils.AndroidXViewThemeUtils
import com.nextcloud.talk.R import com.nextcloud.talk.R
import com.nextcloud.talk.ui.MicInputCloud
import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.DisplayUtils
import com.nextcloud.talk.utils.DrawableUtils import com.nextcloud.talk.utils.DrawableUtils
import com.vanniktech.emoji.EmojiTextView 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 { companion object {
private val THEMEABLE_PLACEHOLDER_IDS = listOf( private val THEMEABLE_PLACEHOLDER_IDS = listOf(
R.drawable.ic_mimetype_package_x_generic, 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:cornerRadius="@dimen/button_corner_radius"
app:icon="@drawable/ic_baseline_arrow_downward_24px" /> 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 <com.google.android.material.button.MaterialButton
android:id="@+id/scrollDownButton" android:id="@+id/scrollDownButton"
style="@style/Widget.AppTheme.Button.ElevatedButton" style="@style/Widget.AppTheme.Button.ElevatedButton"

View File

@ -109,6 +109,47 @@
tools:visibility="gone" tools:visibility="gone"
android:contentDescription="@null" /> 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 <!-- 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 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 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:src="@drawable/ic_baseline_mic_red_24"
android:contentDescription="@null" android:contentDescription="@null"
android:visibility="gone" 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 <Chronometer
android:id="@+id/audioRecordDuration" android:id="@+id/audioRecordDuration"

View File

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