diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/MusicApi.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/MusicApi.kt index 83c712b..f93f719 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/MusicApi.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/MusicApi.kt @@ -1,12 +1,20 @@ package com.ohdodok.catchytape.core.data.api import com.ohdodok.catchytape.core.data.model.MusicGenresResponse +import com.ohdodok.catchytape.core.data.model.MusicRequest import retrofit2.Response +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.POST interface MusicApi { @GET("musics/genres") suspend fun getGenres(): Response + @POST("musics") + suspend fun postMusic( + @Body music: MusicRequest + ): Response + } \ No newline at end of file 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 new file mode 100644 index 0000000..902ee7f --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UploadApi.kt @@ -0,0 +1,20 @@ +package com.ohdodok.catchytape.core.data.api + +import com.ohdodok.catchytape.core.data.model.UrlResponse +import retrofit2.Response +import retrofit2.http.Headers +import retrofit2.http.POST + +interface UploadApi { + + @POST("upload/music") + @Headers("Content-Type: audio/mpeg") + suspend fun uploadMusic( + ): Response + + @POST("upload/image") + @Headers("Content-Type: image/png") + suspend fun uploadImage( + ): Response + +} \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/ApiModule.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/ApiModule.kt index 9d2c873..7ad8229 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/ApiModule.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/ApiModule.kt @@ -1,6 +1,7 @@ package com.ohdodok.catchytape.core.data.di import com.ohdodok.catchytape.core.data.api.MusicApi +import com.ohdodok.catchytape.core.data.api.UploadApi import com.ohdodok.catchytape.core.data.api.UserApi import dagger.Module import dagger.Provides @@ -24,4 +25,10 @@ object ApiModule { fun provideMusicApi(retrofit: Retrofit): MusicApi { return retrofit.create(MusicApi::class.java) } + + @Provides + @Singleton + fun provideUploadApi(retrofit: Retrofit): UploadApi { + return retrofit.create(UploadApi::class.java) + } } \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicRequest.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicRequest.kt new file mode 100644 index 0000000..ed724fe --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicRequest.kt @@ -0,0 +1,7 @@ +package com.ohdodok.catchytape.core.data.model + +data class MusicRequest ( + val title: String, + val cover: String, + val file: String +) \ No newline at end of file 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 new file mode 100644 index 0000000..c359d14 --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/UrlResponse.kt @@ -0,0 +1,5 @@ +package com.ohdodok.catchytape.core.data.model + +data class UrlResponse( + val url: String +) \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/UploadFileUseCase.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/UploadFileUseCase.kt new file mode 100644 index 0000000..b2a7d7f --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/UploadFileUseCase.kt @@ -0,0 +1,24 @@ +package com.ohdodok.catchytape.core.domain.usecase + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import java.io.File +import javax.inject.Inject + +class UploadFileUseCase @Inject constructor( + +) { + + fun getImgUrl(file: File): Flow = flow { + // todo : 서버 기다리는 중.. + delay(1000) + emit("https://kr.object.ncloudstorage.com/catchy-tape-bucket2/image/%EC%8A%A4%ED%81%AC%EB%A6%B0%EC%83%B7%202023-11-21%20180100.png") + } + + fun getAudioUrl(file: File): Flow = flow { + // todo : 서버 기다리는 중.. + delay(1000) + emit("https://kr.object.ncloudstorage.com/catchy-tape-bucket2/music/2/%EC%9D%B4%EB%85%B8%EB%9E%98.mp3") + } +} \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/UploadMusicUseCase.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/UploadMusicUseCase.kt new file mode 100644 index 0000000..c6a94dd --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/UploadMusicUseCase.kt @@ -0,0 +1,13 @@ +package com.ohdodok.catchytape.core.domain.usecase + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class UploadMusicUseCase @Inject constructor() { + + operator fun invoke(imgUrl: String, audioUrl: String, title: String, genre: String): Flow = flow { + // todo : 서버에 업로드 + emit(Unit) + } +} \ No newline at end of file diff --git a/android/core/ui/build.gradle.kts b/android/core/ui/build.gradle.kts index 1a1a69c..f664468 100644 --- a/android/core/ui/build.gradle.kts +++ b/android/core/ui/build.gradle.kts @@ -33,10 +33,13 @@ android { dependencies { + implementation(project(":core:domain")) + api(libs.material) api(libs.navigation.fragment.ktx) api(libs.navigation.ui.ktx) - implementation(project(":core:domain")) + api(libs.glide) + } \ No newline at end of file diff --git a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/BindingAdapter.kt b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/BindingAdapter.kt index 8306400..54821fa 100644 --- a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/BindingAdapter.kt +++ b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/BindingAdapter.kt @@ -1,13 +1,21 @@ package com.ohdodok.catchytape.core.ui +import android.widget.ImageView import androidx.databinding.BindingAdapter import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView - +import com.bumptech.glide.Glide @BindingAdapter("submitList") fun RecyclerView.bindItems(items: List) { val adapter = this.adapter ?: return val listAdapter: ListAdapter = adapter as ListAdapter listAdapter.submitList(items) +} + +@BindingAdapter("imgUrl") +fun ImageView.bindImg(url: String) { + Glide.with(this.context) + .load(url) + .into(this) } \ No newline at end of file diff --git a/android/core/ui/src/main/res/drawable/ic_camera.xml b/android/core/ui/src/main/res/drawable/ic_camera.xml index 364476e..5a798d6 100644 --- a/android/core/ui/src/main/res/drawable/ic_camera.xml +++ b/android/core/ui/src/main/res/drawable/ic_camera.xml @@ -5,5 +5,5 @@ android:viewportHeight="24"> + android:fillColor="@color/black"/> diff --git a/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/BindingAdapter.kt b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/BindingAdapter.kt index 4af0ce0..8489ec3 100644 --- a/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/BindingAdapter.kt +++ b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/BindingAdapter.kt @@ -5,12 +5,6 @@ import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView import androidx.databinding.BindingAdapter - -@BindingAdapter("changeSelectedPosition") -fun AutoCompleteTextView.bindPosition(onChange: (Int) -> Unit) { - setOnItemClickListener { _, _, position, _ -> onChange(position) } -} - @BindingAdapter("list") fun AutoCompleteTextView.setAdapter(list: List) { val adapter = ArrayAdapter(this.context, R.layout.simple_list_item_1, list) diff --git a/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadFragment.kt b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadFragment.kt index 2af7630..b82c3aa 100644 --- a/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadFragment.kt +++ b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadFragment.kt @@ -1,16 +1,18 @@ package com.ohdodok.catchytape.feature.upload +import android.net.Uri import android.os.Bundle +import android.provider.OpenableColumns import android.view.View import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia -import androidx.core.net.toFile import androidx.fragment.app.viewModels import com.ohdodok.catchytape.catchytape.upload.R import com.ohdodok.catchytape.catchytape.upload.databinding.FragmentUploadBinding import com.ohdodok.catchytape.core.ui.BaseFragment import dagger.hilt.android.AndroidEntryPoint +import java.io.File @AndroidEntryPoint class UploadFragment : BaseFragment(R.layout.fragment_upload) { @@ -18,28 +20,43 @@ class UploadFragment : BaseFragment(R.layout.fragment_upl private val imagePickerLauncher = registerForActivityResult(PickVisualMedia()) { uri -> if (uri == null) return@registerForActivityResult - - viewModel.uploadImage(uri.toFile()) + uri.path?.let { viewModel.uploadImage(File(it)) } } private val filePickerLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> if (uri == null) return@registerForActivityResult - - viewModel.uploadAudio(uri.toFile()) + binding.btnFile.text = getFileName(uri) + uri.path?.let { viewModel.uploadAudio(File(it)) } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.viewModel = viewModel - + setUpFileBtn() setupBackStack(binding.tbUpload) setupSelectThumbnailImage() } + private fun setUpFileBtn() { + binding.btnFile.setOnClickListener { + filePickerLauncher.launch("audio/*") + } + } + private fun setupSelectThumbnailImage() { binding.cvUploadThumbnail.setOnClickListener { imagePickerLauncher.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) } } + + private fun getFileName(uri: Uri): String? { + val contentResolver = requireContext().contentResolver + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + return cursor.getString(nameIndex) + } + return null + } } \ 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 2e600c7..e8b66ad 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 @@ -1,40 +1,62 @@ package com.ohdodok.catchytape.feature.upload +import android.net.Uri import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import java.io.File import javax.inject.Inject import androidx.lifecycle.viewModelScope +import com.ohdodok.catchytape.core.domain.usecase.UploadFileUseCase import com.ohdodok.catchytape.core.domain.usecase.GetMusicGenresUseCase +import com.ohdodok.catchytape.core.domain.usecase.UploadMusicUseCase import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch 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 @HiltViewModel class UploadViewModel @Inject constructor( - private val getMusicGenresUseCase: GetMusicGenresUseCase + private val getMusicGenresUseCase: GetMusicGenresUseCase, + private val uploadFileUseCase: UploadFileUseCase, + private val uploadMusicUseCase: UploadMusicUseCase ) : ViewModel() { - private var uploadedImage: String? = null + val musicTitle = MutableStateFlow("") + val musicGenre = MutableStateFlow("") - val uploadedMusicTitle = MutableStateFlow("") + private val _imageState: MutableStateFlow = + MutableStateFlow(UploadedFileState()) + val imageState = _imageState.asStateFlow() - private val _uploadedMusicGenrePosition = MutableStateFlow(-1) - val uploadedMusicGenrePosition = _uploadedMusicGenrePosition.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 = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = false + ) val isUploadEnable: StateFlow = - combine(uploadedMusicTitle, uploadedMusicGenrePosition) { title, genrePosition -> - title.isNotBlank() && genrePosition != -1 + combine( + musicTitle, musicGenre, imageState, audioState + ) { title, genre, imageState, audioState -> + title.isNotBlank() + && genre.isNotBlank() + && imageState.url.isNotBlank() + && audioState.url.isNotBlank() }.stateIn(viewModelScope, SharingStarted.Eagerly, false) - val onChangePosition: (Int) -> Unit = - { position: Int -> _uploadedMusicGenrePosition.value = position } - private val _musicGenres: MutableStateFlow> = MutableStateFlow(emptyList()) val musicGenres = _musicGenres.asStateFlow() @@ -49,11 +71,43 @@ class UploadViewModel @Inject constructor( } fun uploadImage(imageFile: File) { - // todo : image 파일을 업로드 한다. - // todo : 반환 값을 uploadedImage에 저장한다. + uploadFileUseCase.getImgUrl(imageFile).onStart { + _imageState.value = imageState.value.copy(isLoading = true) + }.onEach { url -> + _imageState.value = imageState.value.copy(url = url) + }.onCompletion { + _imageState.value = imageState.value.copy(isLoading = false) + }.launchIn(viewModelScope) } fun uploadAudio(audioFile: File) { - // todo : audio 파일을 업로드 한다. + uploadFileUseCase.getAudioUrl(audioFile).onStart { + _audioState.value = audioState.value.copy(isLoading = true) + }.onEach { url -> + _audioState.value = audioState.value.copy(url = url) + }.onCompletion { + _audioState.value = audioState.value.copy(isLoading = false) + }.launchIn(viewModelScope) } -} \ No newline at end of file + + fun uploadMusic() { + if (isUploadEnable.value) { + uploadMusicUseCase( + imgUrl = imageState.value.url, + audioUrl = audioState.value.url, + title = musicTitle.value, + genre = musicGenre.value + ).onEach { + // TODO : 업로드 성공 + }.catch { + // TODO : 업로드 실패 + }.launchIn(viewModelScope) + } + } +} + +data class UploadedFileState( + val isLoading: Boolean = false, + val url: String = "" +) + 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 762b7ed..9c988c5 100644 --- a/android/feature/upload/src/main/res/layout/fragment_upload.xml +++ b/android/feature/upload/src/main/res/layout/fragment_upload.xml @@ -4,6 +4,10 @@ + + @@ -34,9 +38,18 @@ android:enabled="@{viewModel.isUploadEnable}" android:text="@string/complete" /> - + + + android:importantForAccessibility="no" + app:imgUrl="@{viewModel.imageState.url}" /> + android:src="@drawable/ic_camera" + app:visibility="@{viewModel.imageState.url.empty ? view.VISIBLE : view.GONE}" /> + + + app:layout_constraintTop_toBottomOf="@id/btn_file"> + android:text="@={viewModel.musicTitle}" /> @@ -95,7 +123,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginHorizontal="@dimen/margin_horizontal" - android:layout_marginTop="@dimen/medium" + android:layout_marginTop="@dimen/large" android:hint="@string/genre" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -106,11 +134,10 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="none" - app:changeSelectedPosition="@{viewModel.onChangePosition}" + android:text="@={viewModel.musicGenre}" app:list="@{viewModel.musicGenres}" /> - \ No newline at end of file diff --git a/android/feature/upload/src/main/res/values/strings.xml b/android/feature/upload/src/main/res/values/strings.xml new file mode 100644 index 0000000..00bf9a5 --- /dev/null +++ b/android/feature/upload/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + 파일 업로드 + \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index d09892e..1452828 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -23,6 +23,7 @@ kotlinx-serialization = "1.6.0" kotlinx-serialization-converter = "1.0.0" coroutines = "1.3.5" +glide = "4.15.0" timber = "5.0.1" [libraries] @@ -55,6 +56,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "kotlinx-serialization-converter" } coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } +glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } [plugins]