diff --git a/app/src/main/java/team/ppac/navigation/FarmemeNavHost.kt b/app/src/main/java/team/ppac/navigation/FarmemeNavHost.kt index 2d7f3ab5..da492dc9 100644 --- a/app/src/main/java/team/ppac/navigation/FarmemeNavHost.kt +++ b/app/src/main/java/team/ppac/navigation/FarmemeNavHost.kt @@ -37,7 +37,8 @@ fun FarmemeNavHost( exitTransition = { ExitTransition.None }, ) { recommendationScreen( - analyticsHelper = analyticsHelper + analyticsHelper = analyticsHelper, + navigateToRegister = navigateToRegister, ) searchScreen( analyticsHelper = analyticsHelper, diff --git a/core/data/src/main/java/team/ppac/data/repository/MemeRepositoryImpl.kt b/core/data/src/main/java/team/ppac/data/repository/MemeRepositoryImpl.kt index 149ece18..8b2d3c52 100644 --- a/core/data/src/main/java/team/ppac/data/repository/MemeRepositoryImpl.kt +++ b/core/data/src/main/java/team/ppac/data/repository/MemeRepositoryImpl.kt @@ -71,6 +71,20 @@ internal class MemeRepositoryImpl @Inject constructor( override val savedMemeEventFlow: Flow get() = _savedMemeEventFlow + override suspend fun uploadMeme( + keywordIds: List, + memeImageUri: String, + memeTitle: String, + memeSource: String + ): Boolean { + return memeDataSource.uploadMeme( + keywordIds = keywordIds, + memeTitle = memeTitle, + memeImageUri = memeImageUri, + memeSource = memeSource + ) + } + override suspend fun emitRefreshEvent() { _savedMemeEventFlow.emit(SavedMemeEvent.Refresh) } diff --git a/core/domain/src/main/java/team/ppac/domain/di/UseCaseModule.kt b/core/domain/src/main/java/team/ppac/domain/di/UseCaseModule.kt index 1405bd0c..7eb46cac 100644 --- a/core/domain/src/main/java/team/ppac/domain/di/UseCaseModule.kt +++ b/core/domain/src/main/java/team/ppac/domain/di/UseCaseModule.kt @@ -39,6 +39,8 @@ import team.ppac.domain.usecase.SaveMemeUseCase import team.ppac.domain.usecase.SaveMemeUseCaseImpl import team.ppac.domain.usecase.SetLevelUseCase import team.ppac.domain.usecase.SetLevelUseCaseImpl +import team.ppac.domain.usecase.UploadMemeUseCase +import team.ppac.domain.usecase.UploadMemeUseCaseImpl import team.ppac.domain.usecase.WatchMemeUseCase import team.ppac.domain.usecase.WatchMemeUseCaseImpl @@ -116,4 +118,8 @@ internal abstract class UseCaseModule { @Binds @ViewModelScoped abstract fun bindEmitRefreshEventUseCase(impl: EmitRefreshEventUseCaseImpl): EmitRefreshEventUseCase + + @Binds + @ViewModelScoped + abstract fun bindUploadMemeUseCase(impl: UploadMemeUseCaseImpl): UploadMemeUseCase } \ No newline at end of file diff --git a/core/domain/src/main/java/team/ppac/domain/repository/MemeRepository.kt b/core/domain/src/main/java/team/ppac/domain/repository/MemeRepository.kt index 5f5821c2..cae39cf3 100644 --- a/core/domain/src/main/java/team/ppac/domain/repository/MemeRepository.kt +++ b/core/domain/src/main/java/team/ppac/domain/repository/MemeRepository.kt @@ -1,6 +1,5 @@ package team.ppac.domain.repository -import androidx.paging.PagingData import kotlinx.coroutines.flow.Flow import team.ppac.domain.model.Meme import team.ppac.domain.model.MemeWatchType @@ -15,6 +14,7 @@ interface MemeRepository { keyword: String, getCurrentPage: (Int) -> Unit ): MemeWithPagination + suspend fun reactMeme(memeId: String): Boolean suspend fun watchMeme( memeId: String, @@ -23,6 +23,12 @@ interface MemeRepository { suspend fun emitRefreshEvent() val savedMemeEventFlow: Flow + suspend fun uploadMeme( + keywordIds: List, + memeImageUri: String, + memeTitle: String, + memeSource: String + ): Boolean } sealed class SavedMemeEvent { diff --git a/core/domain/src/main/java/team/ppac/domain/usecase/UploadMemeUseCase.kt b/core/domain/src/main/java/team/ppac/domain/usecase/UploadMemeUseCase.kt new file mode 100644 index 00000000..204a80bf --- /dev/null +++ b/core/domain/src/main/java/team/ppac/domain/usecase/UploadMemeUseCase.kt @@ -0,0 +1,31 @@ +package team.ppac.domain.usecase + +import team.ppac.domain.repository.MemeRepository +import javax.inject.Inject + +interface UploadMemeUseCase { + suspend operator fun invoke( + keywordIds: List, + memeImageUri: String, + memeTitle: String, + memeSource: String + ): Boolean +} + +internal class UploadMemeUseCaseImpl @Inject constructor( + private val memeRepository: MemeRepository +) : UploadMemeUseCase { + override suspend fun invoke( + keywordIds: List, + memeImageUri: String, + memeTitle: String, + memeSource: String + ): Boolean { + return memeRepository.uploadMeme( + keywordIds = keywordIds, + memeTitle = memeTitle, + memeImageUri = memeImageUri, + memeSource = memeSource + ) + } +} \ No newline at end of file diff --git a/core/remote/src/main/kotlin/team/ppac/remote/api/MemeApi.kt b/core/remote/src/main/kotlin/team/ppac/remote/api/MemeApi.kt index 10960dbf..feb465c8 100644 --- a/core/remote/src/main/kotlin/team/ppac/remote/api/MemeApi.kt +++ b/core/remote/src/main/kotlin/team/ppac/remote/api/MemeApi.kt @@ -1,11 +1,16 @@ package team.ppac.remote.api +import okhttp3.MultipartBody +import okhttp3.RequestBody import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query import team.ppac.remote.model.response.meme.MemeResponse +import team.ppac.remote.model.response.meme.UploadMemeResponse import team.ppac.remote.model.response.user.SavedMemesResponse internal interface MemeApi { @@ -39,4 +44,13 @@ internal interface MemeApi { @Path("memeId") memeId: String, @Path("type") type: String, ): Boolean + + @Multipart + @POST("/api/meme") + suspend fun postMeme( + @Part image: MultipartBody.Part, + @Part("title") title: RequestBody, + @Part("source") source: RequestBody, + @Part("keywordIds[]") keywordIds: ArrayList, + ): UploadMemeResponse } diff --git a/core/remote/src/main/kotlin/team/ppac/remote/datasource/MemeDataSource.kt b/core/remote/src/main/kotlin/team/ppac/remote/datasource/MemeDataSource.kt index d4d4c9de..9c6428db 100644 --- a/core/remote/src/main/kotlin/team/ppac/remote/datasource/MemeDataSource.kt +++ b/core/remote/src/main/kotlin/team/ppac/remote/datasource/MemeDataSource.kt @@ -13,9 +13,17 @@ interface MemeDataSource { page: Int, size: Int, ): SavedMemesResponse + suspend fun reactMeme(memeId: String): Boolean suspend fun watchMeme( memeId: String, type: String, ): Boolean + + suspend fun uploadMeme( + keywordIds: List, + memeImageUri: String, + memeTitle: String, + memeSource: String + ): Boolean } \ No newline at end of file diff --git a/core/remote/src/main/kotlin/team/ppac/remote/datasource/impl/MemeDataSourceImpl.kt b/core/remote/src/main/kotlin/team/ppac/remote/datasource/impl/MemeDataSourceImpl.kt index ec17967c..90b611ab 100644 --- a/core/remote/src/main/kotlin/team/ppac/remote/datasource/impl/MemeDataSourceImpl.kt +++ b/core/remote/src/main/kotlin/team/ppac/remote/datasource/impl/MemeDataSourceImpl.kt @@ -1,13 +1,28 @@ package team.ppac.remote.datasource.impl +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import dagger.hilt.android.qualifiers.ApplicationContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody import team.ppac.remote.api.MemeApi import team.ppac.remote.datasource.MemeDataSource import team.ppac.remote.model.response.meme.MemeResponse import team.ppac.remote.model.response.user.SavedMemesResponse +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream import javax.inject.Inject + internal class MemeDataSourceImpl @Inject constructor( private val memeApi: MemeApi, + @ApplicationContext private val context: Context, ) : MemeDataSource { override suspend fun getMemeById(memeId: String): MemeResponse { return memeApi.getMemeById(memeId = memeId) @@ -36,4 +51,59 @@ internal class MemeDataSourceImpl @Inject constructor( override suspend fun watchMeme(memeId: String, type: String): Boolean { return memeApi.watchMeme(memeId, type) } -} \ No newline at end of file + + override suspend fun uploadMeme( + keywordIds: List, + memeImageUri: String, + memeTitle: String, + memeSource: String + ): Boolean { + val file = + getFileFromUri(memeImageUri.toUri()) ?: throw IllegalStateException("파일을 찾을 수 없습니다.") + val imageBody = file.asRequestBody("image/*".toMediaTypeOrNull()) + val imagePart = MultipartBody.Part.createFormData("image", file.name, imageBody) + + val titleRequest = memeTitle.toRequestBody("text/plain".toMediaTypeOrNull()) + val sourceRequest = memeSource.toRequestBody("text/plain".toMediaTypeOrNull()) + val keywordIdRequests = + keywordIds.map { it.toRequestBody("text/plain".toMediaTypeOrNull()) } + memeApi.postMeme( + image = imagePart, + title = titleRequest, + source = sourceRequest, + keywordIds = ArrayList(keywordIdRequests) + ) + return true + } + + private fun getFileFromUri(uri: Uri): File? { + val contentResolver: ContentResolver = context.contentResolver + val fileName = getFileName(contentResolver, uri) ?: return null // 파일 이름 얻기 + val tempFile = File(context.cacheDir, fileName) + + try { + val inputStream: InputStream? = contentResolver.openInputStream(uri) + val outputStream: OutputStream = FileOutputStream(tempFile) + + inputStream?.use { input -> + outputStream.use { output -> + input.copyTo(output) + } + } + return tempFile + } catch (e: Exception) { + e.printStackTrace() + } + return null + } + + private fun getFileName(contentResolver: ContentResolver, uri: Uri): String? { + val cursor = contentResolver.query(uri, null, null, null, null) + return cursor?.use { + if (it.moveToFirst()) { + val index = it.getColumnIndex("_display_name") + it.getString(index) + } else null + } + } +} diff --git a/core/remote/src/main/kotlin/team/ppac/remote/model/response/meme/UploadMemeResponse.kt b/core/remote/src/main/kotlin/team/ppac/remote/model/response/meme/UploadMemeResponse.kt new file mode 100644 index 00000000..d9552f46 --- /dev/null +++ b/core/remote/src/main/kotlin/team/ppac/remote/model/response/meme/UploadMemeResponse.kt @@ -0,0 +1,30 @@ +package team.ppac.remote.model.response.meme + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class UploadMemeResponse( + @field:Json(name = "deviceId") + val deviceId: String, + @field:Json(name = "title") + val title: String, + @field:Json(name = "keywordIds") + val keywords: List?, + @field:Json(name = "image") + val image: String, + @field:Json(name = "reaction") + val reaction: Int, + @field:Json(name = "source") + val source: String, + @field:Json(name = "isTodayMeme") + val isTodayMeme: Boolean, + @field:Json(name = "isDeleted") + val isDeleted: Boolean, + @field:Json(name = "_id") + val id: String, + @field:Json(name = "createdAt") + val createdAt: String, + @field:Json(name = "updatedAt") + val updatedAt: String, +) diff --git a/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationRoute.kt b/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationRoute.kt index 32d5967e..191f5232 100644 --- a/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationRoute.kt +++ b/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationRoute.kt @@ -30,6 +30,7 @@ import team.ppac.recommendation.mvi.RecommendationSideEffect @Composable internal fun RecommendationRoute( analyticsHelper: AnalyticsHelper, + navigateToRegister: () -> Unit, viewModel: RecommendationViewModel = hiltViewModel(), ) { val context = LocalContext.current @@ -138,6 +139,10 @@ internal fun RecommendationRoute( screen = ScreenType.RECOMMENDATION, ) } + + RecommendationSideEffect.NavigateToRegister -> { + navigateToRegister() + } } } } @@ -169,6 +174,9 @@ internal fun RecommendationRoute( memeBitmap[index] = bitmap }, onActionButtonsIntentClick = viewModel::intent, + onUpload = { + viewModel.intent(RecommendationIntent.ClickUpload) + } ) } } \ No newline at end of file diff --git a/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationScreen.kt b/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationScreen.kt index 41d669ac..55c46c93 100644 --- a/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationScreen.kt +++ b/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationScreen.kt @@ -77,6 +77,7 @@ internal fun RecommendationScreen( onRetryClick: () -> Unit, onLoadMeme: (Int, Bitmap) -> Unit, onScrollPager: (Int, Meme) -> Unit, + onUpload: () -> Unit, onActionButtonsIntentClick: (RecommendationIntent.ClickButton) -> Unit, ) { val heroModulePagerState = rememberPagerState { state.thisWeekMemes.size } @@ -151,7 +152,7 @@ internal fun RecommendationScreen( } else { UploadButton( onClick = { - + onUpload() } ) } diff --git a/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationViewModel.kt b/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationViewModel.kt index c7856496..e493199f 100644 --- a/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationViewModel.kt +++ b/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationViewModel.kt @@ -139,6 +139,10 @@ class RecommendationViewModel @Inject constructor( copy(isError = false) } } + + RecommendationIntent.ClickUpload -> { + postSideEffect(RecommendationSideEffect.NavigateToRegister) + } } } diff --git a/feature/recommendation/src/main/java/team/ppac/recommendation/mvi/RecommendationIntent.kt b/feature/recommendation/src/main/java/team/ppac/recommendation/mvi/RecommendationIntent.kt index 4bb16c7f..3a7b0770 100644 --- a/feature/recommendation/src/main/java/team/ppac/recommendation/mvi/RecommendationIntent.kt +++ b/feature/recommendation/src/main/java/team/ppac/recommendation/mvi/RecommendationIntent.kt @@ -6,6 +6,8 @@ import team.ppac.domain.model.Meme sealed interface RecommendationIntent : UiIntent { data object Init : RecommendationIntent data object PullRefresh : RecommendationIntent + data object ClickUpload : RecommendationIntent + data class MovePage( val meme: Meme, val currentPage: Int, diff --git a/feature/recommendation/src/main/java/team/ppac/recommendation/mvi/RecommendationSideEffect.kt b/feature/recommendation/src/main/java/team/ppac/recommendation/mvi/RecommendationSideEffect.kt index eca1df4b..6a7a3706 100644 --- a/feature/recommendation/src/main/java/team/ppac/recommendation/mvi/RecommendationSideEffect.kt +++ b/feature/recommendation/src/main/java/team/ppac/recommendation/mvi/RecommendationSideEffect.kt @@ -8,6 +8,8 @@ sealed interface RecommendationSideEffect : UiSideEffect { data class CopyClipBoard(val selectedMemeIndex: Int) : RecommendationSideEffect data class ShareLink(val memeId: String) : RecommendationSideEffect data object LogHashTagsClicked : RecommendationSideEffect + data object NavigateToRegister : RecommendationSideEffect + data class LogSaveMeme(val meme: Meme) : RecommendationSideEffect data class LogSaveMemeCancel(val meme: Meme) : RecommendationSideEffect } \ No newline at end of file diff --git a/feature/recommendation/src/main/java/team/ppac/recommendation/navigation/RecommendationNavigation.kt b/feature/recommendation/src/main/java/team/ppac/recommendation/navigation/RecommendationNavigation.kt index 90d9f116..97dc49ae 100644 --- a/feature/recommendation/src/main/java/team/ppac/recommendation/navigation/RecommendationNavigation.kt +++ b/feature/recommendation/src/main/java/team/ppac/recommendation/navigation/RecommendationNavigation.kt @@ -13,12 +13,14 @@ fun NavController.navigateToRecommendation(navOptions: NavOptions) = navigate(RE fun NavGraphBuilder.recommendationScreen( analyticsHelper: AnalyticsHelper, + navigateToRegister: () -> Unit ) { composable( route = RECOMMENDATION_ROUTE ) { RecommendationRoute( - analyticsHelper = analyticsHelper + analyticsHelper = analyticsHelper, + navigateToRegister = navigateToRegister, ) } } \ No newline at end of file diff --git a/feature/register/src/main/java/team/ppac/register/RegisterRoute.kt b/feature/register/src/main/java/team/ppac/register/RegisterRoute.kt index fcd4ce6f..64106eee 100644 --- a/feature/register/src/main/java/team/ppac/register/RegisterRoute.kt +++ b/feature/register/src/main/java/team/ppac/register/RegisterRoute.kt @@ -22,6 +22,7 @@ internal fun RegisterRoute( RegisterScreen( uiState = uiState, navigateToBack = navigateToBack, + onIntent = viewModel::intent, ) } } \ No newline at end of file diff --git a/feature/register/src/main/java/team/ppac/register/RegisterScreen.kt b/feature/register/src/main/java/team/ppac/register/RegisterScreen.kt index d3d05894..6ee1feeb 100644 --- a/feature/register/src/main/java/team/ppac/register/RegisterScreen.kt +++ b/feature/register/src/main/java/team/ppac/register/RegisterScreen.kt @@ -1,9 +1,16 @@ package team.ppac.register +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -12,8 +19,11 @@ import androidx.compose.material.Divider import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.toImmutableList +import team.ppac.common.android.component.error.FarmemeErrorScreen import team.ppac.designsystem.FarmemeTheme import team.ppac.designsystem.component.scaffold.FarmemeScaffold import team.ppac.designsystem.component.toolbar.FarmemeBackToolBar @@ -22,13 +32,22 @@ import team.ppac.register.component.RegisterCategoryContent import team.ppac.register.component.RegisterImageArea import team.ppac.register.component.RegisterInputArea import team.ppac.register.component.RegisterKeywordHeader +import team.ppac.register.component.UploadMemeResultDialog +import team.ppac.register.mvi.RegisterIntent import team.ppac.register.mvi.RegisterUiState @Composable internal fun RegisterScreen( uiState: RegisterUiState, navigateToBack: () -> Unit, + onIntent: (RegisterIntent) -> Unit, ) { + val imagePicker = + rememberLauncherForActivityResult(contract = ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) { + onIntent(RegisterIntent.SetImageFromGallery(uri.toString())) + } + } FarmemeScaffold( modifier = Modifier.navigationBarsPadding(), topBar = { @@ -44,40 +63,100 @@ internal fun RegisterScreen( } }, bottomBar = { - RegisterButton( - modifier = Modifier.padding(bottom = 36.dp), - text = "등록하기", - enabled = true, - onClick = {}, - ) + if (uiState.isError.not()) { + Box(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .height(120.dp) + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + 0f to FarmemeTheme.backgroundColor.white.copy(alpha = 0f), + 1f to FarmemeTheme.backgroundColor.white.copy(alpha = 1f), + ) + ) + ) + RegisterButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 36.dp), + text = "등록하기", + enabled = true, + onClick = { + onIntent(RegisterIntent.ClickRegister) + }, + ) + } + } } ) { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - item { - RegisterImageArea( - hasImage = false, - ) - } - item { RegisterInputArea() } - item { - Divider( - modifier = Modifier.fillMaxWidth(), - color = FarmemeTheme.skeletonColor.primary, - thickness = 10.dp, - ) - } - item { RegisterKeywordHeader() } - items(items = uiState.registerCategories) { registerCategory -> - RegisterCategoryContent( - uiModel = registerCategory, - ) + if (uiState.isError) { + FarmemeErrorScreen( + modifier = Modifier.fillMaxSize(), + title = "정보를 불러오지 못 했어요.\n 새로고침 해주세요.", + onRetryClick = { + onIntent(RegisterIntent.OnRetry) + } + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + RegisterImageArea( + loadImage = { imagePicker.launch(PickVisualMediaRequest(mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly)) }, + imageUri = uiState.imageUri, + ) + } + item { + RegisterInputArea( + modifier = Modifier.padding(horizontal = 20.dp), + title = uiState.title, + onTitleChanged = { onIntent(RegisterIntent.InputTitle(it)) }, + source = uiState.source, + onSourceChanged = { onIntent(RegisterIntent.InputSource(it)) }, + ) + } + item { + Divider( + modifier = Modifier.fillMaxWidth(), + color = FarmemeTheme.skeletonColor.primary, + thickness = 10.dp, + ) + } + item { + RegisterKeywordHeader(modifier = Modifier.padding(horizontal = 20.dp)) + } + items(items = uiState.registerCategories) { registerCategory -> + RegisterCategoryContent( + uiModel = registerCategory, + selectedKeywords = uiState.selectedKeywords.toImmutableList(), + onKeywordClick = { + onIntent(RegisterIntent.OnKeywordClick(it)) + } + ) + } + item { + Spacer( + modifier = Modifier + .height(120.dp) + .imePadding() + ) + } } - item { Spacer(modifier = Modifier.height(120.dp)) } } } + if (uiState.uploadMemeResultDialogVisible) { + UploadMemeResultDialog( + onConfirmClick = { + navigateToBack() + }, + onDismiss = { + navigateToBack() + } + ) + } } @Preview @@ -86,5 +165,6 @@ private fun RegisterScreenPreview() { RegisterScreen( uiState = RegisterUiState.INITIAL_STATE, navigateToBack = {}, + onIntent = {}, ) } \ No newline at end of file diff --git a/feature/register/src/main/java/team/ppac/register/RegisterViewModel.kt b/feature/register/src/main/java/team/ppac/register/RegisterViewModel.kt index f5006d08..8169192b 100644 --- a/feature/register/src/main/java/team/ppac/register/RegisterViewModel.kt +++ b/feature/register/src/main/java/team/ppac/register/RegisterViewModel.kt @@ -2,8 +2,14 @@ package team.ppac.register import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet import team.ppac.common.android.base.BaseViewModel +import team.ppac.domain.usecase.GetRecommendKeywordsUseCase +import team.ppac.domain.usecase.UploadMemeUseCase import team.ppac.errorhandling.FarmemeNetworkException +import team.ppac.errorhandling.FarmemeNetworkException.Companion.UNKNOWN_ERROR +import team.ppac.register.model.RegisterCategoryUiModel import team.ppac.register.mvi.RegisterIntent import team.ppac.register.mvi.RegisterSideEffect import team.ppac.register.mvi.RegisterUiState @@ -12,23 +18,106 @@ import javax.inject.Inject @HiltViewModel class RegisterViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val getRecommendKeywordsUseCase: GetRecommendKeywordsUseCase, + private val uploadMemeUseCase: UploadMemeUseCase ) : BaseViewModel(savedStateHandle) { + init { + getCategories() + } + override fun createInitialState(savedStateHandle: SavedStateHandle): RegisterUiState { return RegisterUiState.INITIAL_STATE } override fun handleClientException(throwable: Throwable) { + println(">> $throwable") if (throwable is FarmemeNetworkException) { + if (throwable.code == UNKNOWN_ERROR) { + showSnackbar(message = "밈 등록에 실패했어요") + } else { + reduce { + copy(isError = true) + } + } + } else { + showSnackbar(message = "밈 등록에 실패했어요") + } + } + + private fun getCategories() { + launch { + val registerCategories = getRecommendKeywordsUseCase().map { recommendKeyword -> + RegisterCategoryUiModel( + category = recommendKeyword.category, + keywords = recommendKeyword.keywords.toImmutableList(), + ) + }.toImmutableList() reduce { - copy(isError = true) + copy( + registerCategories = registerCategories, + isLoading = false, + isError = false, + ) } } } override suspend fun handleIntent(intent: RegisterIntent) { -// when (intent) { -// -// } + when (intent) { + is RegisterIntent.SetImageFromGallery -> { + reduce { + copy(imageUri = intent.uri) + } + } + + is RegisterIntent.InputSource -> { + reduce { + copy(source = intent.source) + } + } + + is RegisterIntent.InputTitle -> { + reduce { + copy(title = intent.title) + } + } + + is RegisterIntent.OnKeywordClick -> { + if (currentState.selectedKeywords.contains(intent.keyword)) { + reduce { + copy(selectedKeywords = (currentState.selectedKeywords - intent.keyword).toImmutableSet()) + } + } else { + if (currentState.selectedKeywords.size < 6) { + reduce { + copy(selectedKeywords = (currentState.selectedKeywords + intent.keyword).toImmutableSet()) + } + } else { + showSnackbar(message = "최대 개수를 초과했어요") + } + } + } + + RegisterIntent.ClickRegister -> { + val isUploadSuccess = uploadMemeUseCase( + keywordIds = currentState.selectedKeywords.map { it.id }, + memeTitle = currentState.title, + memeSource = currentState.source, + memeImageUri = currentState.imageUri + ) + if (isUploadSuccess) { + reduce { + copy(uploadMemeResultDialogVisible = true) + } + } else { + showSnackbar(message = "밈 등록에 실패했어요") + } + } + + RegisterIntent.OnRetry -> { + getCategories() + } + } } } \ No newline at end of file diff --git a/feature/register/src/main/java/team/ppac/register/component/RegisterCategoryContent.kt b/feature/register/src/main/java/team/ppac/register/component/RegisterCategoryContent.kt index aa7fd31a..f119e48f 100644 --- a/feature/register/src/main/java/team/ppac/register/component/RegisterCategoryContent.kt +++ b/feature/register/src/main/java/team/ppac/register/component/RegisterCategoryContent.kt @@ -19,12 +19,15 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import team.ppac.designsystem.FarmemeTheme import team.ppac.designsystem.component.chip.FarmemeMediumChip +import team.ppac.domain.model.Keyword import team.ppac.register.model.RegisterCategoryUiModel @Composable internal fun RegisterCategoryContent( modifier: Modifier = Modifier, uiModel: RegisterCategoryUiModel, + selectedKeywords: ImmutableList, + onKeywordClick: (Keyword) -> Unit, ) { Column( modifier = modifier, @@ -32,7 +35,8 @@ internal fun RegisterCategoryContent( RegisterCategoryHeader(title = uiModel.category) RegisterCategoryChips( keywords = uiModel.keywords, - onKeywordClick = {}, + selectedKeywords = selectedKeywords, + onKeywordClick = onKeywordClick, ) Spacer(modifier = Modifier.height(24.dp)) } @@ -42,8 +46,9 @@ internal fun RegisterCategoryContent( @Composable internal fun RegisterCategoryChips( modifier: Modifier = Modifier, - keywords: ImmutableList, - onKeywordClick: (String) -> Unit, + keywords: ImmutableList, + selectedKeywords: ImmutableList, + onKeywordClick: (Keyword) -> Unit, ) { FlowRow( modifier = modifier @@ -55,37 +60,31 @@ internal fun RegisterCategoryChips( repeat(keywords.size) { index -> val keyword = keywords[index] FarmemeMediumChip( - text = keyword, + text = keyword.name, + enabled = selectedKeywords.contains(keyword), onClick = { onKeywordClick(keyword) } ) } } } -@Preview -@Composable -private fun RegisterCategoryContentPreview() { - RegisterCategoryContent( - uiModel = RegisterCategoryUiModel.INITIAL_STATE, - ) -} - @Preview @Composable private fun RegisterCategoryChipsPreview() { Box(modifier = Modifier.background(FarmemeTheme.backgroundColor.white)) { RegisterCategoryChips( keywords = persistentListOf( - "행복", - "슬픈", - "분노", - "웃긴", - "피곤", - "절망", - "현타", - "당황", - "무념무상", + Keyword( + id = "", + name = "행복", + searchCount = null, + createdAt = null, + updatedAt = null, + category = null, + imageUrl = null, + ) ), + selectedKeywords = persistentListOf(), onKeywordClick = {} ) } diff --git a/feature/register/src/main/java/team/ppac/register/component/RegisterImageArea.kt b/feature/register/src/main/java/team/ppac/register/component/RegisterImageArea.kt index a1d03791..527dafc3 100644 --- a/feature/register/src/main/java/team/ppac/register/component/RegisterImageArea.kt +++ b/feature/register/src/main/java/team/ppac/register/component/RegisterImageArea.kt @@ -13,22 +13,27 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest import team.ppac.designsystem.FarmemeTheme import team.ppac.designsystem.component.button.FarmemeCircleButton import team.ppac.designsystem.component.button.FarmemeWeakButton import team.ppac.designsystem.foundation.FarmemeIcon import team.ppac.designsystem.foundation.FarmemeRadius +import team.ppac.designsystem.util.extension.noRippleClickable @Composable internal fun RegisterImageArea( modifier: Modifier = Modifier, - hasImage: Boolean, - loadImage: () -> Unit = {}, + imageUri: String, + loadImage: () -> Unit, ) { val borderColor = - if (hasImage) FarmemeTheme.borderColor.primary else FarmemeTheme.borderColor.tertiary + if (imageUri.isNotEmpty()) FarmemeTheme.borderColor.primary else FarmemeTheme.borderColor.tertiary Box( modifier = modifier @@ -41,10 +46,11 @@ internal fun RegisterImageArea( color = borderColor, shape = FarmemeRadius.Radius20.shape, ) - .background(FarmemeTheme.backgroundColor.assistive), + .background(FarmemeTheme.backgroundColor.assistive) + .noRippleClickable(onClick = loadImage), contentAlignment = Alignment.Center, ) { - if (!hasImage) { + if (imageUri.isEmpty()) { FarmemeWeakButton( text = "이미지 등록", withStar = true, @@ -56,11 +62,19 @@ internal fun RegisterImageArea( } ) } else { - // AsyncImage Box( modifier = Modifier .fillMaxSize() - .background(FarmemeTheme.backgroundColor.brandAssistive), + .background(FarmemeTheme.backgroundColor.primary), + ) + AsyncImage( + modifier = Modifier.matchParentSize(), + model = ImageRequest.Builder(LocalContext.current) + .data(imageUri) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, ) FarmemeCircleButton( modifier = Modifier @@ -79,10 +93,12 @@ internal fun RegisterImageArea( private fun RegisterImageAreaPreview() { Column { RegisterImageArea( - hasImage = true, + loadImage = {}, + imageUri = "", ) RegisterImageArea( - hasImage = false, + loadImage = {}, + imageUri = "asdf", ) } } \ No newline at end of file diff --git a/feature/register/src/main/java/team/ppac/register/component/RegisterInputArea.kt b/feature/register/src/main/java/team/ppac/register/component/RegisterInputArea.kt index 33a90ff9..4c92af22 100644 --- a/feature/register/src/main/java/team/ppac/register/component/RegisterInputArea.kt +++ b/feature/register/src/main/java/team/ppac/register/component/RegisterInputArea.kt @@ -1,52 +1,121 @@ package team.ppac.register.component -import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import team.ppac.designsystem.FarmemeTheme -import team.ppac.designsystem.foundation.FarmemeRadius +import team.ppac.designsystem.component.textfield.FarmemeTextArea +import team.ppac.designsystem.component.textfield.FarmemeTextField + +private const val MAX_TITLE_LENGTH = 18 +private const val MAX_SOURCE_LENGTH = 32 @Composable internal fun RegisterInputArea( modifier: Modifier = Modifier, + title: String, + onTitleChanged: (String) -> Unit, + source: String, + onSourceChanged: (String) -> Unit, ) { + val focusManager = LocalFocusManager.current Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { RegisterListHeader(title = "밈의 제목") Spacer(modifier = Modifier.height(12.dp)) - Spacer(// TextField - modifier = Modifier - .fillMaxWidth() - .height(46.dp) - .padding(horizontal = 20.dp) - .clip(FarmemeRadius.Radius10.shape) - .background( - FarmemeTheme.backgroundColor.assistive, + FarmemeTextField( + text = title, + onTextChanged = { + if (it.length <= MAX_TITLE_LENGTH) { + onTitleChanged(it) + } + }, + trailingContent = { + TrailingTextLength( + textCount = title.length, + maxCount = MAX_TITLE_LENGTH, ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions( + onNext = { + focusManager.moveFocus(FocusDirection.Down) + } + ) ) Spacer(modifier = Modifier.height(40.dp)) RegisterListHeader(title = "밈의 출처") Spacer(modifier = Modifier.height(12.dp)) - Spacer( // TextField - modifier = Modifier - .fillMaxWidth() - .height(82.dp) - .padding(horizontal = 20.dp) - .clip(FarmemeRadius.Radius10.shape) - .background( - FarmemeTheme.backgroundColor.assistive, + FarmemeTextArea( + text = source, + onTextChanged = { + if (it.length <= MAX_SOURCE_LENGTH) { + onSourceChanged(it) + } + }, + trailingContent = { + TrailingTextLength( + textCount = source.length, + maxCount = MAX_SOURCE_LENGTH, ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + } + ) ) Spacer(modifier = Modifier.height(35.dp)) } +} + +@Composable +fun TrailingTextLength( + textCount: Int, + maxCount: Int, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = textCount.toString(), + style = FarmemeTheme.typography.body.medium.medium, + color = when (textCount) { + 0 -> FarmemeTheme.textColor.assistive + maxCount -> FarmemeTheme.textColor.brand + else -> FarmemeTheme.textColor.secondary + } + ) + Text( + text = "/", + style = FarmemeTheme.typography.body.medium.medium, + color = FarmemeTheme.textColor.assistive + ) + Text( + text = maxCount.toString(), + style = FarmemeTheme.typography.body.medium.medium, + color = FarmemeTheme.textColor.assistive + ) + } } \ No newline at end of file diff --git a/feature/register/src/main/java/team/ppac/register/component/RegisterKeywordHeader.kt b/feature/register/src/main/java/team/ppac/register/component/RegisterKeywordHeader.kt index 3c764731..d3fa1a63 100644 --- a/feature/register/src/main/java/team/ppac/register/component/RegisterKeywordHeader.kt +++ b/feature/register/src/main/java/team/ppac/register/component/RegisterKeywordHeader.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -25,9 +24,7 @@ internal fun RegisterKeywordHeader( RegisterListHeader(title = "연관있는 키워드를 골라주세요") Spacer(modifier = Modifier.height(8.dp)) Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), + modifier = Modifier.fillMaxWidth(), text = "최대 6개까지 선택 가능해요", style = FarmemeTheme.typography.body.medium.medium.copy( color = FarmemeTheme.textColor.secondary, diff --git a/feature/register/src/main/java/team/ppac/register/component/RegisterListHeader.kt b/feature/register/src/main/java/team/ppac/register/component/RegisterListHeader.kt index 69278f8a..1875d009 100644 --- a/feature/register/src/main/java/team/ppac/register/component/RegisterListHeader.kt +++ b/feature/register/src/main/java/team/ppac/register/component/RegisterListHeader.kt @@ -19,9 +19,7 @@ internal fun RegisterListHeader( title: String, ) { Row( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), + modifier = modifier.fillMaxWidth(), ) { Text( text = title, diff --git a/feature/register/src/main/java/team/ppac/register/component/UploadMemeResultDialog.kt b/feature/register/src/main/java/team/ppac/register/component/UploadMemeResultDialog.kt new file mode 100644 index 00000000..620b59b9 --- /dev/null +++ b/feature/register/src/main/java/team/ppac/register/component/UploadMemeResultDialog.kt @@ -0,0 +1,67 @@ +package team.ppac.register.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import team.ppac.designsystem.FarmemeTheme +import team.ppac.designsystem.foundation.FarmemeRadius +import team.ppac.designsystem.util.extension.noRippleClickable + +@Composable +internal fun UploadMemeResultDialog( + modifier: Modifier = Modifier, + onConfirmClick: () -> Unit, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + ) { + Column( + modifier = modifier + .clip(FarmemeRadius.Radius20.shape) + .fillMaxWidth() + .background(FarmemeTheme.backgroundColor.white) + .padding( + horizontal = 30.dp, + vertical = 20.dp + ) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = "밈 올리기 성공!", + style = FarmemeTheme.typography.heading.medium.semibold.copy( + color = FarmemeTheme.textColor.primary + ), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = "마이페이지에서 확인할 수 있어요", + style = FarmemeTheme.typography.body.large.medium.copy( + color = FarmemeTheme.textColor.secondary + ), + ) + Spacer(modifier = Modifier.size(14.dp)) + Text( + modifier = Modifier + .fillMaxWidth() + .noRippleClickable(onClick = onConfirmClick), + text = "확인", + style = FarmemeTheme.typography.heading.small.bold.copy( + color = FarmemeTheme.textColor.brand + ), + textAlign = TextAlign.End + ) + } + } +} \ No newline at end of file diff --git a/feature/register/src/main/java/team/ppac/register/model/RegisterCategoryUiModel.kt b/feature/register/src/main/java/team/ppac/register/model/RegisterCategoryUiModel.kt index 079eef0a..efd7357c 100644 --- a/feature/register/src/main/java/team/ppac/register/model/RegisterCategoryUiModel.kt +++ b/feature/register/src/main/java/team/ppac/register/model/RegisterCategoryUiModel.kt @@ -2,10 +2,11 @@ package team.ppac.register.model import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import team.ppac.domain.model.Keyword data class RegisterCategoryUiModel( val category: String = "", - val keywords: ImmutableList = persistentListOf(), + val keywords: ImmutableList = persistentListOf(), ) { companion object { val INITIAL_STATE diff --git a/feature/register/src/main/java/team/ppac/register/mvi/RegisterIntent.kt b/feature/register/src/main/java/team/ppac/register/mvi/RegisterIntent.kt index 4bab5f60..cbb7a51d 100644 --- a/feature/register/src/main/java/team/ppac/register/mvi/RegisterIntent.kt +++ b/feature/register/src/main/java/team/ppac/register/mvi/RegisterIntent.kt @@ -1,7 +1,13 @@ package team.ppac.register.mvi import team.ppac.common.android.base.UiIntent +import team.ppac.domain.model.Keyword sealed interface RegisterIntent : UiIntent { - + data class SetImageFromGallery(val uri: String) : RegisterIntent + data class InputTitle(val title: String) : RegisterIntent + data class InputSource(val source: String) : RegisterIntent + data class OnKeywordClick(val keyword: Keyword) : RegisterIntent + data object ClickRegister : RegisterIntent + data object OnRetry : RegisterIntent } \ No newline at end of file diff --git a/feature/register/src/main/java/team/ppac/register/mvi/RegisterUiState.kt b/feature/register/src/main/java/team/ppac/register/mvi/RegisterUiState.kt index 85e0f0e4..339f5250 100644 --- a/feature/register/src/main/java/team/ppac/register/mvi/RegisterUiState.kt +++ b/feature/register/src/main/java/team/ppac/register/mvi/RegisterUiState.kt @@ -1,64 +1,34 @@ package team.ppac.register.mvi import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import team.ppac.common.android.base.UiState +import team.ppac.domain.model.Keyword import team.ppac.register.model.RegisterCategoryUiModel data class RegisterUiState( val isLoading: Boolean, val isError: Boolean, - val registerCategories: ImmutableList + val registerCategories: ImmutableList, + val selectedKeywords: ImmutableSet, + val imageUri: String, + val title: String, + val source: String, + val uploadMemeResultDialogVisible: Boolean, ) : UiState { companion object { val INITIAL_STATE = RegisterUiState( isLoading = false, isError = false, - registerCategories = persistentListOf( - RegisterCategoryUiModel( - category = "감정", - keywords = persistentListOf( - "행복", - "슬픈", - "분노", - "웃긴", - "피곤", - "절망", - "현타", - "당황", - "무념무상", - ), - ), - RegisterCategoryUiModel( - category = "상황", - keywords = persistentListOf( - "행복", - "슬픈", - "분노", - "웃긴", - "피곤", - "절망", - "현타", - "당황", - "무념무상", - ), - ), - RegisterCategoryUiModel( - category = "콘텐츠", - keywords = persistentListOf( - "행복", - "슬픈", - "분노", - "웃긴", - "피곤", - "절망", - "현타", - "당황", - "무념무상", - ), - ) - ), + registerCategories = persistentListOf(), + selectedKeywords = persistentSetOf(), + imageUri = "", + title = "", + source = "", + uploadMemeResultDialogVisible = false, ) } } \ No newline at end of file