From 835c7304c2ac9b0f44cc7df572114ba44924567d Mon Sep 17 00:00:00 2001 From: erik-everardo Date: Mon, 5 Feb 2024 01:47:38 -0600 Subject: [PATCH] WIP agregar audio a post, sube audio se agrega crashlytics --- app/build.gradle | 7 + .../main/java/com/isolaatti/audio/Module.kt | 4 +- .../isolaatti/audio/common/data/AudiosApi.kt | 13 ++ .../audio/common/data/AudiosRepositoryImpl.kt | 53 ++++++- .../audio/common/domain/AudiosRepository.kt | 2 + .../audio/common/domain/UploadAudioUC.kt | 9 ++ .../audio/drafts/data/AudioDraftEntity.kt | 3 +- .../drafts/data/AudioDraftsRepositoryImpl.kt | 17 +- .../audio/drafts/data/AudiosDraftsDao.kt | 2 +- .../audio/drafts/domain/AudioDraft.kt | 4 +- .../repository/AudioDraftsRepository.kt | 5 +- .../drafts/domain/use_case/SaveAudioDraft.kt | 4 +- .../audio/player/AudioPlayerConnector.kt | 34 ++-- .../presentation/AudioRecorderViewModel.kt | 4 +- .../recorder/ui/AudioRecorderActivity.kt | 7 + .../com/isolaatti/database/AppDatabase.kt | 2 +- .../posting/posts/domain/entity/Post.kt | 8 +- .../posts/presentation/CreatePostViewModel.kt | 146 +++++++++++++++--- .../posting/posts/ui/CreatePostActivity.kt | 6 +- .../posts/ui/MarkdownEditingFragment.kt | 106 ++++++++++++- app/src/main/res/layout/audio_attachment.xml | 62 ++++++++ .../res/layout/fragment_markdown_editing.xml | 32 +++- app/src/main/res/menu/attach_audio_menu.xml | 7 + app/src/main/res/values/strings.xml | 4 + build.gradle | 2 + 25 files changed, 474 insertions(+), 69 deletions(-) create mode 100644 app/src/main/java/com/isolaatti/audio/common/domain/UploadAudioUC.kt create mode 100644 app/src/main/res/layout/audio_attachment.xml create mode 100644 app/src/main/res/menu/attach_audio_menu.xml diff --git a/app/build.gradle b/app/build.gradle index a921dc2..00a6b60 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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") + } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/audio/Module.kt b/app/src/main/java/com/isolaatti/audio/Module.kt index 3a08b27..db8e525 100644 --- a/app/src/main/java/com/isolaatti/audio/Module.kt +++ b/app/src/main/java/com/isolaatti/audio/Module.kt @@ -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 diff --git a/app/src/main/java/com/isolaatti/audio/common/data/AudiosApi.kt b/app/src/main/java/com/isolaatti/audio/common/data/AudiosApi.kt index f578860..11ccecb 100644 --- a/app/src/main/java/com/isolaatti/audio/common/data/AudiosApi.kt +++ b/app/src/main/java/com/isolaatti/audio/common/data/AudiosApi.kt @@ -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 + + @POST("/api/Audios/Create") + @Multipart + fun uploadFile(@Part file: MultipartBody.Part, @Part name: MultipartBody.Part, @Part duration: MultipartBody.Part): Call } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/audio/common/data/AudiosRepositoryImpl.kt b/app/src/main/java/com/isolaatti/audio/common/data/AudiosRepositoryImpl.kt index 8d79313..b945c84 100644 --- a/app/src/main/java/com/isolaatti/audio/common/data/AudiosRepositoryImpl.kt +++ b/app/src/main/java/com/isolaatti/audio/common/data/AudiosRepositoryImpl.kt @@ -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>> = 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> = 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()) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/audio/common/domain/AudiosRepository.kt b/app/src/main/java/com/isolaatti/audio/common/domain/AudiosRepository.kt index 9042ed3..0b67ac1 100644 --- a/app/src/main/java/com/isolaatti/audio/common/domain/AudiosRepository.kt +++ b/app/src/main/java/com/isolaatti/audio/common/domain/AudiosRepository.kt @@ -5,4 +5,6 @@ import kotlinx.coroutines.flow.Flow interface AudiosRepository { fun getAudiosOfUser(userId: Int, lastId: String?): Flow>> + + fun uploadAudio(draftId: Long): Flow> } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/audio/common/domain/UploadAudioUC.kt b/app/src/main/java/com/isolaatti/audio/common/domain/UploadAudioUC.kt new file mode 100644 index 0000000..b1fe00a --- /dev/null +++ b/app/src/main/java/com/isolaatti/audio/common/domain/UploadAudioUC.kt @@ -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> = audiosRepository.uploadAudio(draftId) +} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/audio/drafts/data/AudioDraftEntity.kt b/app/src/main/java/com/isolaatti/audio/drafts/data/AudioDraftEntity.kt index 388becb..651de68 100644 --- a/app/src/main/java/com/isolaatti/audio/drafts/data/AudioDraftEntity.kt +++ b/app/src/main/java/com/isolaatti/audio/drafts/data/AudioDraftEntity.kt @@ -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 ) \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/audio/drafts/data/AudioDraftsRepositoryImpl.kt b/app/src/main/java/com/isolaatti/audio/drafts/data/AudioDraftsRepositoryImpl.kt index bf4fb9c..a26552f 100644 --- a/app/src/main/java/com/isolaatti/audio/drafts/data/AudioDraftsRepositoryImpl.kt +++ b/app/src/main/java/com/isolaatti/audio/drafts/data/AudioDraftsRepositoryImpl.kt @@ -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 = 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 = 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> = 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))) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/audio/drafts/data/AudiosDraftsDao.kt b/app/src/main/java/com/isolaatti/audio/drafts/data/AudiosDraftsDao.kt index e489399..9e124bf 100644 --- a/app/src/main/java/com/isolaatti/audio/drafts/data/AudiosDraftsDao.kt +++ b/app/src/main/java/com/isolaatti/audio/drafts/data/AudiosDraftsDao.kt @@ -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 diff --git a/app/src/main/java/com/isolaatti/audio/drafts/domain/AudioDraft.kt b/app/src/main/java/com/isolaatti/audio/drafts/domain/AudioDraft.kt index 437782a..05afa2a 100644 --- a/app/src/main/java/com/isolaatti/audio/drafts/domain/AudioDraft.kt +++ b/app/src/main/java/com/isolaatti/audio/drafts/domain/AudioDraft.kt @@ -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) } } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/audio/drafts/domain/repository/AudioDraftsRepository.kt b/app/src/main/java/com/isolaatti/audio/drafts/domain/repository/AudioDraftsRepository.kt index 05f65ab..6739d9f 100644 --- a/app/src/main/java/com/isolaatti/audio/drafts/domain/repository/AudioDraftsRepository.kt +++ b/app/src/main/java/com/isolaatti/audio/drafts/domain/repository/AudioDraftsRepository.kt @@ -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 + fun saveAudioDraft(name: String, relativePath: String, size: Long): Flow fun getAudioDrafts(): Flow> fun deleteDrafts(draftIds: LongArray): Flow fun renameDraft(draftId: Long, name: String): Flow + + fun getAudioDraftById(draftId: Long): Flow> } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/audio/drafts/domain/use_case/SaveAudioDraft.kt b/app/src/main/java/com/isolaatti/audio/drafts/domain/use_case/SaveAudioDraft.kt index bc49ffd..ba59984 100644 --- a/app/src/main/java/com/isolaatti/audio/drafts/domain/use_case/SaveAudioDraft.kt +++ b/app/src/main/java/com/isolaatti/audio/drafts/domain/use_case/SaveAudioDraft.kt @@ -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 { - return audioDraftsRepository.saveAudioDraft(name, relativePath) + operator fun invoke(name: String, relativePath: String, size: Long): Flow { + return audioDraftsRepository.saveAudioDraft(name, relativePath, size) } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/audio/player/AudioPlayerConnector.kt b/app/src/main/java/com/isolaatti/audio/player/AudioPlayerConnector.kt index 7f10880..1ccb0cf 100644 --- a/app/src/main/java/com/isolaatti/audio/player/AudioPlayerConnector.kt +++ b/app/src/main/java/com/isolaatti/audio/player/AudioPlayerConnector.kt @@ -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) {} } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/audio/recorder/presentation/AudioRecorderViewModel.kt b/app/src/main/java/com/isolaatti/audio/recorder/presentation/AudioRecorderViewModel.kt index 7f4cc2f..18cefae 100644 --- a/app/src/main/java/com/isolaatti/audio/recorder/presentation/AudioRecorderViewModel.kt +++ b/app/src/main/java/com/isolaatti/audio/recorder/presentation/AudioRecorderViewModel.kt @@ -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 = 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) } diff --git a/app/src/main/java/com/isolaatti/audio/recorder/ui/AudioRecorderActivity.kt b/app/src/main/java/com/isolaatti/audio/recorder/ui/AudioRecorderActivity.kt index 8e72a65..04bc530 100644 --- a/app/src/main/java/com/isolaatti/audio/recorder/ui/AudioRecorderActivity.kt +++ b/app/src/main/java/com/isolaatti/audio/recorder/ui/AudioRecorderActivity.kt @@ -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 } diff --git a/app/src/main/java/com/isolaatti/database/AppDatabase.kt b/app/src/main/java/com/isolaatti/database/AppDatabase.kt index dc1fc30..ac455fe 100644 --- a/app/src/main/java/com/isolaatti/database/AppDatabase.kt +++ b/app/src/main/java/com/isolaatti/database/AppDatabase.kt @@ -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 diff --git a/app/src/main/java/com/isolaatti/posting/posts/domain/entity/Post.kt b/app/src/main/java/com/isolaatti/posting/posts/domain/entity/Post.kt index 4dbd500..b9dce65 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/domain/entity/Post.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/domain/entity/Post.kt @@ -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 { diff --git a/app/src/main/java/com/isolaatti/posting/posts/presentation/CreatePostViewModel.kt b/app/src/main/java/com/isolaatti/posting/posts/presentation/CreatePostViewModel.kt index d7fdc85..05b3f77 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/presentation/CreatePostViewModel.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/presentation/CreatePostViewModel.kt @@ -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 = MutableLiveData(false) val posted: MutableLiveData = MutableLiveData() val error: MutableLiveData = MutableLiveData() - val loading: MutableLiveData = MutableLiveData(false) + val sendingPost: MutableLiveData = MutableLiveData(false) val postToEdit: MutableLiveData = MutableLiveData() val liveContent: MutableLiveData = MutableLiveData() var content: String = "" set(value) {field = value; liveContent.value = value} // TODO remove this and use only liveContent - fun postDiscussion() { + val audioAttachment: MutableLiveData = 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) { - is Resource.Success -> { - loading.postValue(false) - posted.postValue(Post.fromPostDto(it.data!!)) + if(audioDraft != null) { + uploadAudioUC(audioDraft!!).onEach { upLoadAudioResource -> + when(upLoadAudioResource) { + is Resource.Success -> { + audioId = upLoadAudioResource.data?.id + sendEditDiscussion(postId) + } + + is Resource.Error -> {} + is Resource.Loading -> { + sendingPost.postValue(true) + } } - is Resource.Error -> { - loading.postValue(false) - error.postValue(it.errorType) - } - is Resource.Loading -> { - loading.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) { 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 + } + } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostActivity.kt b/app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostActivity.kt index f720132..d4deea9 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostActivity.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostActivity.kt @@ -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 } } diff --git a/app/src/main/java/com/isolaatti/posting/posts/ui/MarkdownEditingFragment.kt b/app/src/main/java/com/isolaatti/posting/posts/ui/MarkdownEditingFragment.kt index ec4fd04..d62d093 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/ui/MarkdownEditingFragment.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/ui/MarkdownEditingFragment.kt @@ -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 { - audioRecorderLauncher.launch(null) + + 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) } diff --git a/app/src/main/res/layout/audio_attachment.xml b/app/src/main/res/layout/audio_attachment.xml new file mode 100644 index 0000000..db12e92 --- /dev/null +++ b/app/src/main/res/layout/audio_attachment.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_markdown_editing.xml b/app/src/main/res/layout/fragment_markdown_editing.xml index 47d1666..047d2ec 100644 --- a/app/src/main/res/layout/fragment_markdown_editing.xml +++ b/app/src/main/res/layout/fragment_markdown_editing.xml @@ -55,12 +55,36 @@ - + + android:layout_marginTop="4dp"> + + + + + + + diff --git a/app/src/main/res/menu/attach_audio_menu.xml b/app/src/main/res/menu/attach_audio_menu.xml new file mode 100644 index 0000000..77dc20f --- /dev/null +++ b/app/src/main/res/menu/attach_audio_menu.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8a7afa4..4b61099 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -179,4 +179,8 @@ New password is invalid. Please check it meets the requirements Rename Set as profile audio + Record new audio + Select from existing + Audio attachment + Remove \ No newline at end of file diff --git a/build.gradle b/build.gradle index 43fac4d..584f1f2 100644 --- a/build.gradle +++ b/build.gradle @@ -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 } \ No newline at end of file