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 'org.jetbrains.kotlin.plugin.serialization' version '1.8.0'
id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'
id 'androidx.navigation.safeargs.kotlin' id 'androidx.navigation.safeargs.kotlin'
id 'com.google.gms.google-services'
id 'com.google.firebase.crashlytics'
} }
android { android {
@ -125,4 +127,9 @@ dependencies {
// QR // QR
implementation 'com.github.androidmads:QRGenerator:1.0.1' 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 @Provides
fun provideAudiosRepository(audiosApi: AudiosApi): AudiosRepository { fun provideAudiosRepository(audiosApi: AudiosApi, audiosDraftsDao: AudiosDraftsDao): AudiosRepository {
return AudiosRepositoryImpl(audiosApi) return AudiosRepositoryImpl(audiosApi, audiosDraftsDao)
} }
@Provides @Provides

View File

@ -1,11 +1,24 @@
package com.isolaatti.audio.common.data package com.isolaatti.audio.common.data
import okhttp3.MultipartBody
import retrofit2.Call import retrofit2.Call
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
interface AudiosApi { interface AudiosApi {
companion object {
const val AudioParam= "audioFile"
const val NameParam = "name"
const val DurationParam = "duration"
}
@GET("/api/Audios/OfUser/{userId}") @GET("/api/Audios/OfUser/{userId}")
fun getAudiosOfUser(@Path("userId") userId: Int, @Query("lastAudioId") lastAudioId: String?): Call<AudiosDto> 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 package com.isolaatti.audio.common.data
import android.util.Log import android.util.Log
import com.isolaatti.MyApplication
import com.isolaatti.audio.common.domain.Audio import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.AudiosRepository import com.isolaatti.audio.common.domain.AudiosRepository
import com.isolaatti.audio.drafts.data.AudiosDraftsDao
import com.isolaatti.utils.Resource import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
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 retrofit2.awaitResponse
import java.io.File
import javax.inject.Inject 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 { override fun getAudiosOfUser(userId: Int, lastId: String?): Flow<Resource<List<Audio>>> = flow {
emit(Resource.Loading()) emit(Resource.Loading())
try { try {
@ -29,4 +38,46 @@ class AudiosRepositoryImpl @Inject constructor(private val audiosApi: AudiosApi)
emit(Resource.Error(Resource.Error.ErrorType.NetworkError)) 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 { interface AudiosRepository {
fun getAudiosOfUser(userId: Int, lastId: String?): Flow<Resource<List<Audio>>> 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( data class AudioDraftEntity(
@PrimaryKey(autoGenerate = true) val id: Long, @PrimaryKey(autoGenerate = true) val id: Long,
val name: String, 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.MyApplication
import com.isolaatti.audio.drafts.domain.AudioDraft import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import java.io.File import java.io.File
class AudioDraftsRepositoryImpl(private val audiosDraftsDao: AudiosDraftsDao) : AudioDraftsRepository { class AudioDraftsRepositoryImpl(private val audiosDraftsDao: AudiosDraftsDao) : AudioDraftsRepository {
override fun saveAudioDraft(name: String, relativePath: String): Flow<AudioDraft> = flow { override fun saveAudioDraft(name: String, relativePath: String, size: Long): Flow<AudioDraft> = flow {
val entity = AudioDraftEntity(0, name, relativePath) val entity = AudioDraftEntity(0, name, relativePath, size)
val insertedEntityId = audiosDraftsDao.insertAudioDraft(AudioDraftEntity(0, name, relativePath)) val insertedEntityId = audiosDraftsDao.insertAudioDraft(entity)
emit(AudioDraft.fromEntity(entity.copy(id = insertedEntityId))) emit(AudioDraft.fromEntity(entity.copy(id = insertedEntityId)))
} }
@ -39,4 +40,14 @@ class AudioDraftsRepositoryImpl(private val audiosDraftsDao: AudiosDraftsDao) :
emit(rowsAffected > 0) 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 suspend fun insertAudioDraft(audioDraftEntity: AudioDraftEntity): Long
@Query("SELECT * FROM audio_drafts WHERE id = :draftId") @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)") @Query("SELECT * FROM audio_drafts WHERE id in (:draftId)")
suspend fun getAudioDraftsByIds(draftId: LongArray): Array<AudioDraftEntity> 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 com.isolaatti.audio.drafts.data.AudioDraftEntity
import java.io.File 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? override val thumbnail: String?
get() = null get() = null
@ -19,7 +19,7 @@ data class AudioDraft(val id: Long, val name: String, val localStorageRelativePa
companion object { companion object {
fun fromEntity(audioDraftEntity: AudioDraftEntity): AudioDraft { 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 package com.isolaatti.audio.drafts.domain.repository
import com.isolaatti.audio.drafts.domain.AudioDraft import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface AudioDraftsRepository { 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 getAudioDrafts(): Flow<List<AudioDraft>>
fun deleteDrafts(draftIds: LongArray): Flow<Boolean> fun deleteDrafts(draftIds: LongArray): Flow<Boolean>
fun renameDraft(draftId: Long, name: String): 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 import javax.inject.Inject
class SaveAudioDraft @Inject constructor(private val audioDraftsRepository: AudioDraftsRepository) { class SaveAudioDraft @Inject constructor(private val audioDraftsRepository: AudioDraftsRepository) {
operator fun invoke(name: String, relativePath: String): Flow<AudioDraft> { operator fun invoke(name: String, relativePath: String, size: Long): Flow<AudioDraft> {
return audioDraftsRepository.saveAudioDraft(name, relativePath) return audioDraftsRepository.saveAudioDraft(name, relativePath, size)
} }
} }

View File

@ -8,8 +8,14 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.ExoPlayer 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.Audio
import com.isolaatti.audio.common.domain.Playable import com.isolaatti.audio.common.domain.Playable
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -63,6 +69,9 @@ class AudioPlayerConnector(
} }
private val playerListener: Player.Listener = object: Player.Listener { private val playerListener: Player.Listener = object: Player.Listener {
override fun onPlayerError(error: PlaybackException) {
Log.e(TAG, error.message.toString())
}
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
if(audio != null) { if(audio != null) {
listeners.forEach { listener -> listener.onPlaying(isPlaying, audio!!)} listeners.forEach { listener -> listener.onPlaying(isPlaying, audio!!)}
@ -119,7 +128,6 @@ class AudioPlayerConnector(
private fun initializePlayer() { private fun initializePlayer() {
player = ExoPlayer.Builder(context).build() player = ExoPlayer.Builder(context).build()
player?.playWhenReady = true
player?.addListener(playerListener) player?.addListener(playerListener)
player?.prepare() player?.prepare()
} }
@ -137,16 +145,14 @@ class AudioPlayerConnector(
} }
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
Log.d(TAG, event.toString())
when(event) { when(event) {
Lifecycle.Event.ON_START -> {
initializePlayer()
}
Lifecycle.Event.ON_RESUME -> { Lifecycle.Event.ON_RESUME -> {
if(player == null) { if(player == null) {
initializePlayer() initializePlayer()
} }
} }
Lifecycle.Event.ON_STOP, Lifecycle.Event.ON_DESTROY -> { Lifecycle.Event.ON_DESTROY -> {
releasePlayer() releasePlayer()
listeners.clear() listeners.clear()
} }
@ -173,6 +179,7 @@ class AudioPlayerConnector(
mediaItem = MediaItem.fromUri(audio.uri) mediaItem = MediaItem.fromUri(audio.uri)
player?.setMediaItem(mediaItem!!) player?.setMediaItem(mediaItem!!)
player?.playWhenReady = true
} }
fun stopPlayback() { fun stopPlayback() {
@ -191,20 +198,9 @@ class AudioPlayerConnector(
open class DefaultListener() : Listener { open class DefaultListener() : Listener {
override fun onPlaying(isPlaying: Boolean, audio: Playable) {} override fun onPlaying(isPlaying: Boolean, audio: Playable) {}
override fun isLoading(isLoading: Boolean, audio: Playable) {} override fun isLoading(isLoading: Boolean, audio: Playable) {}
override fun progressChanged(second: Int, audio: Playable) {}
override fun progressChanged(second: Int, audio: Playable) { override fun durationChanged(duration: Int, audio: Playable) {}
override fun onEnded(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.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -23,6 +24,7 @@ class AudioRecorderViewModel @Inject constructor(
validate() validate()
} }
var relativePath = "" var relativePath = ""
var size: Long = 0
private var audioRecorded = false private var audioRecorded = false
val audioDraft: MutableLiveData<AudioDraft> = MutableLiveData() val audioDraft: MutableLiveData<AudioDraft> = MutableLiveData()
@ -44,7 +46,7 @@ class AudioRecorderViewModel @Inject constructor(
} }
fun saveAudioDraft() { fun saveAudioDraft() {
viewModelScope.launch { viewModelScope.launch {
saveAudioDraftUC(name, relativePath).onEach { saveAudioDraftUC(name, relativePath, size).onEach {
audioDraft.postValue(it) audioDraft.postValue(it)
}.flowOn(Dispatchers.IO).launchIn(this) }.flowOn(Dispatchers.IO).launchIn(this)
} }

View File

@ -191,6 +191,13 @@ class AudioRecorderActivity : AppCompatActivity() {
binding.toolbar.setOnMenuItemClickListener { binding.toolbar.setOnMenuItemClickListener {
when(it.itemId) { when(it.itemId) {
R.id.save_draft -> { 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() viewModel.saveAudioDraft()
true 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.KeyValueDao
import com.isolaatti.settings.data.KeyValueEntity 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 class AppDatabase : RoomDatabase() {
abstract fun keyValueDao(): KeyValueDao abstract fun keyValueDao(): KeyValueDao
abstract fun userInfoDao(): UserInfoDao abstract fun userInfoDao(): UserInfoDao

View File

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

View File

@ -1,8 +1,13 @@
package com.isolaatti.posting.posts.presentation package com.isolaatti.posting.posts.presentation
import android.util.Log
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.CreatePostDto
import com.isolaatti.posting.posts.data.remote.EditPostDto import com.isolaatti.posting.posts.data.remote.EditPostDto
import com.isolaatti.posting.posts.data.remote.EditPostDto.Companion.PRIVACY_ISOLAATTI 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.posting.posts.domain.use_case.MakePost
import com.isolaatti.utils.Resource import com.isolaatti.utils.Resource
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -21,30 +27,97 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @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 validation: MutableLiveData<Boolean> = MutableLiveData(false)
val posted: MutableLiveData<Post?> = MutableLiveData() val posted: MutableLiveData<Post?> = MutableLiveData()
val error: MutableLiveData<Resource.Error.ErrorType?> = 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 postToEdit: MutableLiveData<EditPostDto> = MutableLiveData()
val liveContent: MutableLiveData<String> = MutableLiveData() val liveContent: MutableLiveData<String> = MutableLiveData()
var content: String = "" var content: String = ""
set(value) {field = value; liveContent.value = value} // TODO remove this and use only liveContent 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 { viewModelScope.launch {
makePost(EditPostDto.PRIVACY_ISOLAATTI, content, null, null).onEach { makePost(EditPostDto.PRIVACY_ISOLAATTI, content, audioId, null).onEach {
when(it) { when(it) {
is Resource.Success -> { is Resource.Success -> {
loading.postValue(false) sendingPost.postValue(false)
posted.postValue(Post.fromPostDto(it.data!!)) posted.postValue(Post.fromPostDto(it.data!!))
} }
is Resource.Error -> { is Resource.Error -> {
loading.postValue(false) sendingPost.postValue(false)
error.postValue(it.errorType) error.postValue(it.errorType)
} }
is Resource.Loading -> { 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) }.flowOn(Dispatchers.IO).launchIn(this)
@ -52,22 +125,25 @@ class CreatePostViewModel @Inject constructor(private val makePost: MakePost, pr
} }
fun editDiscussion(postId: Long) { fun editDiscussion(postId: Long) {
viewModelScope.launch { viewModelScope.launch {
editPost(postId, EditPostDto.PRIVACY_ISOLAATTI, content, null, null).onEach { if(audioDraft != null) {
when(it) { uploadAudioUC(audioDraft!!).onEach { upLoadAudioResource ->
when(upLoadAudioResource) {
is Resource.Success -> { is Resource.Success -> {
loading.postValue(false) audioId = upLoadAudioResource.data?.id
posted.postValue(Post.fromPostDto(it.data!!)) sendEditDiscussion(postId)
}
is Resource.Error -> {
loading.postValue(false)
error.postValue(it.errorType)
} }
is Resource.Error -> {}
is Resource.Loading -> { is Resource.Loading -> {
loading.postValue(true) sendingPost.postValue(true)
} }
} }
}.flowOn(Dispatchers.IO).launchIn(this) }.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) { if(postRes is Resource.Success) {
postRes.data?.let { postRes.data?.let {
postToEdit.postValue(EditPostDto(PRIVACY_ISOLAATTI, content = it.textContent, postId = it.id)) postToEdit.postValue(EditPostDto(PRIVACY_ISOLAATTI, content = it.textContent, postId = it.id))
it.audio?.let { audio -> audioAttachment.postValue(audio) }
} }
} }
}.flowOn(Dispatchers.IO).launchIn(this) }.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.os.Bundle
import android.view.View import android.view.View
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.findNavController
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.isolaatti.R import com.isolaatti.R
import com.isolaatti.common.IsolaattiBaseActivity import com.isolaatti.common.IsolaattiBaseActivity
@ -129,7 +127,7 @@ class CreatePostActivity : IsolaattiBaseActivity() {
finish() finish()
} }
viewModel.loading.observe(this@CreatePostActivity) { viewModel.sendingPost.observe(this@CreatePostActivity) {
binding.progressBarLoading.visibility = if(it) View.VISIBLE else View.GONE binding.progressBarLoading.visibility = if(it) View.VISIBLE else View.GONE
} }
} }

View File

@ -1,16 +1,24 @@
package com.isolaatti.posting.posts.ui package com.isolaatti.posting.posts.ui
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.PopupMenu
import androidx.core.widget.doOnTextChanged import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels 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.audio.recorder.ui.AudioRecorderContract
import com.isolaatti.databinding.FragmentMarkdownEditingBinding import com.isolaatti.databinding.FragmentMarkdownEditingBinding
import com.isolaatti.images.image_chooser.ui.ImageChooserContract 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 import com.isolaatti.posting.posts.presentation.CreatePostViewModel
class MarkdownEditingFragment : Fragment(){ class MarkdownEditingFragment : Fragment(){
companion object {
const val LOG_TAG = "MarkdownEditingFragment"
}
private lateinit var binding: FragmentMarkdownEditingBinding private lateinit var binding: FragmentMarkdownEditingBinding
private val viewModel: CreatePostViewModel by activityViewModels() private val viewModel: CreatePostViewModel by activityViewModels()
private val linkCreatorViewModel: LinkCreatorViewModel by viewModels() private val linkCreatorViewModel: LinkCreatorViewModel by viewModels()
private var audioPlayerConnector: AudioPlayerConnector? = null
private val audioRecorderLauncher = registerForActivityResult(AudioRecorderContract()) { draftId -> private val audioRecorderLauncher = registerForActivityResult(AudioRecorderContract()) { draftId ->
if(draftId != null) { 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( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -53,11 +97,23 @@ class MarkdownEditingFragment : Fragment(){
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) 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() setupListeners()
setupObservers() setupObservers()
} }
override fun onDestroyView() {
super.onDestroyView()
viewLifecycleOwner.lifecycle.removeObserver(audioPlayerConnector!!)
}
private fun setupListeners() { private fun setupListeners() {
binding.filledTextField.editText?.setText(viewModel.content) binding.filledTextField.editText?.setText(viewModel.content)
binding.filledTextField.requestFocus() binding.filledTextField.requestFocus()
@ -74,7 +130,30 @@ class MarkdownEditingFragment : Fragment(){
} }
binding.addAudioButton.setOnClickListener { 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) 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 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() { private fun insertImage() {
imageChooserLauncher.launch(ImageChooserContract.Requester.UserPost) 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 <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" 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 <com.google.android.material.button.MaterialButton
android:id="@+id/add_audio_button" android:id="@+id/add_audio_button"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:text="Attach audio" /> 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> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
</LinearLayout> </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="new_password_is_invalid">New password is invalid. Please check it meets the requirements</string>
<string name="rename">Rename</string> <string name="rename">Rename</string>
<string name="set_as_profile_audio">Set as profile audio</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> </resources>

View File

@ -17,4 +17,6 @@ plugins {
id 'com.android.library' version '8.2.2' apply false id 'com.android.library' version '8.2.2' apply false
id 'org.jetbrains.kotlin.android' version '1.8.0' 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.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
} }