WIP agregar audio a post, sube audio

se agrega crashlytics
This commit is contained in:
erik-everardo 2024-02-05 01:47:38 -06:00
parent 5084fe337d
commit 835c7304c2
25 changed files with 474 additions and 69 deletions

View File

@ -7,6 +7,8 @@ plugins {
id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.0'
id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'
id 'androidx.navigation.safeargs.kotlin'
id 'com.google.gms.google-services'
id 'com.google.firebase.crashlytics'
}
android {
@ -125,4 +127,9 @@ dependencies {
// QR
implementation 'com.github.androidmads:QRGenerator:1.0.1'
// Firebase
implementation(platform("com.google.firebase:firebase-bom:32.7.1"))
implementation("com.google.firebase:firebase-crashlytics")
implementation("com.google.firebase:firebase-analytics")
}

View File

@ -23,8 +23,8 @@ class Module {
}
@Provides
fun provideAudiosRepository(audiosApi: AudiosApi): AudiosRepository {
return AudiosRepositoryImpl(audiosApi)
fun provideAudiosRepository(audiosApi: AudiosApi, audiosDraftsDao: AudiosDraftsDao): AudiosRepository {
return AudiosRepositoryImpl(audiosApi, audiosDraftsDao)
}
@Provides

View File

@ -1,11 +1,24 @@
package com.isolaatti.audio.common.data
import okhttp3.MultipartBody
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
interface AudiosApi {
companion object {
const val AudioParam= "audioFile"
const val NameParam = "name"
const val DurationParam = "duration"
}
@GET("/api/Audios/OfUser/{userId}")
fun getAudiosOfUser(@Path("userId") userId: Int, @Query("lastAudioId") lastAudioId: String?): Call<AudiosDto>
@POST("/api/Audios/Create")
@Multipart
fun uploadFile(@Part file: MultipartBody.Part, @Part name: MultipartBody.Part, @Part duration: MultipartBody.Part): Call<AudioDto>
}

View File

@ -1,15 +1,24 @@
package com.isolaatti.audio.common.data
import android.util.Log
import com.isolaatti.MyApplication
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.AudiosRepository
import com.isolaatti.audio.drafts.data.AudiosDraftsDao
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import retrofit2.awaitResponse
import java.io.File
import javax.inject.Inject
class AudiosRepositoryImpl @Inject constructor(private val audiosApi: AudiosApi) : AudiosRepository {
class AudiosRepositoryImpl @Inject constructor(private val audiosApi: AudiosApi, private val audiosDraftsDao: AudiosDraftsDao) : AudiosRepository {
companion object {
const val LOG_TAG = "AudiosRepositoryImpl"
}
override fun getAudiosOfUser(userId: Int, lastId: String?): Flow<Resource<List<Audio>>> = flow {
emit(Resource.Loading())
try {
@ -29,4 +38,46 @@ class AudiosRepositoryImpl @Inject constructor(private val audiosApi: AudiosApi)
emit(Resource.Error(Resource.Error.ErrorType.NetworkError))
}
}
override fun uploadAudio(draftId: Long): Flow<Resource<Audio>> = flow {
val audioDraftEntity = audiosDraftsDao.getAudioDraftById(draftId)
if(audioDraftEntity == null) {
emit(Resource.Error(Resource.Error.ErrorType.NotFoundError, "draft not found"))
return@flow
}
val file = File(MyApplication.myApp.filesDir, audioDraftEntity.audioLocalPath)
if(!file.exists()) {
// remove draft, file was removed from file system for some reason
audiosDraftsDao.deleteDrafts(arrayOf(audioDraftEntity))
emit(Resource.Error(Resource.Error.ErrorType.OtherError, "File not found"))
return@flow
}
try {
val response = audiosApi.uploadFile(
MultipartBody.Part.createFormData(
AudiosApi.AudioParam, // api parameter name
audioDraftEntity.audioLocalPath.split("/")[1],
file.asRequestBody("audio/3gpp".toMediaType()) // actual file to be sent
),
MultipartBody.Part.createFormData(AudiosApi.NameParam, audioDraftEntity.name),
MultipartBody.Part.createFormData(AudiosApi.DurationParam, "0")
).awaitResponse()
if(response.isSuccessful) {
val audioDto = response.body()
if(audioDto != null) {
Log.d(LOG_TAG, "emit audio dto")
emit(Resource.Success(Audio.fromDto(audioDto)))
}
} else {
emit(Resource.Error(Resource.Error.mapErrorCode(response.code())))
}
} catch(e: Exception) {
Log.d(LOG_TAG, e.message.toString())
}
}
}

View File

@ -5,4 +5,6 @@ import kotlinx.coroutines.flow.Flow
interface AudiosRepository {
fun getAudiosOfUser(userId: Int, lastId: String?): Flow<Resource<List<Audio>>>
fun uploadAudio(draftId: Long): Flow<Resource<Audio>>
}

View File

@ -0,0 +1,9 @@
package com.isolaatti.audio.common.domain
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class UploadAudioUC @Inject constructor(private val audiosRepository: AudiosRepository) {
operator fun invoke(draftId: Long): Flow<Resource<Audio>> = audiosRepository.uploadAudio(draftId)
}

View File

@ -7,5 +7,6 @@ import androidx.room.PrimaryKey
data class AudioDraftEntity(
@PrimaryKey(autoGenerate = true) val id: Long,
val name: String,
val audioLocalPath: String
val audioLocalPath: String,
val sizeInBytes: Long
)

View File

@ -4,14 +4,15 @@ import android.util.Log
import com.isolaatti.MyApplication
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import java.io.File
class AudioDraftsRepositoryImpl(private val audiosDraftsDao: AudiosDraftsDao) : AudioDraftsRepository {
override fun saveAudioDraft(name: String, relativePath: String): Flow<AudioDraft> = flow {
val entity = AudioDraftEntity(0, name, relativePath)
val insertedEntityId = audiosDraftsDao.insertAudioDraft(AudioDraftEntity(0, name, relativePath))
override fun saveAudioDraft(name: String, relativePath: String, size: Long): Flow<AudioDraft> = flow {
val entity = AudioDraftEntity(0, name, relativePath, size)
val insertedEntityId = audiosDraftsDao.insertAudioDraft(entity)
emit(AudioDraft.fromEntity(entity.copy(id = insertedEntityId)))
}
@ -39,4 +40,14 @@ class AudioDraftsRepositoryImpl(private val audiosDraftsDao: AudiosDraftsDao) :
emit(rowsAffected > 0)
}
override fun getAudioDraftById(draftId: Long): Flow<Resource<AudioDraft>> = flow {
val audioDraft = audiosDraftsDao.getAudioDraftById(draftId)
if(audioDraft == null) {
emit(Resource.Error(Resource.Error.ErrorType.NotFoundError, "Audio draft not found"))
} else {
emit(Resource.Success(AudioDraft.fromEntity(audioDraft)))
}
}
}

View File

@ -12,7 +12,7 @@ interface AudiosDraftsDao {
suspend fun insertAudioDraft(audioDraftEntity: AudioDraftEntity): Long
@Query("SELECT * FROM audio_drafts WHERE id = :draftId")
suspend fun getAudioDraftById(draftId: Long): AudioDraftEntity
suspend fun getAudioDraftById(draftId: Long): AudioDraftEntity?
@Query("SELECT * FROM audio_drafts WHERE id in (:draftId)")
suspend fun getAudioDraftsByIds(draftId: LongArray): Array<AudioDraftEntity>

View File

@ -7,7 +7,7 @@ import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.drafts.data.AudioDraftEntity
import java.io.File
data class AudioDraft(val id: Long, val name: String, val localStorageRelativePath: String) : Playable() {
data class AudioDraft(val id: Long, val name: String, val localStorageRelativePath: String, val size: Long) : Playable() {
override val thumbnail: String?
get() = null
@ -19,7 +19,7 @@ data class AudioDraft(val id: Long, val name: String, val localStorageRelativePa
companion object {
fun fromEntity(audioDraftEntity: AudioDraftEntity): AudioDraft {
return AudioDraft(audioDraftEntity.id, audioDraftEntity.name, audioDraftEntity.audioLocalPath)
return AudioDraft(audioDraftEntity.id, audioDraftEntity.name, audioDraftEntity.audioLocalPath, audioDraftEntity.sizeInBytes)
}
}
}

View File

@ -1,14 +1,17 @@
package com.isolaatti.audio.drafts.domain.repository
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
interface AudioDraftsRepository {
fun saveAudioDraft(name: String, relativePath: String): Flow<AudioDraft>
fun saveAudioDraft(name: String, relativePath: String, size: Long): Flow<AudioDraft>
fun getAudioDrafts(): Flow<List<AudioDraft>>
fun deleteDrafts(draftIds: LongArray): Flow<Boolean>
fun renameDraft(draftId: Long, name: String): Flow<Boolean>
fun getAudioDraftById(draftId: Long): Flow<Resource<AudioDraft>>
}

View File

@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class SaveAudioDraft @Inject constructor(private val audioDraftsRepository: AudioDraftsRepository) {
operator fun invoke(name: String, relativePath: String): Flow<AudioDraft> {
return audioDraftsRepository.saveAudioDraft(name, relativePath)
operator fun invoke(name: String, relativePath: String, size: Long): Flow<AudioDraft> {
return audioDraftsRepository.saveAudioDraft(name, relativePath, size)
}
}

View File

@ -8,8 +8,14 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import kotlinx.coroutines.CoroutineScope
@ -63,6 +69,9 @@ class AudioPlayerConnector(
}
private val playerListener: Player.Listener = object: Player.Listener {
override fun onPlayerError(error: PlaybackException) {
Log.e(TAG, error.message.toString())
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if(audio != null) {
listeners.forEach { listener -> listener.onPlaying(isPlaying, audio!!)}
@ -119,7 +128,6 @@ class AudioPlayerConnector(
private fun initializePlayer() {
player = ExoPlayer.Builder(context).build()
player?.playWhenReady = true
player?.addListener(playerListener)
player?.prepare()
}
@ -137,16 +145,14 @@ class AudioPlayerConnector(
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
Log.d(TAG, event.toString())
when(event) {
Lifecycle.Event.ON_START -> {
initializePlayer()
}
Lifecycle.Event.ON_RESUME -> {
if(player == null) {
initializePlayer()
}
}
Lifecycle.Event.ON_STOP, Lifecycle.Event.ON_DESTROY -> {
Lifecycle.Event.ON_DESTROY -> {
releasePlayer()
listeners.clear()
}
@ -173,6 +179,7 @@ class AudioPlayerConnector(
mediaItem = MediaItem.fromUri(audio.uri)
player?.setMediaItem(mediaItem!!)
player?.playWhenReady = true
}
fun stopPlayback() {
@ -191,20 +198,9 @@ class AudioPlayerConnector(
open class DefaultListener() : Listener {
override fun onPlaying(isPlaying: Boolean, audio: Playable) {}
override fun isLoading(isLoading: Boolean, audio: Playable) {}
override fun progressChanged(second: Int, audio: Playable) {
}
override fun durationChanged(duration: Int, audio: Playable) {
}
override fun onEnded(audio: Playable) {
}
override fun progressChanged(second: Int, audio: Playable) {}
override fun durationChanged(duration: Int, audio: Playable) {}
override fun onEnded(audio: Playable) {}
}
}

View File

@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
@ -23,6 +24,7 @@ class AudioRecorderViewModel @Inject constructor(
validate()
}
var relativePath = ""
var size: Long = 0
private var audioRecorded = false
val audioDraft: MutableLiveData<AudioDraft> = MutableLiveData()
@ -44,7 +46,7 @@ class AudioRecorderViewModel @Inject constructor(
}
fun saveAudioDraft() {
viewModelScope.launch {
saveAudioDraftUC(name, relativePath).onEach {
saveAudioDraftUC(name, relativePath, size).onEach {
audioDraft.postValue(it)
}.flowOn(Dispatchers.IO).launchIn(this)
}

View File

@ -191,6 +191,13 @@ class AudioRecorderActivity : AppCompatActivity() {
binding.toolbar.setOnMenuItemClickListener {
when(it.itemId) {
R.id.save_draft -> {
viewModel.relativePath = "/audios/${UUID.randomUUID()}.3gp"
File(outputFile).run {
viewModel.size = length()
copyTo(File(filesDir.absolutePath, viewModel.relativePath), overwrite = true)
}
viewModel.saveAudioDraft()
true
}

View File

@ -9,7 +9,7 @@ import com.isolaatti.auth.data.local.UserInfoEntity
import com.isolaatti.settings.data.KeyValueDao
import com.isolaatti.settings.data.KeyValueEntity
@Database(entities = [KeyValueEntity::class, UserInfoEntity::class, AudioDraftEntity::class], version = 4)
@Database(entities = [KeyValueEntity::class, UserInfoEntity::class, AudioDraftEntity::class], version = 5)
abstract class AppDatabase : RoomDatabase() {
abstract fun keyValueDao(): KeyValueDao
abstract fun userInfoDao(): UserInfoDao

View File

@ -2,6 +2,7 @@ package com.isolaatti.posting.posts.domain.entity
import android.os.Parcel
import android.os.Parcelable
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.common.Ownable
import com.isolaatti.posting.posts.data.remote.FeedDto
import java.io.Serializable
@ -18,7 +19,8 @@ data class Post(
var numberOfComments: Int,
val userName: String,
val squadName: String?,
var liked: Boolean
var liked: Boolean,
val audio: Audio? = null
) : Ownable, Parcelable {
constructor(parcel: Parcel) : this(
parcel.readLong(),
@ -32,7 +34,8 @@ data class Post(
parcel.readInt(),
parcel.readString()!!,
parcel.readString(),
parcel.readByte() != 0.toByte()
parcel.readByte() != 0.toByte(),
parcel.readSerializable() as? Audio
) {
}
@ -98,6 +101,7 @@ data class Post(
parcel.writeString(userName)
parcel.writeString(squadName)
parcel.writeByte(if (liked) 1 else 0)
parcel.writeSerializable(audio)
}
override fun describeContents(): Int {

View File

@ -1,8 +1,13 @@
package com.isolaatti.posting.posts.presentation
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.common.domain.UploadAudioUC
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
import com.isolaatti.posting.posts.data.remote.CreatePostDto
import com.isolaatti.posting.posts.data.remote.EditPostDto
import com.isolaatti.posting.posts.data.remote.EditPostDto.Companion.PRIVACY_ISOLAATTI
@ -13,6 +18,7 @@ import com.isolaatti.posting.posts.domain.use_case.LoadSinglePost
import com.isolaatti.posting.posts.domain.use_case.MakePost
import com.isolaatti.utils.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
@ -21,30 +27,97 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class CreatePostViewModel @Inject constructor(private val makePost: MakePost, private val editPost: EditPost, private val loadPost: LoadSinglePost) : ViewModel() {
class CreatePostViewModel @Inject constructor(
private val makePost: MakePost,
private val editPost: EditPost,
private val loadPost: LoadSinglePost,
private val audioDraftsRepository: AudioDraftsRepository,
private val uploadAudioUC: UploadAudioUC
) : ViewModel() {
companion object {
const val LOG_TAG = "CreatePostViewModel"
}
val validation: MutableLiveData<Boolean> = MutableLiveData(false)
val posted: MutableLiveData<Post?> = MutableLiveData()
val error: MutableLiveData<Resource.Error.ErrorType?> = MutableLiveData()
val loading: MutableLiveData<Boolean> = MutableLiveData(false)
val sendingPost: MutableLiveData<Boolean> = MutableLiveData(false)
val postToEdit: MutableLiveData<EditPostDto> = MutableLiveData()
val liveContent: MutableLiveData<String> = MutableLiveData()
var content: String = ""
set(value) {field = value; liveContent.value = value} // TODO remove this and use only liveContent
fun postDiscussion() {
val audioAttachment: MutableLiveData<Playable?> = MutableLiveData()
private var audioDraft: Long? = null
private var audioId: String? = null
/**
* postDiscussion() and editDiscussion() will check for audios pending to upload (drafts). It will
* upload it (if any) and then send the request to post.
*/
private fun sendDiscussion() {
Log.d(LOG_TAG, "postDiscussion#send()")
viewModelScope.launch {
makePost(EditPostDto.PRIVACY_ISOLAATTI, content, null, null).onEach {
makePost(EditPostDto.PRIVACY_ISOLAATTI, content, audioId, null).onEach {
when(it) {
is Resource.Success -> {
loading.postValue(false)
sendingPost.postValue(false)
posted.postValue(Post.fromPostDto(it.data!!))
}
is Resource.Error -> {
loading.postValue(false)
sendingPost.postValue(false)
error.postValue(it.errorType)
}
is Resource.Loading -> {
loading.postValue(true)
sendingPost.postValue(true)
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
fun postDiscussion() {
viewModelScope.launch {
if(audioDraft != null) {
uploadAudioUC(audioDraft!!).onEach { upLoadAudioResource ->
Log.d(LOG_TAG, upLoadAudioResource.toString())
when(upLoadAudioResource) {
is Resource.Success -> {
Log.d(LOG_TAG, "uploadAudioResource: Success")
audioId = upLoadAudioResource.data?.id
sendDiscussion()
}
is Resource.Error -> {}
is Resource.Loading -> {
sendingPost.postValue(true)
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
} else {
sendDiscussion()
}
}
}
private fun sendEditDiscussion(postId: Long) {
viewModelScope.launch {
editPost(postId, EditPostDto.PRIVACY_ISOLAATTI, content, audioId, null).onEach {
when(it) {
is Resource.Success -> {
sendingPost.postValue(false)
posted.postValue(Post.fromPostDto(it.data!!))
}
is Resource.Error -> {
sendingPost.postValue(false)
error.postValue(it.errorType)
}
is Resource.Loading -> {
sendingPost.postValue(true)
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
@ -52,22 +125,25 @@ class CreatePostViewModel @Inject constructor(private val makePost: MakePost, pr
}
fun editDiscussion(postId: Long) {
viewModelScope.launch {
editPost(postId, EditPostDto.PRIVACY_ISOLAATTI, content, null, null).onEach {
when(it) {
if(audioDraft != null) {
uploadAudioUC(audioDraft!!).onEach { upLoadAudioResource ->
when(upLoadAudioResource) {
is Resource.Success -> {
loading.postValue(false)
posted.postValue(Post.fromPostDto(it.data!!))
}
is Resource.Error -> {
loading.postValue(false)
error.postValue(it.errorType)
audioId = upLoadAudioResource.data?.id
sendEditDiscussion(postId)
}
is Resource.Error -> {}
is Resource.Loading -> {
loading.postValue(true)
sendingPost.postValue(true)
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
} else {
sendEditDiscussion(postId)
}
}
}
@ -77,10 +153,38 @@ class CreatePostViewModel @Inject constructor(private val makePost: MakePost, pr
if(postRes is Resource.Success) {
postRes.data?.let {
postToEdit.postValue(EditPostDto(PRIVACY_ISOLAATTI, content = it.textContent, postId = it.id))
it.audio?.let { audio -> audioAttachment.postValue(audio) }
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
// call this when user has recorded or selected a draft
fun putAudioDraft(draftId: Long) {
viewModelScope.launch {
audioDraftsRepository.getAudioDraftById(draftId).onEach { draft ->
when(draft) {
is Resource.Error -> {}
is Resource.Loading -> {}
is Resource.Success -> {
audioAttachment.postValue(draft.data)
this@CreatePostViewModel.audioDraft = draftId
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
// call this when user selects an existing audio (not a draft)
fun putAudio(audio: Audio) {
audioAttachment.value = audio
}
// clear user input
fun removeAudio() {
audioAttachment.value = null
}
}

View File

@ -5,12 +5,10 @@ import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.findNavController
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.isolaatti.R
import com.isolaatti.common.IsolaattiBaseActivity
@ -129,7 +127,7 @@ class CreatePostActivity : IsolaattiBaseActivity() {
finish()
}
viewModel.loading.observe(this@CreatePostActivity) {
viewModel.sendingPost.observe(this@CreatePostActivity) {
binding.progressBarLoading.visibility = if(it) View.VISIBLE else View.GONE
}
}

View File

@ -1,16 +1,24 @@
package com.isolaatti.posting.posts.ui
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.PopupMenu
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import com.isolaatti.audio.recorder.ui.AudioRecorderActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import com.isolaatti.R
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.player.AudioPlayerConnector
import com.isolaatti.audio.recorder.ui.AudioRecorderContract
import com.isolaatti.databinding.FragmentMarkdownEditingBinding
import com.isolaatti.images.image_chooser.ui.ImageChooserContract
@ -19,13 +27,21 @@ import com.isolaatti.posting.link_creator.ui.LinkCreatorFragment
import com.isolaatti.posting.posts.presentation.CreatePostViewModel
class MarkdownEditingFragment : Fragment(){
companion object {
const val LOG_TAG = "MarkdownEditingFragment"
}
private lateinit var binding: FragmentMarkdownEditingBinding
private val viewModel: CreatePostViewModel by activityViewModels()
private val linkCreatorViewModel: LinkCreatorViewModel by viewModels()
private var audioPlayerConnector: AudioPlayerConnector? = null
private val audioRecorderLauncher = registerForActivityResult(AudioRecorderContract()) { draftId ->
if(draftId != null) {
viewModel.putAudioDraft(draftId)
binding.viewAnimator.displayedChild = 1
}
}
@ -40,6 +56,34 @@ class MarkdownEditingFragment : Fragment(){
}
private val audioListener = object: AudioPlayerConnector.Listener {
override fun durationChanged(duration: Int, audio: Playable) {
binding.audioItem.audioProgress.max = duration
}
override fun onEnded(audio: Playable) {
binding.audioItem.audioProgress.progress = 0
}
override fun progressChanged(second: Int, audio: Playable) {
binding.audioItem.audioProgress.progress = second
}
override fun onPlaying(isPlaying: Boolean, audio: Playable) {
binding.audioItem.playButton.icon = AppCompatResources.getDrawable(requireContext(), if(isPlaying) R.drawable.baseline_pause_circle_24 else R.drawable.baseline_play_circle_24)
}
override fun isLoading(isLoading: Boolean, audio: Playable) {
binding.audioItem.audioProgress.isIndeterminate = isLoading
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
audioPlayerConnector = AudioPlayerConnector(requireContext())
audioPlayerConnector?.addListener(audioListener)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -53,11 +97,23 @@ class MarkdownEditingFragment : Fragment(){
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycle.addObserver(audioPlayerConnector!!)
viewLifecycleOwner.lifecycle.addObserver(object: LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
Log.d(LOG_TAG, event.toString())
}
})
setupListeners()
setupObservers()
}
override fun onDestroyView() {
super.onDestroyView()
viewLifecycleOwner.lifecycle.removeObserver(audioPlayerConnector!!)
}
private fun setupListeners() {
binding.filledTextField.editText?.setText(viewModel.content)
binding.filledTextField.requestFocus()
@ -74,7 +130,30 @@ class MarkdownEditingFragment : Fragment(){
}
binding.addAudioButton.setOnClickListener {
val popupMenu = PopupMenu(requireContext(), it)
popupMenu.inflate(R.menu.attach_audio_menu)
popupMenu.setOnMenuItemClickListener { menuItem ->
when(menuItem.itemId){
R.id.record_new_audio_item -> {
audioRecorderLauncher.launch(null)
true
}
R.id.select_from_audios -> {
true
}
else -> false
}
}
popupMenu.show()
}
binding.removeAudio.setOnClickListener {
binding.viewAnimator.displayedChild = 0
viewModel.removeAudio()
}
}
@ -89,8 +168,27 @@ class MarkdownEditingFragment : Fragment(){
linkCreatorViewModel.inserted.value = false
}
}
viewModel.audioAttachment.observe(viewLifecycleOwner) { playable ->
if(playable != null) {
audioPlayerConnector?.stopPlayback()
binding.audioItem.playButton.setOnClickListener {
audioPlayerConnector?.playPauseAudio(playable)
}
when(playable) {
is Audio -> {
binding.audioItem.textViewDescription.text = playable.name
}
is AudioDraft -> {
binding.audioItem.textViewDescription.text = playable.name
}
}
}
}
}
private fun insertImage() {
imageChooserLauncher.launch(ImageChooserContract.Requester.UserPost)
}

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<RelativeLayout
android:id="@+id/play_button_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/audio_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<com.google.android.material.button.MaterialButton
android:id="@+id/play_button"
style="?attr/materialIconButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:icon="@drawable/baseline_play_circle_24" />
</RelativeLayout>
<TextView
android:id="@+id/text_view_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:layout_marginHorizontal="20dp"
android:maxLines="4"
android:textAlignment="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/description_options_button"
app:layout_constraintStart_toEndOf="@id/play_button_container"
app:layout_constraintTop_toTopOf="parent"
tools:text="Hi there, I am software developer!" />
<com.google.android.material.button.MaterialButton
android:id="@+id/description_options_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
style="?attr/materialIconButtonStyle"
android:visibility="gone"
app:icon="@drawable/baseline_more_horiz_24"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -55,12 +55,36 @@
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Audio attachment"/>
android:text="@string/audio_attachment"/>
<ViewAnimator
android:id="@+id/view_animator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/add_audio_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:text="Attach audio" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include android:id="@+id/audio_item" layout="@layout/audio_attachment"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/remove_audio"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/audio_item"
android:layout_marginTop="4dp"
style="?attr/materialIconButtonFilledTonalStyle"
app:icon="@drawable/baseline_close_24"
android:text="@string/remove"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</ViewAnimator>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/record_new_audio_item"
android:title="@string/record_new_audio"/>
<item android:id="@+id/select_from_audios"
android:title="@string/select_from_existing"/>
</menu>

View File

@ -179,4 +179,8 @@
<string name="new_password_is_invalid">New password is invalid. Please check it meets the requirements</string>
<string name="rename">Rename</string>
<string name="set_as_profile_audio">Set as profile audio</string>
<string name="record_new_audio">Record new audio</string>
<string name="select_from_existing">Select from existing</string>
<string name="audio_attachment">Audio attachment</string>
<string name="remove">Remove</string>
</resources>

View File

@ -17,4 +17,6 @@ plugins {
id 'com.android.library' version '8.2.2' apply false
id 'org.jetbrains.kotlin.android' version '1.8.0' apply false
id 'com.google.dagger.hilt.android' version '2.47' apply false
id 'com.google.gms.google-services' version '4.4.0' apply false
id 'com.google.firebase.crashlytics' version '2.9.9' apply false
}