From b8e3e68841e772f8a66236617bd034d05d5a2362 Mon Sep 17 00:00:00 2001 From: SangEun Date: Thu, 3 Oct 2024 17:21:14 +0900 Subject: [PATCH] Feature/profile image (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 프로필 이미지 업로드 UI, UseCase구현 * image upload api 구현(미완) * file provider 추가 * image upload api 수정 * 이미지 저장되지 않는 현상 수정 --- app/src/main/AndroidManifest.xml | 10 +++ app/src/main/res/xml/file_paths.xml | 21 ++++++ .../com/dkin/chevit/data/di/NetworkModule.kt | 27 +++++++ .../com/dkin/chevit/data/di/RetrofitModule.kt | 8 ++ .../data/di/usecase/AuthUseCaseModule.kt | 20 +++++ .../request/ProfileImageUploadPayload.kt | 11 +++ .../response/ProfileImageUploadResponse.kt | 12 +++ .../com/dkin/chevit/data/remote/AuthAPI.kt | 11 +++ .../com/dkin/chevit/data/remote/ImageAPI.kt | 14 ++++ .../data/repository/AuthRepositoryImpl.kt | 37 ++++++++- .../chevit/domain/model/ProfileImageData.kt | 10 +++ .../com/dkin/chevit/domain/model/UserState.kt | 4 +- .../domain/repository/AuthRepository.kt | 7 ++ .../auth/GetProfileImageDataUseCase.kt | 18 +++++ .../usecase/auth/UploadProfileImageUseCase.kt | 29 +++++++ .../home/contents/HomeTabContents.kt | 2 +- .../home/contents/user/UserContents.kt | 2 +- .../user/profile/EditProfileImageContents.kt | 36 ++++++--- .../contents/user/profile/ProfileSetting.kt | 75 ++++++++++++++++++- .../user/profile/ProfileSettingContract.kt | 8 +- .../user/profile/ProfileSettingScreen.kt | 51 +++++++------ .../user/profile/ProfileSettingViewModel.kt | 65 ++++++++++++++-- 22 files changed, 427 insertions(+), 51 deletions(-) create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 data/src/main/java/com/dkin/chevit/data/model/request/ProfileImageUploadPayload.kt create mode 100644 data/src/main/java/com/dkin/chevit/data/model/response/ProfileImageUploadResponse.kt create mode 100644 data/src/main/java/com/dkin/chevit/data/remote/ImageAPI.kt create mode 100644 domain/src/main/java/com/dkin/chevit/domain/model/ProfileImageData.kt create mode 100644 domain/src/main/java/com/dkin/chevit/domain/usecase/auth/GetProfileImageDataUseCase.kt create mode 100644 domain/src/main/java/com/dkin/chevit/domain/usecase/auth/UploadProfileImageUseCase.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8b32b80..c04d8d5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,6 +26,16 @@ android:value="androidx.startup" /> + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/data/src/debug/java/com/dkin/chevit/data/di/NetworkModule.kt b/data/src/debug/java/com/dkin/chevit/data/di/NetworkModule.kt index 3acc63d..45d3b67 100644 --- a/data/src/debug/java/com/dkin/chevit/data/di/NetworkModule.kt +++ b/data/src/debug/java/com/dkin/chevit/data/di/NetworkModule.kt @@ -23,6 +23,7 @@ import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Converter import retrofit2.Retrofit +import javax.inject.Named @Module @InstallIn(SingletonComponent::class) @@ -86,6 +87,20 @@ internal object NetworkModule { .addInterceptor(tokenInterceptor) .build() + @Provides + @Singleton + @Named("Pure") + fun providePureOkHttpClient( + httpLoggingInterceptor: HttpLoggingInterceptor, + chuckerInterceptor: ChuckerInterceptor, + ) = OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .writeTimeout(20, TimeUnit.SECONDS) + .addInterceptor(httpLoggingInterceptor) + .addInterceptor(chuckerInterceptor) + .build() + @Provides @Singleton fun provideRetrofit( @@ -96,4 +111,16 @@ internal object NetworkModule { .addConverterFactory(jsonConverter) .baseUrl(BuildConfig.API_URL) .build() + + @Provides + @Singleton + @Named("Pure") + fun providePureRetrofit( + @Named("Pure") okHttpClient: OkHttpClient, + @JsonConverter jsonConverter: Converter.Factory, + ): Retrofit = Retrofit.Builder() + .client(okHttpClient) + .addConverterFactory(jsonConverter) + .baseUrl(BuildConfig.API_URL) + .build() } diff --git a/data/src/main/java/com/dkin/chevit/data/di/RetrofitModule.kt b/data/src/main/java/com/dkin/chevit/data/di/RetrofitModule.kt index 12c14c0..163b6d8 100644 --- a/data/src/main/java/com/dkin/chevit/data/di/RetrofitModule.kt +++ b/data/src/main/java/com/dkin/chevit/data/di/RetrofitModule.kt @@ -1,6 +1,7 @@ package com.dkin.chevit.data.di import com.dkin.chevit.data.remote.AuthAPI +import com.dkin.chevit.data.remote.ImageAPI import com.dkin.chevit.data.remote.NotificationAPI import com.dkin.chevit.data.remote.PlanAPI import com.dkin.chevit.data.remote.ServiceAPI @@ -10,6 +11,7 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton import retrofit2.Retrofit +import javax.inject.Named @Module @InstallIn(SingletonComponent::class) @@ -37,4 +39,10 @@ internal object RetrofitModule { fun providePlanAPI( retrofit: Retrofit ): PlanAPI = retrofit.create(PlanAPI::class.java) + + @Provides + @Singleton + fun provideImageAPI( + @Named("Pure") retrofit: Retrofit + ): ImageAPI = retrofit.create(ImageAPI::class.java) } diff --git a/data/src/main/java/com/dkin/chevit/data/di/usecase/AuthUseCaseModule.kt b/data/src/main/java/com/dkin/chevit/data/di/usecase/AuthUseCaseModule.kt index a759805..12b4af5 100644 --- a/data/src/main/java/com/dkin/chevit/data/di/usecase/AuthUseCaseModule.kt +++ b/data/src/main/java/com/dkin/chevit/data/di/usecase/AuthUseCaseModule.kt @@ -2,11 +2,13 @@ package com.dkin.chevit.data.di.usecase import com.dkin.chevit.domain.base.CoroutineDispatcherProvider import com.dkin.chevit.domain.repository.AuthRepository +import com.dkin.chevit.domain.usecase.auth.GetProfileImageDataUseCase import com.dkin.chevit.domain.usecase.auth.GetUserStateUseCase import com.dkin.chevit.domain.usecase.auth.GetUserUseCase import com.dkin.chevit.domain.usecase.auth.SignOutUseCase import com.dkin.chevit.domain.usecase.auth.SignUpUserUseCase import com.dkin.chevit.domain.usecase.auth.UpdateUserUseCase +import com.dkin.chevit.domain.usecase.auth.UploadProfileImageUseCase import com.dkin.chevit.domain.usecase.auth.WithDrawUserUseCase import dagger.Module import dagger.Provides @@ -69,4 +71,22 @@ internal object AuthUseCaseModule { coroutineDispatcherProvider, authRepository ) + + @Provides + fun provideGetProfileImageDataUseCase( + coroutineDispatcherProvider: CoroutineDispatcherProvider, + authRepository: AuthRepository, + ) = GetProfileImageDataUseCase( + coroutineDispatcherProvider, + authRepository + ) + + @Provides + fun provideUploadProfileImageUseCase( + coroutineDispatcherProvider: CoroutineDispatcherProvider, + authRepository: AuthRepository, + ) = UploadProfileImageUseCase( + coroutineDispatcherProvider, + authRepository + ) } diff --git a/data/src/main/java/com/dkin/chevit/data/model/request/ProfileImageUploadPayload.kt b/data/src/main/java/com/dkin/chevit/data/model/request/ProfileImageUploadPayload.kt new file mode 100644 index 0000000..80b2779 --- /dev/null +++ b/data/src/main/java/com/dkin/chevit/data/model/request/ProfileImageUploadPayload.kt @@ -0,0 +1,11 @@ +package com.dkin.chevit.data.model.request + +import com.dkin.chevit.data.DataModel +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class ProfileImageUploadPayload( + @SerialName("fileSize") val fileSize: Int, + @SerialName("mimeType") val mimeType: String, +) : DataModel diff --git a/data/src/main/java/com/dkin/chevit/data/model/response/ProfileImageUploadResponse.kt b/data/src/main/java/com/dkin/chevit/data/model/response/ProfileImageUploadResponse.kt new file mode 100644 index 0000000..eb2f0d9 --- /dev/null +++ b/data/src/main/java/com/dkin/chevit/data/model/response/ProfileImageUploadResponse.kt @@ -0,0 +1,12 @@ +package com.dkin.chevit.data.model.response + +import com.dkin.chevit.data.DataModel +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class ProfileImageUploadResponse( + @SerialName("uploadMethod") val uploadMethod: String = "", + @SerialName("uploadURL") val uploadURL: String = "", + @SerialName("imageURL") val imageURL: String = "", +) : DataModel \ No newline at end of file diff --git a/data/src/main/java/com/dkin/chevit/data/remote/AuthAPI.kt b/data/src/main/java/com/dkin/chevit/data/remote/AuthAPI.kt index 8e7a611..9f14e49 100644 --- a/data/src/main/java/com/dkin/chevit/data/remote/AuthAPI.kt +++ b/data/src/main/java/com/dkin/chevit/data/remote/AuthAPI.kt @@ -1,15 +1,23 @@ package com.dkin.chevit.data.remote +import com.dkin.chevit.data.model.request.ProfileImageUploadPayload import com.dkin.chevit.data.model.request.SignUpPayload import com.dkin.chevit.data.model.request.UpdateUserPayload import com.dkin.chevit.data.model.request.ValidationNicknamePayload +import com.dkin.chevit.data.model.response.ProfileImageUploadResponse import com.dkin.chevit.data.model.response.UserResponse +import com.dkin.chevit.domain.base.None +import okhttp3.MultipartBody +import okhttp3.RequestBody import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Url /** * 유저 정보 및 인증 관련 API 모음 @@ -29,4 +37,7 @@ internal interface AuthAPI { @DELETE("deleteUser") suspend fun deleteUser(): Response + + @POST("getProfileUploadURL") + suspend fun getProfileUploadURL(@Body body: ProfileImageUploadPayload): ProfileImageUploadResponse } diff --git a/data/src/main/java/com/dkin/chevit/data/remote/ImageAPI.kt b/data/src/main/java/com/dkin/chevit/data/remote/ImageAPI.kt new file mode 100644 index 0000000..7468a14 --- /dev/null +++ b/data/src/main/java/com/dkin/chevit/data/remote/ImageAPI.kt @@ -0,0 +1,14 @@ +package com.dkin.chevit.data.remote + +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.PUT +import retrofit2.http.Url + +internal interface ImageAPI { + @PUT + suspend fun uploadProfileImage( + @Url url: String, + @Body file: RequestBody + ) +} \ No newline at end of file diff --git a/data/src/main/java/com/dkin/chevit/data/repository/AuthRepositoryImpl.kt b/data/src/main/java/com/dkin/chevit/data/repository/AuthRepositoryImpl.kt index 251a7b5..518b022 100644 --- a/data/src/main/java/com/dkin/chevit/data/repository/AuthRepositoryImpl.kt +++ b/data/src/main/java/com/dkin/chevit/data/repository/AuthRepositoryImpl.kt @@ -1,17 +1,27 @@ package com.dkin.chevit.data.repository +import com.dkin.chevit.data.model.request.ProfileImageUploadPayload import com.dkin.chevit.data.model.request.SignUpPayload import com.dkin.chevit.data.model.request.UpdateUserPayload import com.dkin.chevit.data.model.response.toUser import com.dkin.chevit.data.remote.AuthAPI +import com.dkin.chevit.data.remote.ImageAPI +import com.dkin.chevit.domain.base.None +import com.dkin.chevit.domain.model.ProfileImageData import com.dkin.chevit.domain.model.UserState import com.dkin.chevit.domain.repository.AuthRepository import com.google.firebase.auth.FirebaseAuth +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File import javax.inject.Inject internal class AuthRepositoryImpl @Inject constructor( private val authAPI: AuthAPI, - private val auth: FirebaseAuth + private val imageAPI: ImageAPI, + private val auth: FirebaseAuth, ) : AuthRepository { override suspend fun getUserState(): UserState { return runCatching { @@ -43,4 +53,29 @@ internal class AuthRepositoryImpl @Inject constructor( auth.signOut() return getUserState() } + + override suspend fun getProfileUploadURL(fileSize: Int): ProfileImageData { + val result = authAPI.getProfileUploadURL( + ProfileImageUploadPayload( + fileSize = fileSize, + mimeType = "image/jpeg" + ) + ) + return ProfileImageData( + uploadMethod = result.uploadMethod, + uploadURL = result.uploadURL, + uploadHeaders = "{\"Content-Type\": [\"image/jpeg\"]}", + imageURL = result.imageURL, + ) + } + + override suspend fun uploadProfileImage( + uploadURL: String, + uploadMethod: String, + uploadHeaders: String, + file: File + ) { + val requestFile: RequestBody = file.asRequestBody("image/jpeg".toMediaTypeOrNull()) + return imageAPI.uploadProfileImage(uploadURL, requestFile) + } } diff --git a/domain/src/main/java/com/dkin/chevit/domain/model/ProfileImageData.kt b/domain/src/main/java/com/dkin/chevit/domain/model/ProfileImageData.kt new file mode 100644 index 0000000..f12b1e1 --- /dev/null +++ b/domain/src/main/java/com/dkin/chevit/domain/model/ProfileImageData.kt @@ -0,0 +1,10 @@ +package com.dkin.chevit.domain.model + +import com.dkin.chevit.domain.base.DomainModel + +data class ProfileImageData( + val uploadMethod: String, + val uploadURL: String, + val uploadHeaders: String, + val imageURL: String +) : DomainModel \ No newline at end of file diff --git a/domain/src/main/java/com/dkin/chevit/domain/model/UserState.kt b/domain/src/main/java/com/dkin/chevit/domain/model/UserState.kt index f0affd4..498cb04 100644 --- a/domain/src/main/java/com/dkin/chevit/domain/model/UserState.kt +++ b/domain/src/main/java/com/dkin/chevit/domain/model/UserState.kt @@ -3,9 +3,9 @@ package com.dkin.chevit.domain.model import com.dkin.chevit.domain.base.DomainModel sealed interface UserState : DomainModel { - object Guest : UserState + data object Guest : UserState - object NotRegister : UserState + data object NotRegister : UserState data class User( val id: String, diff --git a/domain/src/main/java/com/dkin/chevit/domain/repository/AuthRepository.kt b/domain/src/main/java/com/dkin/chevit/domain/repository/AuthRepository.kt index 28c225e..0617abd 100644 --- a/domain/src/main/java/com/dkin/chevit/domain/repository/AuthRepository.kt +++ b/domain/src/main/java/com/dkin/chevit/domain/repository/AuthRepository.kt @@ -1,6 +1,9 @@ package com.dkin.chevit.domain.repository +import com.dkin.chevit.domain.base.None +import com.dkin.chevit.domain.model.ProfileImageData import com.dkin.chevit.domain.model.UserState +import java.io.File interface AuthRepository { suspend fun getUserState(): UserState @@ -12,4 +15,8 @@ interface AuthRepository { suspend fun signOutUser(): UserState suspend fun withDrawUser(): UserState + + suspend fun getProfileUploadURL(fileSize: Int): ProfileImageData + + suspend fun uploadProfileImage(uploadURL: String, uploadMethod: String, uploadHeaders: String, file: File) } diff --git a/domain/src/main/java/com/dkin/chevit/domain/usecase/auth/GetProfileImageDataUseCase.kt b/domain/src/main/java/com/dkin/chevit/domain/usecase/auth/GetProfileImageDataUseCase.kt new file mode 100644 index 0000000..a108064 --- /dev/null +++ b/domain/src/main/java/com/dkin/chevit/domain/usecase/auth/GetProfileImageDataUseCase.kt @@ -0,0 +1,18 @@ +package com.dkin.chevit.domain.usecase.auth + +import com.dkin.chevit.domain.base.CoroutineDispatcherProvider +import com.dkin.chevit.domain.base.IOUseCase +import com.dkin.chevit.domain.model.ProfileImageData +import com.dkin.chevit.domain.repository.AuthRepository + +class GetProfileImageDataUseCase( + coroutineDispatcherProvider: CoroutineDispatcherProvider, + private val authRepository: AuthRepository, +) : IOUseCase(coroutineDispatcherProvider = coroutineDispatcherProvider) { + override suspend fun execute(params: Param): ProfileImageData { + return authRepository.getProfileUploadURL(params.fileSize) + } + + @JvmInline + value class Param(val fileSize: Int) +} \ No newline at end of file diff --git a/domain/src/main/java/com/dkin/chevit/domain/usecase/auth/UploadProfileImageUseCase.kt b/domain/src/main/java/com/dkin/chevit/domain/usecase/auth/UploadProfileImageUseCase.kt new file mode 100644 index 0000000..baa4bad --- /dev/null +++ b/domain/src/main/java/com/dkin/chevit/domain/usecase/auth/UploadProfileImageUseCase.kt @@ -0,0 +1,29 @@ +package com.dkin.chevit.domain.usecase.auth + +import com.dkin.chevit.domain.base.CoroutineDispatcherProvider +import com.dkin.chevit.domain.base.IOUseCase +import com.dkin.chevit.domain.base.None +import com.dkin.chevit.domain.repository.AuthRepository +import java.io.File + +class UploadProfileImageUseCase( + coroutineDispatcherProvider: CoroutineDispatcherProvider, + private val authRepository: AuthRepository, +) : IOUseCase(coroutineDispatcherProvider = coroutineDispatcherProvider) { + override suspend fun execute(params: Param): None { + authRepository.uploadProfileImage( + uploadURL = params.uploadURL, + uploadMethod = params.uploadMethod, + uploadHeaders = params.uploadHeaders, + file = params.file + ) + return None + } + + data class Param( + val uploadURL: String, + val uploadMethod: String, + val uploadHeaders: String, + val file: File + ) +} \ No newline at end of file diff --git a/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/HomeTabContents.kt b/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/HomeTabContents.kt index 0c1c0e8..5728f22 100644 --- a/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/HomeTabContents.kt +++ b/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/HomeTabContents.kt @@ -238,7 +238,7 @@ private fun HomeStable( .crossfade(true) .build(), contentDescription = "", - contentScale = ContentScale.Fit, + contentScale = ContentScale.FillBounds, error = painterResource(id = R.drawable.ic_profile_empty) ) } diff --git a/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/UserContents.kt b/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/UserContents.kt index 1aac181..e34c278 100644 --- a/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/UserContents.kt +++ b/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/UserContents.kt @@ -103,7 +103,7 @@ fun UserContents( .crossfade(true) .build(), contentDescription = "", - contentScale = ContentScale.Fit, + contentScale = ContentScale.FillBounds, error = painterResource(id = drawable.ic_profile_empty) ) } diff --git a/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/EditProfileImageContents.kt b/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/EditProfileImageContents.kt index e3943e2..cfe30a8 100644 --- a/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/EditProfileImageContents.kt +++ b/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/EditProfileImageContents.kt @@ -1,5 +1,8 @@ package com.dkin.chevit.presentation.home.contents.user.profile +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -8,9 +11,11 @@ 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.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.dkin.chevit.presentation.resource.ChevitBottomsheet @@ -18,19 +23,29 @@ import com.dkin.chevit.presentation.resource.ChevitTheme @Composable fun EditProfileImageContents( - viewModel: ProfileSettingViewModel, onClickBack: () -> Unit, + changeImage: (uri: Uri?) -> Unit ) { + val launcher = rememberLauncherForActivityResult( + contract = + ActivityResultContracts.GetContent() + ) { uri: Uri? -> + changeImage(uri) + onClickBack() + } + ChevitBottomsheet( modifier = Modifier.fillMaxSize(), onClickBackground = onClickBack ) { Column(modifier = Modifier.fillMaxWidth()) { Column( - modifier = Modifier.fillMaxWidth().clickable { - viewModel.openAlbum() - onClickBack() - } + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + launcher.launch("image/*") + } ) { Spacer(modifier = Modifier.height(16.dp)) Text( @@ -48,10 +63,13 @@ fun EditProfileImageContents( .background(color = ChevitTheme.colors.grey0) ) Column( - modifier = Modifier.fillMaxWidth().clickable { - viewModel.resetProfileImage() - onClickBack() - } + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + changeImage(null) + onClickBack() + } ) { Spacer(modifier = Modifier.height(16.dp)) Text( diff --git a/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSetting.kt b/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSetting.kt index 9fd59b0..f14ad28 100644 --- a/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSetting.kt +++ b/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSetting.kt @@ -1,13 +1,22 @@ package com.dkin.chevit.presentation.home.contents.user.profile import android.os.Bundle +import android.os.FileUtils.copy import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.window.DialogProperties +import androidx.core.content.FileProvider +import androidx.core.net.toUri import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.dialog @@ -17,6 +26,9 @@ import com.dkin.chevit.core.mvi.MVIComposeFragment import com.dkin.chevit.presentation.deeplink.navPopBack import com.dkin.chevit.presentation.home.contents.user.profile.ProfileSettingEffect.NavPopBack import dagger.hilt.android.AndroidEntryPoint +import java.io.File +import java.io.FileOutputStream + @AndroidEntryPoint class ProfileSetting : @@ -40,12 +52,32 @@ class ProfileSetting : setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { val navController = rememberNavController() + val settingState by viewModel.state.collectAsStateWithLifecycle() + var imageUrl by remember { mutableStateOf("") } + var imageChanged by remember { mutableStateOf(false) } + + LaunchedEffect(settingState) { + val state = settingState + if (state is ProfileSettingState.Stable) { + imageUrl = state.imageUrl + } + } + NavHost(navController = navController, startDestination = "settingMain") { composable("settingMain") { ProfileSettingScreen( viewModel = viewModel, + settingState = settingState, + imageUrl = imageUrl, onClickBack = { findNavController().popBackStack() }, - onClickImage = { navController.navigate("editImage") } + onClickImage = { navController.navigate("editImage") }, + onClickSave = { name -> + saveProfileSetting( + name, + imageUrl, + imageChanged + ) + } ) } dialog( @@ -53,12 +85,49 @@ class ProfileSetting : dialogProperties = DialogProperties(usePlatformDefaultWidth = false) ) { EditProfileImageContents( - viewModel = viewModel, - onClickBack = { navController.popBackStack() } + onClickBack = { navController.popBackStack() }, + changeImage = { uri -> + imageUrl = uri.toString() + imageChanged = true + } ) } } } } } + + private fun saveProfileSetting(name: String, imageUrl: String, imageChanged: Boolean) { + val directory = File(requireContext().cacheDir, "images") + directory.mkdirs() // 임시 파일이 위치할 폴더를 생성한다. + + if (imageChanged) { + val file = File.createTempFile( + "selected_image", + ".jpg", + directory, + ) // 해당 폴더에 임시 파일을 만든다. + + val authority = requireContext().packageName + ".fileprovider" // + val outputFileUri = FileProvider.getUriForFile(requireContext(), authority, file) + + FileOutputStream(file).use { outputStream -> + requireContext().contentResolver.openInputStream(imageUrl.toUri()) + .use { inputStream -> + inputStream?.copyTo(outputStream) + outputStream.flush() + } + } + viewModel.dispatch(ProfileSettingIntent.SaveImageProfile( + name, + outputFileUri.toString(), + file + )) + } else { + viewModel.dispatch(ProfileSettingIntent.SaveProfile( + name, + imageUrl, + )) + } + } } diff --git a/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSettingContract.kt b/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSettingContract.kt index 46cd3f6..f42341b 100644 --- a/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSettingContract.kt +++ b/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSettingContract.kt @@ -4,15 +4,17 @@ import androidx.compose.runtime.Stable import com.dkin.chevit.core.mvi.ViewEffect import com.dkin.chevit.core.mvi.ViewIntent import com.dkin.chevit.core.mvi.ViewState +import java.io.File sealed interface ProfileSettingIntent : ViewIntent { - object Initialize : ProfileSettingIntent + data object Initialize : ProfileSettingIntent + data class SaveImageProfile(val name: String, val imageUrl: String, val file: File) : ProfileSettingIntent data class SaveProfile(val name: String, val imageUrl: String) : ProfileSettingIntent } @Stable sealed interface ProfileSettingState : ViewState { - object Loading : ProfileSettingState + data object Loading : ProfileSettingState data class Stable( val name: String, val imageUrl: String @@ -25,5 +27,5 @@ sealed interface ProfileSettingState : ViewState { sealed interface ProfileSettingEffect : ViewEffect { - object NavPopBack : ProfileSettingEffect + data object NavPopBack : ProfileSettingEffect } diff --git a/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSettingScreen.kt b/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSettingScreen.kt index 238ab1c..27efce6 100644 --- a/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSettingScreen.kt +++ b/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSettingScreen.kt @@ -1,5 +1,6 @@ package com.dkin.chevit.presentation.home.contents.user.profile +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -13,7 +14,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -34,16 +34,19 @@ import com.dkin.chevit.presentation.resource.ChevitTheme import com.dkin.chevit.presentation.resource.R import com.dkin.chevit.presentation.resource.icon.ChevitIcon import com.dkin.chevit.presentation.resource.icon.IconArrowLeftLine +import com.dkin.chevit.presentation.resource.icon.IconCameraFill import com.dkin.chevit.presentation.resource.icon.IconCloseCircleFill import com.dkin.chevit.presentation.resource.util.clickableNoRipple @Composable fun ProfileSettingScreen( viewModel: ProfileSettingViewModel, + settingState: ProfileSettingState, + imageUrl: String, onClickBack: () -> Unit, onClickImage: () -> Unit, + onClickSave: (name: String) -> Unit, ) { - val settingState by viewModel.state.collectAsState() LaunchedEffect(Unit) { viewModel.dispatch(ProfileSettingIntent.Initialize) @@ -73,7 +76,7 @@ fun ProfileSettingScreen( } Spacer(modifier = Modifier.height(92.dp)) - when (val state = settingState) { + when (settingState) { ProfileSettingState.Loading -> { Column( modifier = Modifier @@ -84,12 +87,12 @@ fun ProfileSettingScreen( } is ProfileSettingState.Stable -> { - var name by remember { mutableStateOf(state.name) } - var imageUrl by remember { mutableStateOf(state.imageUrl) } + var name by remember { mutableStateOf(settingState.name) } var isValidInput by remember { mutableStateOf(false) } LaunchedEffect(name) { - isValidInput = name.isNotBlank() && name.length < 8 && imageUrl.isNotBlank() + isValidInput = + name.isNotBlank() && name.length < 8 && settingState.imageUrl.isNotBlank() } Column( @@ -98,7 +101,7 @@ fun ProfileSettingScreen( .weight(1f), horizontalAlignment = Alignment.CenterHorizontally ) { - Box(modifier = Modifier.clickableNoRipple { /*onClickImage()*/ }) { + Box(modifier = Modifier.clickableNoRipple { onClickImage() }) { Box( modifier = Modifier .size(128.dp) @@ -112,25 +115,25 @@ fun ProfileSettingScreen( .crossfade(true) .build(), contentDescription = "", - contentScale = ContentScale.Fit, + contentScale = ContentScale.FillBounds, error = painterResource(id = R.drawable.ic_profile_empty) ) } -// Box( -// modifier = Modifier -// .size(32.dp) -// .clip(CircleShape) -// .background(color = ChevitTheme.colors.black) -// .align(Alignment.BottomEnd) -// .padding(4.dp), -// contentAlignment = Alignment.Center -// ) { -// Icon( -// imageVector = ChevitIcon.IconCameraFill, -// contentDescription = "", -// tint = ChevitTheme.colors.white -// ) -// } + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(color = ChevitTheme.colors.black) + .align(Alignment.BottomEnd) + .padding(4.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = ChevitIcon.IconCameraFill, + contentDescription = "", + tint = ChevitTheme.colors.white + ) + } } Spacer(modifier = Modifier.height(32.dp)) @@ -179,7 +182,7 @@ fun ProfileSettingScreen( modifier = Modifier.fillMaxWidth(), enabled = isValidInput, onClick = { - viewModel.dispatch(ProfileSettingIntent.SaveProfile(name, imageUrl)) + onClickSave(name) } ) { Text(text = "저장하기") diff --git a/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSettingViewModel.kt b/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSettingViewModel.kt index 4416349..f9a5703 100644 --- a/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSettingViewModel.kt +++ b/presentation/home/src/main/java/com/dkin/chevit/presentation/home/contents/user/profile/ProfileSettingViewModel.kt @@ -1,19 +1,27 @@ package com.dkin.chevit.presentation.home.contents.user.profile +import androidx.lifecycle.viewModelScope import com.dkin.chevit.core.mvi.MVIViewModel import com.dkin.chevit.domain.base.get import com.dkin.chevit.domain.base.onComplete +import com.dkin.chevit.domain.usecase.auth.GetProfileImageDataUseCase import com.dkin.chevit.domain.usecase.auth.GetUserUseCase import com.dkin.chevit.domain.usecase.auth.UpdateUserUseCase +import com.dkin.chevit.domain.usecase.auth.UploadProfileImageUseCase import com.dkin.chevit.presentation.home.contents.user.profile.ProfileSettingIntent.Initialize import com.dkin.chevit.presentation.home.contents.user.profile.ProfileSettingIntent.SaveProfile +import com.dkin.chevit.presentation.home.contents.user.profile.ProfileSettingIntent.SaveImageProfile import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import java.io.File import javax.inject.Inject @HiltViewModel class ProfileSettingViewModel @Inject constructor( private val getUserUseCase: GetUserUseCase, private val updateUserUseCase: UpdateUserUseCase, + private val getProfileImageDataUseCase: GetProfileImageDataUseCase, + private val uploadProfileImageUseCase: UploadProfileImageUseCase ) : MVIViewModel() { override fun createInitialState(): ProfileSettingState = ProfileSettingState.Loading @@ -21,7 +29,8 @@ class ProfileSettingViewModel @Inject constructor( override suspend fun processIntent(intent: ProfileSettingIntent) { when (intent) { Initialize -> getProfile() - is SaveProfile -> saveProfile(intent.name, intent.imageUrl) + is SaveImageProfile -> saveProfileWithImage(intent.name, intent.imageUrl, intent.file) + is SaveProfile -> updateProfile(intent.name, intent.imageUrl) } } @@ -35,7 +44,29 @@ class ProfileSettingViewModel @Inject constructor( } } - private suspend fun saveProfile(name: String, imageUrl: String) { + private suspend fun saveProfileWithImage(name: String, imageUrl: String, file: File,) { + kotlin.runCatching { + getProfileImageDataUseCase( + params = GetProfileImageDataUseCase.Param( + fileSize = file.length().toInt() + ) + ).onComplete { + saveImageWithUpdateProfile( + name = name, + imageUrl = imageUrl, + newImageUrl = imageURL, + uploadURL = uploadURL, + uploadMethod = uploadMethod, + uploadHeaders = uploadHeaders, + file = file + ) + } + }.onFailure { + updateProfile(name, imageUrl) + } + } + + private suspend fun updateProfile(name: String, imageUrl: String) { val param = UpdateUserUseCase.Params( name = name.takeIf { it.isNotBlank() }, profileImage = imageUrl.takeIf { it.isNotBlank() } @@ -45,11 +76,31 @@ class ProfileSettingViewModel @Inject constructor( } } - fun openAlbum() { - //TODO - } + private fun saveImageWithUpdateProfile( + name: String, + imageUrl: String, + file: File, + newImageUrl: String, + uploadURL: String, + uploadMethod: String, + uploadHeaders: String + ) { + viewModelScope.launch { + kotlin.runCatching { + uploadProfileImageUseCase( + params = UploadProfileImageUseCase.Param( + uploadURL = uploadURL, + uploadMethod = uploadMethod, + uploadHeaders = uploadHeaders, + file = file + ) + ) + }.onSuccess { + updateProfile(name, newImageUrl) + }.onFailure { + updateProfile(name, imageUrl) + } + } - fun resetProfileImage() { - //TODO } }