diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UploadApi.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UploadApi.kt index 2144408..dcaa618 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UploadApi.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UploadApi.kt @@ -1,14 +1,13 @@ package com.ohdodok.catchytape.core.data.api -import com.ohdodok.catchytape.core.data.model.PreSignedUrlResponse import com.ohdodok.catchytape.core.data.model.UrlResponse import com.ohdodok.catchytape.core.data.model.UuidResponse import okhttp3.MultipartBody +import okhttp3.RequestBody import retrofit2.http.GET import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.Part -import retrofit2.http.Query interface UploadApi { @@ -16,22 +15,19 @@ interface UploadApi { suspend fun getUuid( ): UuidResponse - @GET("upload") - suspend fun getPreSignedUrl( - @Query("uuid") uuid: String, - @Query("type") type: String, - ): PreSignedUrlResponse - @Multipart @POST("upload/music") suspend fun postMusic( - @Part part: MultipartBody.Part, + @Part audio: MultipartBody.Part, + @Part("uuid") uuid: RequestBody, ): UrlResponse @Multipart @POST("upload/image") suspend fun postImage( - @Part part: MultipartBody.Part, + @Part image: MultipartBody.Part, + @Part("uuid") uuid: RequestBody, + @Part("type") type: RequestBody, ): UrlResponse } diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/RepositoryModule.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/RepositoryModule.kt index 7bd6376..f4a4572 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/RepositoryModule.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/RepositoryModule.kt @@ -3,13 +3,13 @@ package com.ohdodok.catchytape.core.data.di import com.ohdodok.catchytape.core.data.repository.AuthRepositoryImpl import com.ohdodok.catchytape.core.data.repository.MusicRepositoryImpl import com.ohdodok.catchytape.core.data.repository.PlaylistRepositoryImpl -import com.ohdodok.catchytape.core.data.repository.UrlRepositoryImpl +import com.ohdodok.catchytape.core.data.repository.UploadRepositoryImpl import com.ohdodok.catchytape.core.data.repository.UserTokenRepositoryImpl import com.ohdodok.catchytape.core.data.repository.UuidRepositoryImpl import com.ohdodok.catchytape.core.domain.repository.AuthRepository import com.ohdodok.catchytape.core.domain.repository.MusicRepository import com.ohdodok.catchytape.core.domain.repository.PlaylistRepository -import com.ohdodok.catchytape.core.domain.repository.UrlRepository +import com.ohdodok.catchytape.core.domain.repository.UploadRepository import com.ohdodok.catchytape.core.domain.repository.UserTokenRepository import com.ohdodok.catchytape.core.domain.repository.UuidRepository import dagger.Binds @@ -36,7 +36,7 @@ interface RepositoryModule { @Binds @Singleton - fun bindUrlRepository(urlRepositoryImpl: UrlRepositoryImpl): UrlRepository + fun bindUrlRepository(urlRepositoryImpl: UploadRepositoryImpl): UploadRepository @Binds @Singleton diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/UrlResponse.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/UrlResponse.kt index a816196..f2c135e 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/UrlResponse.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/UrlResponse.kt @@ -2,11 +2,6 @@ package com.ohdodok.catchytape.core.data.model import kotlinx.serialization.Serializable -@Serializable -data class PreSignedUrlResponse( - val signedUrl: String -) - @Serializable data class UrlResponse( val url: String diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/UploadRepositoryImpl.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/UploadRepositoryImpl.kt new file mode 100644 index 0000000..cf4251a --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/UploadRepositoryImpl.kt @@ -0,0 +1,45 @@ +package com.ohdodok.catchytape.core.data.repository + +import com.ohdodok.catchytape.core.data.api.UploadApi +import com.ohdodok.catchytape.core.domain.repository.UploadRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File +import javax.inject.Inject + +class UploadRepositoryImpl @Inject constructor( + private val uploadApi: UploadApi +) : UploadRepository { + + override fun uploadImage(uuid: String, file: File): Flow = flow { + val response = uploadApi.postImage( + image = file.toImageMultipart(), + uuid = uuid.toRequestBody(), + type = "cover".toRequestBody(), + ) + emit(response.url) + } + + override fun uploadAudio(uuid: String, file: File): Flow = flow { + val response = uploadApi.postMusic( + audio = file.toAudioMultipart(), + uuid = uuid.toRequestBody(), + ) + emit(response.url) + } + + private fun File.toAudioMultipart(): MultipartBody.Part { + val fileBody = this.asRequestBody("audio/mpeg".toMediaTypeOrNull()) + return MultipartBody.Part.createFormData("file", this.name, fileBody) + } + + private fun File.toImageMultipart(): MultipartBody.Part { + val fileBody = this.asRequestBody("image/png".toMediaTypeOrNull()) + return MultipartBody.Part.createFormData("file", this.name, fileBody) + } + +} \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/UrlRepositoryImpl.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/UrlRepositoryImpl.kt deleted file mode 100644 index aed8d8e..0000000 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/UrlRepositoryImpl.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.ohdodok.catchytape.core.data.repository - -import com.ohdodok.catchytape.core.data.api.UploadApi -import com.ohdodok.catchytape.core.domain.repository.UrlRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.MultipartBody -import okhttp3.RequestBody.Companion.asRequestBody -import java.io.File -import javax.inject.Inject - -class UrlRepositoryImpl @Inject constructor( - private val uploadApi: UploadApi -) : UrlRepository { - - - override fun getImageUrl(file: File): Flow = flow { - val urlResponse = uploadApi.postImage(file.toMultipart("image/png")) - emit(urlResponse.url) - } - - override fun getAudioUrl(file: File): Flow = flow { - val urlResponse = uploadApi.postMusic(file.toMultipart("audio/mpeg")) - emit(urlResponse.url) - } - - private fun File.toMultipart(contentType: String): MultipartBody.Part { - val fileBody = this.asRequestBody(contentType.toMediaTypeOrNull()) - return MultipartBody.Part.createFormData("file", this.name, fileBody) - } -} \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/UploadRepository.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/UploadRepository.kt new file mode 100644 index 0000000..ba2f7fe --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/UploadRepository.kt @@ -0,0 +1,12 @@ +package com.ohdodok.catchytape.core.domain.repository + +import kotlinx.coroutines.flow.Flow +import java.io.File + +interface UploadRepository { + + fun uploadImage(uuid: String, file: File): Flow + + fun uploadAudio(uuid: String, file: File): Flow + +} \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/UrlRepository.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/UrlRepository.kt deleted file mode 100644 index be54fa3..0000000 --- a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/UrlRepository.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.ohdodok.catchytape.core.domain.repository - -import kotlinx.coroutines.flow.Flow -import java.io.File - -interface UrlRepository { - - fun getImageUrl(file: File): Flow - - fun getAudioUrl(file: File): Flow - -} \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/upload/UploadFileUseCase.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/upload/UploadFileUseCase.kt new file mode 100644 index 0000000..62b9610 --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/upload/UploadFileUseCase.kt @@ -0,0 +1,23 @@ +package com.ohdodok.catchytape.core.domain.usecase.upload + +import com.ohdodok.catchytape.core.domain.repository.UploadRepository +import com.ohdodok.catchytape.core.domain.repository.UuidRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.io.File +import javax.inject.Inject + +class UploadFileUseCase @Inject constructor( + private val uuidRepository: UuidRepository, + private val uploadRepository: UploadRepository, +) { + + fun uploadAudio(audioFile: File): Flow = uuidRepository.getUuid().map { uuid -> + uploadRepository.uploadAudio(uuid, audioFile).first() + } + + fun uploadMusicCover(imageFile: File): Flow = uuidRepository.getUuid().map { uuid -> + uploadRepository.uploadImage(uuid, imageFile).first() + } +} \ No newline at end of file diff --git a/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadViewModel.kt b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadViewModel.kt index 725c5d4..a8e44f0 100644 --- a/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadViewModel.kt +++ b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadViewModel.kt @@ -5,31 +5,59 @@ import androidx.lifecycle.viewModelScope import com.ohdodok.catchytape.core.domain.model.CtErrorType import com.ohdodok.catchytape.core.domain.model.CtException import com.ohdodok.catchytape.core.domain.repository.MusicRepository -import com.ohdodok.catchytape.core.domain.repository.UrlRepository +import com.ohdodok.catchytape.core.domain.usecase.upload.UploadFileUseCase import com.ohdodok.catchytape.core.domain.usecase.upload.UploadMusicUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.plus import java.io.File import javax.inject.Inject +data class UploadUiState( + val musicTitle: String = "", + val musicGenre: String = "", + val imageState: UploadedFileState = UploadedFileState(), + val audioState: UploadedFileState = UploadedFileState(), + val encoding: Boolean = false, + val musicGenres: List = emptyList() +) { + val isLoading: Boolean + get() = imageState.isLoading || audioState.isLoading || encoding + + val isUploadEnable: Boolean + get() = musicTitle.isNotBlank() + && musicGenre.isNotBlank() + && imageState.url.isNotBlank() + && audioState.url.isNotBlank() + && !encoding +} + +data class UploadedFileState( + val isLoading: Boolean = false, + val url: String = "" +) + +sealed interface UploadEvent { + data object NavigateToBack : UploadEvent + data class ShowMessage(val error: CtErrorType) : UploadEvent +} + + @HiltViewModel class UploadViewModel @Inject constructor( private val musicRepository: MusicRepository, - private val urlRepository: UrlRepository, + private val uploadFileUseCase: UploadFileUseCase, private val uploadMusicUseCase: UploadMusicUseCase ) : ViewModel() { @@ -48,88 +76,62 @@ class UploadViewModel @Inject constructor( private val viewModelScopeWithExceptionHandler = viewModelScope + exceptionHandler - val musicTitle = MutableStateFlow("") - val musicGenre = MutableStateFlow("") - - private val _imageState: MutableStateFlow = - MutableStateFlow(UploadedFileState()) - val imageState = _imageState.asStateFlow() - - private val _audioState: MutableStateFlow = - MutableStateFlow(UploadedFileState()) - val audioState = _audioState.asStateFlow() - - val isLoading: StateFlow = combine(imageState, audioState) { imageState, audioState -> - imageState.isLoading || audioState.isLoading - }.stateIn( - scope = viewModelScopeWithExceptionHandler, - started = SharingStarted.Eagerly, - initialValue = false - ) - - val isUploadEnable: StateFlow = - combine( - musicTitle, musicGenre, imageState, audioState - ) { title, genre, imageState, audioState -> - title.isNotBlank() - && genre.isNotBlank() - && imageState.url.isNotBlank() - && audioState.url.isNotBlank() - }.stateIn(viewModelScopeWithExceptionHandler, SharingStarted.Eagerly, false) - - private val _musicGenres: MutableStateFlow> = MutableStateFlow(emptyList()) - val musicGenres = _musicGenres.asStateFlow() + private val _uiState: MutableStateFlow = MutableStateFlow(UploadUiState()) + val uiState: StateFlow = _uiState.asStateFlow() init { fetchGenres() } private fun fetchGenres() { - musicRepository.getGenres().onEach { - _musicGenres.value = it + musicRepository.getGenres().onEach { genres -> + _uiState.update { it.copy(musicGenres = genres) } }.launchIn(viewModelScope) } + fun updateMusicTitle(title: CharSequence) { + _uiState.update { it.copy(musicTitle = title.toString()) } + } + + fun updateMusicGenre(genre: CharSequence) { + _uiState.update { it.copy(musicGenre = genre.toString()) } + } + fun uploadImage(imageFile: File) { - urlRepository.getImageUrl(imageFile).onStart { - _imageState.value = imageState.value.copy(isLoading = true) + uploadFileUseCase.uploadMusicCover(imageFile).onStart { + _uiState.update { it.copy(imageState = it.imageState.copy(isLoading = true)) } }.onEach { url -> - _imageState.value = imageState.value.copy(url = url) + _uiState.update { it.copy(imageState = it.imageState.copy(url = url)) } }.onCompletion { - _imageState.value = imageState.value.copy(isLoading = false) + _uiState.update { it.copy(imageState = it.imageState.copy(isLoading = false)) } }.launchIn(viewModelScopeWithExceptionHandler) } fun uploadAudio(audioFile: File) { - urlRepository.getAudioUrl(audioFile).onStart { - _audioState.value = audioState.value.copy(isLoading = true) + uploadFileUseCase.uploadAudio(audioFile).onStart { + _uiState.update { it.copy(audioState = it.audioState.copy(isLoading = true)) } }.onEach { url -> - _audioState.value = audioState.value.copy(url = url) + _uiState.update { it.copy(audioState = it.audioState.copy(url = url)) } }.onCompletion { - _audioState.value = audioState.value.copy(isLoading = false) + _uiState.update { it.copy(audioState = it.audioState.copy(isLoading = false)) } }.launchIn(viewModelScopeWithExceptionHandler) } fun uploadMusic() { - if (isUploadEnable.value) { - uploadMusicUseCase( - imageUrl = imageState.value.url, - audioUrl = audioState.value.url, - title = musicTitle.value, - genre = musicGenre.value - ).onEach { - _events.emit(UploadEvent.NavigateToBack) - }.launchIn(viewModelScopeWithExceptionHandler) - } + if (!uiState.value.isUploadEnable) return + + uploadMusicUseCase( + imageUrl = uiState.value.imageState.url, + audioUrl = uiState.value.audioState.url, + title = uiState.value.musicTitle, + genre = uiState.value.musicGenre + ).onStart { + _uiState.update { it.copy(encoding = true) } + }.onEach { + _events.emit(UploadEvent.NavigateToBack) + }.onCompletion { + _uiState.update { it.copy(encoding = false) } + }.launchIn(viewModelScopeWithExceptionHandler) } } -data class UploadedFileState( - val isLoading: Boolean = false, - val url: String = "" -) - -sealed interface UploadEvent { - data object NavigateToBack : UploadEvent - data class ShowMessage(val error: CtErrorType) : UploadEvent -} diff --git a/android/feature/upload/src/main/res/layout/fragment_upload.xml b/android/feature/upload/src/main/res/layout/fragment_upload.xml index 0ab722b..d333c64 100644 --- a/android/feature/upload/src/main/res/layout/fragment_upload.xml +++ b/android/feature/upload/src/main/res/layout/fragment_upload.xml @@ -34,7 +34,7 @@ android:layout_gravity="end|center_vertical" android:layout_marginEnd="@dimen/margin_horizontal" android:background="@android:color/transparent" - android:enabled="@{viewModel.isUploadEnable}" + android:enabled="@{viewModel.uiState.uploadEnable}" android:text="@string/complete" /> @@ -44,10 +44,10 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:indeterminate="true" + android:visibility="@{viewModel.uiState.loading ? view.VISIBLE : view.GONE}" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/tb_upload" - android:visibility="@{viewModel.isLoading ? view.VISIBLE : view.GONE}" /> + app:layout_constraintTop_toBottomOf="@id/tb_upload" /> + app:imgUrl="@{viewModel.uiState.imageState.url}" /> + app:visibility="@{viewModel.uiState.imageState.url.empty ? view.VISIBLE : view.GONE}" /> @@ -89,8 +89,8 @@ android:layout_marginTop="@dimen/extra_large" android:drawableEnd="@drawable/ic_upload" android:drawableTint="@color/on_surface" - android:textColor="@color/on_surface" android:hint="@string/upload_file" + android:textColor="@color/on_surface" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/cv_upload_thumbnail" /> @@ -112,7 +112,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:maxLength="10" - android:text="@={viewModel.musicTitle}" /> + android:onTextChanged="@{(text, start, before, count) -> viewModel.updateMusicTitle(text)}" /> @@ -133,8 +133,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="none" - android:text="@={viewModel.musicGenre}" - app:list="@{viewModel.musicGenres}" /> + android:onTextChanged="@{(text, start, before, count) -> viewModel.updateMusicGenre(text)}" + app:list="@{viewModel.uiState.musicGenres}" />