diff --git a/core/navigation/src/main/java/co/kr/tnt/navigation/RouteModel.kt b/core/navigation/src/main/java/co/kr/tnt/navigation/RouteModel.kt index 2ca83df..4869e61 100644 --- a/core/navigation/src/main/java/co/kr/tnt/navigation/RouteModel.kt +++ b/core/navigation/src/main/java/co/kr/tnt/navigation/RouteModel.kt @@ -13,10 +13,18 @@ sealed interface Route { data object Login : Route @Serializable - data object TrainerSignUp : Route + data class TrainerSignUp( + val authId: String, + val authType: String, + val email: String, + ) : Route @Serializable - data object TraineeSignUp : Route + data class TraineeSignUp( + val authId: String, + val authType: String, + val email: String, + ) : Route @Serializable data class TrainerConnect(val isFromMyPage: Boolean) : Route diff --git a/core/ui/src/main/java/co/kr/tnt/ui/util/FileUtils.kt b/core/ui/src/main/java/co/kr/tnt/ui/util/FileUtils.kt new file mode 100644 index 0000000..3fdab8b --- /dev/null +++ b/core/ui/src/main/java/co/kr/tnt/ui/util/FileUtils.kt @@ -0,0 +1,29 @@ +package co.kr.tnt.ui.util + +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.provider.MediaStore +import android.util.Log +import java.io.File + +fun Uri.toFile(context: Context): File? { + return getRealPathFromUri(this, context)?.let { filePath -> + File(filePath) + } ?: run { + Log.e("toFile", "Error creating file for URI: $this") + null + } +} + +fun getRealPathFromUri(uri: Uri, context: Context): String? { + val projection = arrayOf(MediaStore.Images.Media.DATA) + val cursor: Cursor? = context.contentResolver.query(uri, projection, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA) + return it.getString(columnIndex) + } + } + return null +} diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 2211686..318e8d3 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -16,6 +16,8 @@ 닫기 확인 + 서버 요청에 실패했어요 + 트레이니 트레이너 diff --git a/data/network/src/main/java/co/kr/data/network/model/SignUpRequest.kt b/data/network/src/main/java/co/kr/data/network/model/SignUpRequest.kt new file mode 100644 index 0000000..030a9f1 --- /dev/null +++ b/data/network/src/main/java/co/kr/data/network/model/SignUpRequest.kt @@ -0,0 +1,65 @@ +package co.kr.data.network.model + +import co.kr.tnt.domain.model.UserType +import kotlinx.serialization.Serializable + +@Serializable +data class SignUpRequest( + val socialType: String, + val socialId: String, + val fcmToken: String, + val serviceAgreement: Boolean, + val collectionAgreement: Boolean, + val advertisementAgreement: Boolean, + val socialEmail: String, + val memberType: String, + val name: String, + val birthday: String? = null, + val height: Double? = null, + val weight: Double? = null, + val goalContents: List? = null, + val cautionNote: String? = "", +) + +fun UserType.toSignUpRequest( + socialId: String, + socialType: String, + email: String, + fcmToken: String, +): SignUpRequest { + return when (this) { + is UserType.Trainer -> SignUpRequest( + memberType = "trainer", + name = name, + birthday = null, + height = null, + weight = null, + goalContents = null, + cautionNote = null, + socialType = socialType, + socialId = socialId, + socialEmail = email, + fcmToken = fcmToken, + serviceAgreement = true, + collectionAgreement = true, + advertisementAgreement = true, + ) + + is UserType.Trainee -> SignUpRequest( + memberType = "trainee", + name = name, + birthday = birthday?.toString(), + height = height.toDouble(), + weight = weight, + goalContents = ptPurpose, + cautionNote = caution?.ifBlank { null }, + socialType = socialType, + socialId = socialId, + socialEmail = email, + fcmToken = fcmToken, + serviceAgreement = true, + collectionAgreement = true, + advertisementAgreement = true, + ) + } +} diff --git a/data/network/src/main/java/co/kr/data/network/model/SignUpResponse.kt b/data/network/src/main/java/co/kr/data/network/model/SignUpResponse.kt new file mode 100644 index 0000000..77de8c8 --- /dev/null +++ b/data/network/src/main/java/co/kr/data/network/model/SignUpResponse.kt @@ -0,0 +1,21 @@ +package co.kr.data.network.model + +import co.kr.tnt.domain.model.SignUpResult +import kotlinx.serialization.Serializable + +@Serializable +data class SignUpResponse( + val memberType: String, + val sessionId: String, + val name: String, + val profileImageUrl: String, +) + +fun SignUpResponse.toDomain(): SignUpResult { + return SignUpResult( + memberType = memberType, + sessionId = sessionId, + name = name, + profileImageUrl = profileImageUrl, + ) +} diff --git a/data/network/src/main/java/co/kr/data/network/service/ApiService.kt b/data/network/src/main/java/co/kr/data/network/service/ApiService.kt index a3be07a..bb5412c 100644 --- a/data/network/src/main/java/co/kr/data/network/service/ApiService.kt +++ b/data/network/src/main/java/co/kr/data/network/service/ApiService.kt @@ -2,12 +2,24 @@ package co.kr.data.network.service import co.kr.data.network.model.LoginRequest import co.kr.data.network.model.LoginResponse +import co.kr.data.network.model.SignUpResponse +import okhttp3.MultipartBody +import okhttp3.RequestBody import retrofit2.http.Body +import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.Part interface ApiService { @POST("/login") suspend fun postLogin( @Body request: LoginRequest, ): LoginResponse + + @Multipart + @POST("/members/sign-up") + suspend fun postSignUp( + @Part profileImage: MultipartBody.Part?, + @Part("request") request: RequestBody, + ): SignUpResponse } diff --git a/data/network/src/main/java/co/kr/data/network/source/SignUpRemoteDataSource.kt b/data/network/src/main/java/co/kr/data/network/source/SignUpRemoteDataSource.kt new file mode 100644 index 0000000..cb59d42 --- /dev/null +++ b/data/network/src/main/java/co/kr/data/network/source/SignUpRemoteDataSource.kt @@ -0,0 +1,21 @@ +package co.kr.data.network.source + +import co.kr.data.network.model.SignUpResponse +import co.kr.data.network.service.ApiService +import co.kr.data.network.util.networkHandler +import okhttp3.MultipartBody +import okhttp3.RequestBody +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SignUpRemoteDataSource @Inject constructor( + private val apiService: ApiService, +) { + suspend fun postSignUp( + profileImage: MultipartBody.Part?, + request: RequestBody, + ): SignUpResponse = networkHandler { + apiService.postSignUp(profileImage, request) + } +} diff --git a/data/repository/build.gradle.kts b/data/repository/build.gradle.kts index de82399..af8f0eb 100644 --- a/data/repository/build.gradle.kts +++ b/data/repository/build.gradle.kts @@ -13,4 +13,6 @@ dependencies { implementation(projects.domain) implementation(projects.data.network) implementation(projects.data.storage) + implementation(libs.okhttp.logging) + implementation(libs.kotlinx.serialization.json) } diff --git a/data/repository/src/main/java/co/kr/data/repository/SignUpRepositoryImpl.kt b/data/repository/src/main/java/co/kr/data/repository/SignUpRepositoryImpl.kt new file mode 100644 index 0000000..f026958 --- /dev/null +++ b/data/repository/src/main/java/co/kr/data/repository/SignUpRepositoryImpl.kt @@ -0,0 +1,61 @@ +package co.kr.data.repository + +import co.kr.data.network.model.SignUpRequest +import co.kr.data.network.model.toDomain +import co.kr.data.network.model.toSignUpRequest +import co.kr.data.network.source.SignUpRemoteDataSource +import co.kr.data.storage.source.SessionLocalDataSource +import co.kr.tnt.domain.model.SignUpResult +import co.kr.tnt.domain.model.UserType +import co.kr.tnt.domain.repository.SignUpRepository +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File +import javax.inject.Inject + +class SignUpRepositoryImpl @Inject constructor( + private val signupRemoteDataSource: SignUpRemoteDataSource, + private val sessionLocalDataSource: SessionLocalDataSource, + private val json: Json, +) : SignUpRepository { + override suspend fun signUp( + profileImage: File?, + userType: UserType, + socialId: String, + socialType: String, + email: String, + ): SignUpResult { + val profileImagePart = profileImage?.let { + val requestFile = it.asRequestBody("image/*".toMediaTypeOrNull()) + MultipartBody.Part.createFormData("profileImage", it.name, requestFile) + } + + // TODO FCM token + val signUpRequest = userType.toSignUpRequest( + socialId = socialId, + socialType = socialType, + email = email, + fcmToken = "EMPTY", + ) + val requestBody = signUpRequest.toRequestBody(Json) + + val response = signupRemoteDataSource.postSignUp( + profileImage = profileImagePart, + request = requestBody, + ) + + sessionLocalDataSource.updateSessionId(response.sessionId) + + return response.toDomain() + } + + private fun SignUpRequest.toRequestBody(json: Json): RequestBody { + val jsonString = json.encodeToString(this) + return jsonString.toRequestBody("application/json".toMediaTypeOrNull()) + } +} diff --git a/data/repository/src/main/java/co/kr/data/repository/di/RepositoryModule.kt b/data/repository/src/main/java/co/kr/data/repository/di/RepositoryModule.kt index a41cb99..d510ece 100644 --- a/data/repository/src/main/java/co/kr/data/repository/di/RepositoryModule.kt +++ b/data/repository/src/main/java/co/kr/data/repository/di/RepositoryModule.kt @@ -1,7 +1,9 @@ package co.kr.data.repository.di import co.kr.data.repository.LoginRepositoryImpl +import co.kr.data.repository.SignUpRepositoryImpl import co.kr.tnt.domain.repository.LoginRepository +import co.kr.tnt.domain.repository.SignUpRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -14,4 +16,9 @@ internal abstract class RepositoryModule { abstract fun bindsLoginRepository( repository: LoginRepositoryImpl, ): LoginRepository + + @Binds + abstract fun bindsSignUpRepository( + repository: SignUpRepositoryImpl, + ): SignUpRepository } diff --git a/domain/src/main/java/co/kr/tnt/domain/model/SignUpResult.kt b/domain/src/main/java/co/kr/tnt/domain/model/SignUpResult.kt new file mode 100644 index 0000000..4019e29 --- /dev/null +++ b/domain/src/main/java/co/kr/tnt/domain/model/SignUpResult.kt @@ -0,0 +1,8 @@ +package co.kr.tnt.domain.model + +data class SignUpResult( + val memberType: String, + val sessionId: String, + val name: String, + val profileImageUrl: String, +) diff --git a/domain/src/main/java/co/kr/tnt/domain/model/UserType.kt b/domain/src/main/java/co/kr/tnt/domain/model/UserType.kt index c4a2731..989a556 100644 --- a/domain/src/main/java/co/kr/tnt/domain/model/UserType.kt +++ b/domain/src/main/java/co/kr/tnt/domain/model/UserType.kt @@ -1,24 +1,27 @@ package co.kr.tnt.domain.model +import java.time.LocalDate + sealed class UserType { abstract val id: String abstract val name: String abstract val image: String? data class Trainer( - override val id: String = "", - override val name: String = "", - override val image: String? = null, + override val id: String, + override val name: String, + override val image: String?, ) : UserType() data class Trainee( - override val id: String = "", - override val name: String = "", - override val image: String? = null, - val age: Int = 0, - val weight: Float = 0f, - val height: Int = 0, - val ptPurpose: List = emptyList(), - val caution: String? = null, + override val id: String, + override val name: String, + override val image: String?, + val birthday: LocalDate?, + val age: Int?, + val weight: Double, + val height: Int, + val ptPurpose: List, + val caution: String?, ) : UserType() } diff --git a/domain/src/main/java/co/kr/tnt/domain/repository/SignUpRepository.kt b/domain/src/main/java/co/kr/tnt/domain/repository/SignUpRepository.kt new file mode 100644 index 0000000..07494d9 --- /dev/null +++ b/domain/src/main/java/co/kr/tnt/domain/repository/SignUpRepository.kt @@ -0,0 +1,15 @@ +package co.kr.tnt.domain.repository + +import co.kr.tnt.domain.model.SignUpResult +import co.kr.tnt.domain.model.UserType +import java.io.File + +interface SignUpRepository { + suspend fun signUp( + profileImage: File?, + userType: UserType, + socialId: String, + socialType: String, + email: String, + ): SignUpResult +} diff --git a/feature/main/src/main/java/co/kr/tnt/main/ui/TnTNavHost.kt b/feature/main/src/main/java/co/kr/tnt/main/ui/TnTNavHost.kt index 198f551..9dc688a 100644 --- a/feature/main/src/main/java/co/kr/tnt/main/ui/TnTNavHost.kt +++ b/feature/main/src/main/java/co/kr/tnt/main/ui/TnTNavHost.kt @@ -13,9 +13,11 @@ import co.kr.tnt.roleselect.navigateToRoleSelection import co.kr.tnt.roleselect.roleSelectionScreen import co.kr.tnt.trainee.connect.navigation.navigateToTraineeConnect import co.kr.tnt.trainee.connect.navigation.traineeConnectScreen +import co.kr.tnt.trainee.signup.navigation.navigateToTraineeSignUp import co.kr.tnt.trainee.signup.navigation.traineeSignUpScreen import co.kr.tnt.trainer.connect.navigation.navigateToTrainerConnect import co.kr.tnt.trainer.connect.navigation.trainerConnectScreen +import co.kr.tnt.trainer.signup.navigation.navigateToTrainerSignUp import co.kr.tnt.trainer.signup.navigation.trainerSignUpScreen @Composable @@ -50,6 +52,22 @@ fun TnTNavHost( ) }, ) + roleSelectionScreen( + navigateToTraineeSignUp = { authId, authType, email -> + navController.navigateToTraineeSignUp( + authId = authId, + authType = authType, + email = email, + ) + }, + navigateToTrainerSignUp = { authId, authType, email -> + navController.navigateToTrainerSignUp( + authId = authId, + authType = authType, + email = email, + ) + }, + ) trainerSignUpScreen( navigateToPrevious = { navController.popBackStack() }, navigateToConnect = { navController.navigateToTrainerConnect(isFromMyPage = false) }, @@ -70,7 +88,6 @@ fun TnTNavHost( navController.navigateToHome(isTrainer = false, clearBackStack = true) }, ) - roleSelectionScreen() homeNavGraph() } } diff --git a/feature/roleselect/src/main/java/co/kr/tnt/roleselect/RoleSelectionContract.kt b/feature/roleselect/src/main/java/co/kr/tnt/roleselect/RoleSelectionContract.kt new file mode 100644 index 0000000..329935c --- /dev/null +++ b/feature/roleselect/src/main/java/co/kr/tnt/roleselect/RoleSelectionContract.kt @@ -0,0 +1,21 @@ +package co.kr.tnt.roleselect + +import co.kr.tnt.roleselect.model.RoleState +import co.kr.tnt.ui.base.UiEvent +import co.kr.tnt.ui.base.UiSideEffect +import co.kr.tnt.ui.base.UiState + +internal class RoleSelectionContract { + data class RoleSelectionUiState( + val userType: RoleState = RoleState.Trainer, + ) : UiState + + sealed interface RoleSelectionUiEvent : UiEvent { + data class OnNextClick(val role: RoleState) : RoleSelectionUiEvent + } + + sealed interface RoleSelectionEffect : UiSideEffect { + data object NavigateToTrainerSignUp : RoleSelectionEffect + data object NavigateToTraineeSignUp : RoleSelectionEffect + } +} diff --git a/feature/roleselect/src/main/java/co/kr/tnt/roleselect/RoleSelectionNavigation.kt b/feature/roleselect/src/main/java/co/kr/tnt/roleselect/RoleSelectionNavigation.kt index 15bfeb5..192c586 100644 --- a/feature/roleselect/src/main/java/co/kr/tnt/roleselect/RoleSelectionNavigation.kt +++ b/feature/roleselect/src/main/java/co/kr/tnt/roleselect/RoleSelectionNavigation.kt @@ -21,13 +21,15 @@ fun NavController.navigateToRoleSelection( builder = navOptions, ) -fun NavGraphBuilder.roleSelectionScreen() { +fun NavGraphBuilder.roleSelectionScreen( + navigateToTraineeSignUp: (authId: String, authType: String, email: String) -> Unit, + navigateToTrainerSignUp: (authId: String, authType: String, email: String) -> Unit, +) { composable { navBackstackEntry -> navBackstackEntry.toRoute().apply { - RoleSelectionScreen( - authId = authId, - authType = authType, - email = email, + RoleSelectionRoute( + navigateToTraineeSignUp = { navigateToTraineeSignUp(authId, authType, email) }, + navigateToTrainerSignUp = { navigateToTrainerSignUp(authId, authType, email) }, ) } } diff --git a/feature/roleselect/src/main/java/co/kr/tnt/roleselect/RoleSelectionScreen.kt b/feature/roleselect/src/main/java/co/kr/tnt/roleselect/RoleSelectionScreen.kt index 1b09876..1225319 100644 --- a/feature/roleselect/src/main/java/co/kr/tnt/roleselect/RoleSelectionScreen.kt +++ b/feature/roleselect/src/main/java/co/kr/tnt/roleselect/RoleSelectionScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -21,29 +22,54 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import co.kr.tnt.designsystem.component.button.TnTBottomButton import co.kr.tnt.designsystem.component.button.TnTTextButton import co.kr.tnt.designsystem.component.button.model.ButtonSize import co.kr.tnt.designsystem.component.button.model.ButtonType import co.kr.tnt.designsystem.theme.TnTTheme -import co.kr.tnt.domain.model.AuthType import co.kr.tnt.domain.model.UserType import co.kr.tnt.feature.roleselect.R +import co.kr.tnt.roleselect.RoleSelectionContract.RoleSelectionEffect +import co.kr.tnt.roleselect.RoleSelectionContract.RoleSelectionUiEvent import co.kr.tnt.roleselect.model.RoleState import co.kr.tnt.core.ui.R as uiResource @Composable -@Suppress("UnusedParameter") +internal fun RoleSelectionRoute( + viewModel: RoleSelectionViewModel = hiltViewModel(), + navigateToTraineeSignUp: () -> Unit, + navigateToTrainerSignUp: () -> Unit, +) { + RoleSelectionScreen( + onNextClick = { viewModel.setEvent(RoleSelectionUiEvent.OnNextClick(it)) }, + ) + + LaunchedEffect(viewModel.effect) { + viewModel.effect.collect { effect -> + when (effect) { + RoleSelectionEffect.NavigateToTraineeSignUp -> navigateToTraineeSignUp() + RoleSelectionEffect.NavigateToTrainerSignUp -> navigateToTrainerSignUp() + } + } + } +} + +@Composable fun RoleSelectionScreen( - onRoleSelected: (UserType) -> Unit = {}, - onNextClick: () -> Unit = {}, - authId: String, - authType: String, - email: String, - modifier: Modifier = Modifier, + onNextClick: (RoleState) -> Unit = {}, ) { - var selectedRole by remember { mutableStateOf(RoleState.fromDomain(UserType.Trainer())) } - val authType = AuthType.from(authType) + var selectedRole by remember { + mutableStateOf( + RoleState.fromDomain( + UserType.Trainer( + id = "", + name = "", + image = "TODO()", + ), + ), + ) + } Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Column( @@ -71,7 +97,6 @@ fun RoleSelectionScreen( contentDescription = null, modifier = Modifier.align(Alignment.CenterHorizontally), ) - // TODO 선택한 버튼 정보 저장 Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier @@ -85,7 +110,6 @@ fun RoleSelectionScreen( type = if (selectedRole == RoleState.Trainer) ButtonType.RedOutline else ButtonType.GrayOutline, onClick = { selectedRole = RoleState.Trainer - onRoleSelected(UserType.Trainer("", "")) }, ) TnTTextButton( @@ -95,14 +119,13 @@ fun RoleSelectionScreen( type = if (selectedRole == RoleState.Trainee) ButtonType.RedOutline else ButtonType.GrayOutline, onClick = { selectedRole = RoleState.Trainee - onRoleSelected(UserType.Trainer("", "")) }, ) } TnTBottomButton( text = stringResource(uiResource.string.next), enabled = true, - onClick = { onNextClick() }, + onClick = { onNextClick(selectedRole) }, ) } } @@ -113,12 +136,7 @@ fun RoleSelectionScreen( private fun RoleScreenPreview() { TnTTheme { RoleSelectionScreen( - onRoleSelected = {}, onNextClick = {}, - authId = "", - authType = "", - email = "", - modifier = Modifier.fillMaxSize(), ) } } diff --git a/feature/roleselect/src/main/java/co/kr/tnt/roleselect/RoleSelectionViewModel.kt b/feature/roleselect/src/main/java/co/kr/tnt/roleselect/RoleSelectionViewModel.kt new file mode 100644 index 0000000..a105033 --- /dev/null +++ b/feature/roleselect/src/main/java/co/kr/tnt/roleselect/RoleSelectionViewModel.kt @@ -0,0 +1,33 @@ +package co.kr.tnt.roleselect + +import co.kr.tnt.roleselect.RoleSelectionContract.RoleSelectionEffect +import co.kr.tnt.roleselect.RoleSelectionContract.RoleSelectionUiEvent +import co.kr.tnt.roleselect.RoleSelectionContract.RoleSelectionUiState +import co.kr.tnt.roleselect.model.RoleState +import co.kr.tnt.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +internal class RoleSelectionViewModel @Inject constructor() : + BaseViewModel( + RoleSelectionUiState(), + ) { + override suspend fun handleEvent(event: RoleSelectionUiEvent) { + when (event) { + is RoleSelectionUiEvent.OnNextClick -> navigateToSignUp(event.role) + } + } + + private fun navigateToSignUp(role: RoleState) { + when (role) { + is RoleState.Trainer -> { + sendEffect(RoleSelectionEffect.NavigateToTrainerSignUp) + } + + is RoleState.Trainee -> { + sendEffect(RoleSelectionEffect.NavigateToTraineeSignUp) + } + } + } + } diff --git a/feature/roleselect/src/main/java/co/kr/tnt/roleselect/model/RoleState.kt b/feature/roleselect/src/main/java/co/kr/tnt/roleselect/model/RoleState.kt index 3f652a3..6b8ce9b 100644 --- a/feature/roleselect/src/main/java/co/kr/tnt/roleselect/model/RoleState.kt +++ b/feature/roleselect/src/main/java/co/kr/tnt/roleselect/model/RoleState.kt @@ -23,8 +23,8 @@ sealed class RoleState( companion object { fun fromDomain(role: UserType): RoleState { return when (role) { - is UserType.Trainee -> Trainer - is UserType.Trainer -> Trainee + is UserType.Trainer -> Trainer + is UserType.Trainee -> Trainee } } } diff --git a/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/CodeEntryPage.kt b/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/CodeEntryPage.kt index d0d0067..d2b3277 100644 --- a/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/CodeEntryPage.kt +++ b/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/CodeEntryPage.kt @@ -113,9 +113,9 @@ private fun CodeEntryPagePreview() { isFromMyPage = false, onSkipClick = {}, onNextClick = {}, - state = TODO(), + state = TraineeConnectUiState(), onValidateClick = {}, - onCodeChanged = TODO(), + onCodeChanged = {}, onBackClick = {}, ) } diff --git a/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/PTSessionFormPage.kt b/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/PTSessionFormPage.kt index c34426c..70efa05 100644 --- a/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/PTSessionFormPage.kt +++ b/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/PTSessionFormPage.kt @@ -267,7 +267,7 @@ private fun PTSessionFormPagePreview() { PTSessionFormPage( onNextClick = {}, onBackClick = {}, - state = TODO(), + state = TraineeConnectUiState(), ) } } diff --git a/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectCompletePage.kt b/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectCompletePage.kt index 636a54d..a196055 100644 --- a/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectCompletePage.kt +++ b/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectCompletePage.kt @@ -132,7 +132,7 @@ private fun ProfileSection( private fun TraineeConnectCompletePagePreview() { TnTTheme { TraineeConnectCompletePage( - state = TODO(), + state = TraineeConnectUiState(), onNextClick = {}, onBackClick = {}, ) diff --git a/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectContract.kt b/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectContract.kt index f22ea23..6e0e724 100644 --- a/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectContract.kt +++ b/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectContract.kt @@ -16,8 +16,22 @@ internal class TraineeConnectContract { val completedSession: Int = 0, val totalSession: Int = 0, val selectedStartDate: LocalDate = LocalDate.now(), - val trainerState: UserType.Trainer = UserType.Trainer(), - val traineeState: UserType.Trainee = UserType.Trainee(), + val trainerState: UserType.Trainer = UserType.Trainer( + id = "", + name = "", + image = null, + ), + val traineeState: UserType.Trainee = UserType.Trainee( + id = "", + name = "", + image = null, + birthday = null, + age = 0, + weight = 0.0, + height = 0, + ptPurpose = emptyList(), + caution = null, + ), ) : UiState sealed interface TraineeConnectUiEvent : UiEvent { diff --git a/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectViewModel.kt b/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectViewModel.kt index 9ac73af..5cfba12 100644 --- a/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectViewModel.kt +++ b/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectViewModel.kt @@ -47,8 +47,9 @@ internal class TraineeConnectViewModel @Inject constructor() : id = "trainee", name = "김회원", image = "https://buly.kr/3j7VVqN", + birthday = null, age = 25, - weight = 100F, + weight = 100.0, height = 165, ptPurpose = listOf("체중 감량", "자세 교정"), caution = "발목이 안좋아서 발목에 무리가는 행동을 하면 안돼요. 잘 부탁드려요!", diff --git a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeBasicInfoPage.kt b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeBasicInfoPage.kt index 0144da7..c536da0 100644 --- a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeBasicInfoPage.kt +++ b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeBasicInfoPage.kt @@ -18,11 +18,6 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -36,32 +31,25 @@ import co.kr.tnt.designsystem.component.TnTTopBarWithBackButton import co.kr.tnt.designsystem.component.button.TnTBottomButton import co.kr.tnt.designsystem.theme.TnTTheme import co.kr.tnt.feature.trainee.signup.R +import co.kr.tnt.trainee.signup.TraineeSignUpContract.TraineeSignUpUiState import co.kr.tnt.trainee.signup.component.ProgressSteps import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter import co.kr.tnt.core.ui.R as uiResource -private const val MAX_HEIGHT_LENGTH = 3 -private const val MAX_WEIGHT_LENGTH = 5 - @Composable -fun TraineeBasicInfoPage( +internal fun TraineeBasicInfoPage( + state: TraineeSignUpUiState, + onHeightChange: (String) -> Unit, + onWeightChange: (String) -> Unit, + onBirthdayChange: (LocalDate) -> Unit, onBackClick: () -> Unit, onNextClick: () -> Unit, ) { BackHandler { onBackClick() } - // TODO 상태 관리 따로 빼기 val today = LocalDate.now() - var height by remember { mutableStateOf("") } - var weight by remember { mutableStateOf("") } - var birthday by remember { mutableStateOf(null) } - - val isHeightValid by remember { derivedStateOf { height.isNotEmpty() && validateHeight(height) } } - val isWeightValid by remember { derivedStateOf { weight.isNotEmpty() && validateWeight(weight) } } - - val isFormValid by remember { derivedStateOf { isHeightValid && isWeightValid } } Scaffold( topBar = { TnTTopBarWithBackButton(onBackClick = onBackClick) }, @@ -90,8 +78,8 @@ fun TraineeBasicInfoPage( BirthdayPicker( modifier = Modifier.padding(horizontal = 20.dp), today = today, - selectedDate = birthday, - onDateSelected = { birthday = it }, + selectedDate = state.birthday, + onDateSelected = onBirthdayChange, ) HorizontalDivider( thickness = 1.dp, @@ -107,32 +95,32 @@ fun TraineeBasicInfoPage( ) { TnTLabeledTextField( title = stringResource(uiResource.string.height_label), - value = height, + value = state.height, placeholder = "0", isSingleLine = true, - showWarning = !validateHeight(height), + showWarning = state.height.isNotEmpty() && !state.isHeightValid, warningMessage = stringResource(R.string.entered_wrong_number), isRequired = true, keyboardType = KeyboardType.Number, trailingComponent = { UnitLabel(uiResource.string.height_unit) }, - onValueChange = { height = it }, + onValueChange = onHeightChange, modifier = Modifier.weight(1f), ) TnTLabeledTextField( title = stringResource(uiResource.string.weight_label), - value = weight, + value = state.weight, placeholder = "00.0", isSingleLine = true, - showWarning = !validateWeight(weight), + showWarning = state.weight.isNotEmpty() && !state.isWeightValid, warningMessage = stringResource(R.string.entered_wrong_number), isRequired = true, keyboardType = KeyboardType.Number, trailingComponent = { UnitLabel(uiResource.string.weight_unit) }, - onValueChange = { weight = it }, + onValueChange = onWeightChange, modifier = Modifier.weight(1f), ) } @@ -140,7 +128,7 @@ fun TraineeBasicInfoPage( TnTBottomButton( text = stringResource(uiResource.string.next), modifier = Modifier.align(Alignment.BottomCenter), - enabled = isFormValid, + enabled = state.isBasicInfoValid, onClick = onNextClick, ) } @@ -207,30 +195,17 @@ private fun UnitLabel(stringResId: Int) { ) } -/** - * 키가 유효한 입력값인지 검사 - * 형식: 정수 3자 - */ -private fun validateHeight(input: String): Boolean { - return input.isEmpty() || input.toIntOrNull() != null && !input.startsWith("0") && input.length <= MAX_HEIGHT_LENGTH -} - -/** - * 몸무게가 유효한 입력값인지 검사 - * 형식: 5자 이하의 실수 (000, 00, 00.0, 000.0) - */ -private fun validateWeight(input: String): Boolean { - val weightRegex = Regex("^(\\d{1,3}(\\.\\d)?)?\$") - return input.isEmpty() || input.matches(weightRegex) && !input.startsWith("0") && input.length <= MAX_WEIGHT_LENGTH -} - @Preview(showBackground = true) @Composable private fun TraineeBasicInfoPagePreview() { TnTTheme { TraineeBasicInfoPage( + state = TraineeSignUpUiState(), onBackClick = {}, onNextClick = {}, + onHeightChange = {}, + onWeightChange = {}, + onBirthdayChange = {}, ) } } diff --git a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeNoteForTrainerPage.kt b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeNoteForTrainerPage.kt index 9ae56d2..72cfa3e 100644 --- a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeNoteForTrainerPage.kt +++ b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeNoteForTrainerPage.kt @@ -11,10 +11,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -31,15 +27,14 @@ import co.kr.tnt.core.ui.R as uiResource private const val MAX_LENGTH = 100 @Composable -fun TraineeNoteForTrainerPage( +internal fun TraineeNoteForTrainerPage( + caution: String?, + onCautionChange: (String) -> Unit, onBackClick: () -> Unit, onNextClick: () -> Unit, ) { BackHandler { onBackClick() } - // TODO 상태 관리 따로 빼기 - var text by remember { mutableStateOf("") } - Scaffold( topBar = { TnTTopBarWithBackButton(onBackClick = onBackClick) }, containerColor = TnTTheme.colors.commonColors.Common0, @@ -59,13 +54,15 @@ fun TraineeNoteForTrainerPage( ) Spacer(Modifier.padding(top = 48.dp)) TnTOutlinedTextField( - value = text, + value = caution ?: "", onValueChange = { newValue -> if (newValue.length <= MAX_LENGTH) { - text = newValue + onCautionChange(newValue) } }, modifier = Modifier.padding(horizontal = 20.dp), + isError = (caution?.length ?: 0) == MAX_LENGTH, + warningMessage = stringResource(R.string.text_length_overflow), maxLength = 100, ) } @@ -83,8 +80,10 @@ fun TraineeNoteForTrainerPage( private fun TraineeNoteForTrainerPagePreview() { TnTTheme { TraineeNoteForTrainerPage( + caution = "", onBackClick = {}, onNextClick = {}, + onCautionChange = {}, ) } } diff --git a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineePTPurposePage.kt b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineePTPurposePage.kt index 2c4be6c..e9612cc 100644 --- a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineePTPurposePage.kt +++ b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineePTPurposePage.kt @@ -12,10 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -28,6 +24,7 @@ import co.kr.tnt.designsystem.component.button.model.ButtonSize import co.kr.tnt.designsystem.component.button.model.ButtonType import co.kr.tnt.designsystem.theme.TnTTheme import co.kr.tnt.feature.trainee.signup.R +import co.kr.tnt.trainee.signup.TraineeSignUpContract.TraineeSignUpUiState import co.kr.tnt.trainee.signup.component.ProgressSteps import co.kr.tnt.trainee.signup.model.PTPurpose import co.kr.tnt.core.ui.R as uiResource @@ -37,15 +34,14 @@ private const val COLUMNS_NUM = 2 @OptIn(ExperimentalLayoutApi::class) @Composable -fun TraineePTPurposePage( +internal fun TraineePTPurposePage( + state: TraineeSignUpUiState, + onPurposeSelected: (String) -> Unit, onBackClick: () -> Unit, onNextClick: () -> Unit, ) { BackHandler { onBackClick() } - // TODO 리소스 id값 텍스트로 전환해 넘겨주기 - var selectedPurposes by remember { mutableStateOf(setOf()) } - Scaffold( topBar = { TnTTopBarWithBackButton(onBackClick = onBackClick) }, containerColor = TnTTheme.colors.commonColors.Common0, @@ -68,12 +64,11 @@ fun TraineePTPurposePage( modifier = Modifier.fillMaxWidth(), ) { PTPurpose.entries.forEach { purpose -> + val purposeText = stringResource(purpose.textResId) PurposeButton( - text = stringResource(purpose.textResId), - isSelected = purpose in selectedPurposes, - onClick = { - selectedPurposes = toggleSelection(selectedPurposes, purpose) - }, + text = purposeText, + isSelected = purposeText in state.ptPurpose, + onClick = { onPurposeSelected(purposeText) }, modifier = Modifier.weight(1f), ) } @@ -83,7 +78,7 @@ fun TraineePTPurposePage( TnTBottomButton( text = stringResource(uiResource.string.next), onClick = onNextClick, - enabled = selectedPurposes.isNotEmpty(), + enabled = state.ptPurpose.isNotEmpty(), modifier = Modifier.align(Alignment.BottomCenter), ) } @@ -106,27 +101,15 @@ fun PurposeButton( ) } -// 선택된 값 업데이트 -private fun toggleSelection( - selectedPurposes: Set, - purpose: PTPurpose, -): Set { - return selectedPurposes.toMutableSet().apply { - if (contains(purpose)) { - remove(purpose) - } else { - add(purpose) - } - }.toSet() -} - @Preview(showBackground = true) @Composable private fun TraineePTPurposePagePreview() { TnTTheme { TraineePTPurposePage( + state = TraineeSignUpUiState(), onBackClick = {}, onNextClick = {}, + onPurposeSelected = {}, ) } } diff --git a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeProfileSetupPage.kt b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeProfileSetupPage.kt index 5e79bf5..4d4c209 100644 --- a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeProfileSetupPage.kt +++ b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeProfileSetupPage.kt @@ -16,11 +16,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -35,6 +30,7 @@ import co.kr.tnt.designsystem.component.image.TnTProfileImage import co.kr.tnt.designsystem.theme.TnTTheme import co.kr.tnt.domain.IMAGE_MAX_SIZE import co.kr.tnt.feature.trainee.signup.R +import co.kr.tnt.trainee.signup.TraineeSignUpContract.TraineeSignUpUiState import co.kr.tnt.trainee.signup.component.ProgressSteps import co.kr.tnt.ui.coil.ResizeTransformation import co.kr.tnt.ui.model.DefaultUserProfile @@ -42,29 +38,27 @@ import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import co.kr.tnt.core.ui.R as uiResource +private const val MAX_LENGTH = 15 + @Composable -fun TraineeProfileSetupPage( +internal fun TraineeProfileSetupPage( + state: TraineeSignUpUiState, + onProfileImageSelect: (Uri) -> Unit, + onNameChange: (String) -> Unit, onBackClick: () -> Unit, onNextClick: () -> Unit, ) { BackHandler { onBackClick() } val context = LocalContext.current - - // TODO 상태 관리 따로 빼기 - val maxLength = 15 - var text by remember { mutableStateOf("") } - val isWarning by remember { derivedStateOf { text.length > maxLength } } - var profileImage by remember { mutableStateOf(null) } - val pickMediaLauncher = rememberLauncherForActivityResult(PickVisualMedia()) { uri -> if (uri != null) { - profileImage = uri + onProfileImageSelect(uri) } } val painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) - .data(profileImage) + .data(state.image) .transformations(ResizeTransformation(IMAGE_MAX_SIZE)) .build(), ) @@ -91,7 +85,7 @@ fun TraineeProfileSetupPage( .fillMaxWidth() .padding(vertical = 12.dp), defaultImage = painterResource(DefaultUserProfile.Trainee.image), - image = profileImage?.let { painter }, + image = state.image?.let { painter }, onEditClick = { pickMediaLauncher.launch( PickVisualMediaRequest( @@ -103,23 +97,23 @@ fun TraineeProfileSetupPage( Spacer(Modifier.padding(top = 60.dp)) TnTLabeledTextFieldWithCounter( title = stringResource(uiResource.string.name), - value = text, + value = state.name, onValueChange = { newValue -> val filteredText = validateInput(newValue) - text = filteredText + onNameChange(filteredText) }, modifier = Modifier.padding(horizontal = 20.dp), placeholder = stringResource(R.string.enter_your_name), - maxLength = maxLength, + maxLength = MAX_LENGTH, isSingleLine = true, - showWarning = isWarning, + showWarning = !state.isNameValid, isRequired = true, - warningMessage = stringResource(R.string.text_length_warning, maxLength), + warningMessage = stringResource(R.string.text_length_warning, MAX_LENGTH), ) } TnTBottomButton( text = stringResource(uiResource.string.next), - enabled = text.isNotBlank() && !isWarning, + enabled = state.name.isNotBlank() && state.isNameValid, onClick = onNextClick, modifier = Modifier.align(Alignment.BottomCenter), ) @@ -139,8 +133,11 @@ private fun validateInput(input: String): String { private fun TraineeProfileSetupPagePreview() { TnTTheme { TraineeProfileSetupPage( + state = TraineeSignUpUiState(), onBackClick = {}, onNextClick = {}, + onProfileImageSelect = {}, + onNameChange = {}, ) } } diff --git a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpCompletePage.kt b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpCompletePage.kt index dd60f9c..a753361 100644 --- a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpCompletePage.kt +++ b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpCompletePage.kt @@ -1,5 +1,6 @@ package co.kr.tnt.trainee.signup +import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -22,21 +23,19 @@ import co.kr.tnt.designsystem.component.button.TnTBottomButton import co.kr.tnt.designsystem.component.image.TnTProfileImage import co.kr.tnt.designsystem.theme.TnTTheme import co.kr.tnt.feature.trainee.signup.R +import co.kr.tnt.trainee.signup.TraineeSignUpContract.TraineeSignUpUiState import co.kr.tnt.ui.model.DefaultUserProfile import coil.compose.rememberAsyncImagePainter import co.kr.tnt.core.ui.R as uiResource @Composable -fun TraineeSignUpCompletePage( +internal fun TraineeSignUpCompletePage( + state: TraineeSignUpUiState, onBackClick: () -> Unit, - onNextClick: () -> Unit, + onNextClick: (Uri?) -> Unit, ) { BackHandler { onBackClick() } - // TODO 이름, 프로필 사진 불러오기 - val name = "김헬짱" - val profileImage = "https://buly.kr/7FQeS5M" - Scaffold( containerColor = TnTTheme.colors.commonColors.Common0, ) { innerPadding -> @@ -54,7 +53,7 @@ fun TraineeSignUpCompletePage( .padding(bottom = 66.dp), ) { Text( - text = stringResource(R.string.nice_to_meet_you_trainee, name), + text = stringResource(R.string.nice_to_meet_you_trainee, state.name), color = TnTTheme.colors.neutralColors.Neutral950, style = TnTTheme.typography.h1, textAlign = Center, @@ -70,14 +69,14 @@ fun TraineeSignUpCompletePage( Spacer(Modifier.padding(top = 28.dp)) TnTProfileImage( defaultImage = painterResource(DefaultUserProfile.Trainee.image), - image = rememberAsyncImagePainter(profileImage), + image = rememberAsyncImagePainter(state.image), imageSize = 200.dp, showEditButton = false, ) } TnTBottomButton( text = stringResource(uiResource.string.start), - onClick = onNextClick, + onClick = { onNextClick(state.image) }, modifier = Modifier.align(Alignment.BottomCenter), ) } @@ -91,6 +90,7 @@ private fun TraineeSignUpCompletePagePreview() { TraineeSignUpCompletePage( onBackClick = {}, onNextClick = {}, + state = TraineeSignUpUiState(name = "김회원"), ) } } diff --git a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpContract.kt b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpContract.kt index d1b3925..0f15a58 100644 --- a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpContract.kt +++ b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpContract.kt @@ -1,21 +1,71 @@ package co.kr.tnt.trainee.signup +import android.content.Context +import android.net.Uri import co.kr.tnt.ui.base.UiEvent import co.kr.tnt.ui.base.UiSideEffect import co.kr.tnt.ui.base.UiState +import java.time.LocalDate + +private const val MAX_NAME_LENGTH = 15 +private const val MAX_HEIGHT_LENGTH = 3 +private const val MAX_WEIGHT_LENGTH = 5 internal class TraineeSignUpContract { data class TraineeSignUpUiState( val page: TraineeSignUpPage = TraineeSignUpPage.ProfileSetUp, val name: String = "", - ) : UiState + val image: Uri? = null, + val birthday: LocalDate? = null, + val height: String = "", + val weight: String = "", + val ptPurpose: List = emptyList(), + val caution: String? = "", + ) : UiState { + val isNameValid get() = name.length <= MAX_NAME_LENGTH + + /** + * 키가 유효한 입력값인지 검사 + * 형식: 정수 3자 + */ + val isHeightValid + get() = height.isNotEmpty() && height.toIntOrNull() != null && + !height.startsWith("0") && height.length <= MAX_HEIGHT_LENGTH + + /** + * 몸무게가 유효한 입력값인지 검사 + * 형식: 5자 이하의 실수 (000, 00, 00.0, 000.0) + */ + private val weightRegex = Regex("^(\\d{1,3}(\\.\\d)?)?\$") + val isWeightValid + get() = weight.isNotEmpty() && weight.matches(weightRegex) && + !weight.startsWith("0") && weight.length <= MAX_WEIGHT_LENGTH + + val isBasicInfoValid + get() = isWeightValid && isHeightValid + } sealed interface TraineeSignUpUiEvent : UiEvent { + data class OnImageChange(val imageUri: Uri) : TraineeSignUpUiEvent + data class OnNameChange(val name: String) : TraineeSignUpUiEvent + data class OnHeightChange(val height: String) : TraineeSignUpUiEvent + data class OnWeightChange(val weight: String) : TraineeSignUpUiEvent + data class OnBirthdayChange(val birthday: LocalDate) : TraineeSignUpUiEvent + data class OnPurposeSelected(val purpose: String) : TraineeSignUpUiEvent + data class OnCautionChange(val text: String) : TraineeSignUpUiEvent data object OnNextClick : TraineeSignUpUiEvent data object OnBackClick : TraineeSignUpUiEvent + data class RequestSignUp( + val context: Context, + val imageUri: Uri?, + val id: String, + val email: String, + val authType: String, + ) : TraineeSignUpUiEvent } sealed interface TraineeSignUpEffect : UiSideEffect { + data class ShowToast(val message: String) : TraineeSignUpEffect data object NavigateToBack : TraineeSignUpEffect data object NavigateToConnect : TraineeSignUpEffect } diff --git a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpScreen.kt b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpScreen.kt index c1a584b..5c6ed56 100644 --- a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpScreen.kt +++ b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpScreen.kt @@ -1,27 +1,53 @@ package co.kr.tnt.trainee.signup +import android.net.Uri +import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.kr.tnt.trainee.signup.TraineeSignUpContract.TraineeSignUpEffect import co.kr.tnt.trainee.signup.TraineeSignUpContract.TraineeSignUpPage import co.kr.tnt.trainee.signup.TraineeSignUpContract.TraineeSignUpUiEvent import co.kr.tnt.trainee.signup.TraineeSignUpContract.TraineeSignUpUiState +import java.time.LocalDate @Composable internal fun TraineeSignUpRoute( + authId: String, + authType: String, + email: String, navigateToPrevious: () -> Unit, navigateToConnect: () -> Unit, viewModel: TraineeSignUpViewModel = hiltViewModel(), ) { + val context = LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() TraineeSignUpScreen( state = uiState, + onNameChange = { viewModel.setEvent(TraineeSignUpUiEvent.OnNameChange(it)) }, + onProfileImageSelect = { viewModel.setEvent(TraineeSignUpUiEvent.OnImageChange(it)) }, + onHeightChange = { viewModel.setEvent(TraineeSignUpUiEvent.OnHeightChange(it)) }, + onWeightChange = { viewModel.setEvent(TraineeSignUpUiEvent.OnWeightChange(it)) }, + onBirthdayChange = { viewModel.setEvent(TraineeSignUpUiEvent.OnBirthdayChange(it)) }, + onPurposeSelected = { viewModel.setEvent(TraineeSignUpUiEvent.OnPurposeSelected(it)) }, + onCautionChange = { viewModel.setEvent(TraineeSignUpUiEvent.OnCautionChange(it)) }, onBackClick = { viewModel.setEvent(TraineeSignUpUiEvent.OnBackClick) }, onNextClick = { viewModel.setEvent(TraineeSignUpUiEvent.OnNextClick) }, + onSubmitSignUp = { uri -> + viewModel.setEvent( + TraineeSignUpUiEvent.RequestSignUp( + context = context, + imageUri = uri, + id = authId, + email = email, + authType = authType, + ), + ) + }, ) LaunchedEffect(viewModel.effect) { @@ -29,6 +55,9 @@ internal fun TraineeSignUpRoute( when (effect) { TraineeSignUpEffect.NavigateToBack -> navigateToPrevious() TraineeSignUpEffect.NavigateToConnect -> navigateToConnect() + is TraineeSignUpEffect.ShowToast -> { + Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + } } } } @@ -37,29 +66,53 @@ internal fun TraineeSignUpRoute( @Composable private fun TraineeSignUpScreen( state: TraineeSignUpUiState, + onProfileImageSelect: (Uri) -> Unit, + onNameChange: (String) -> Unit, + onHeightChange: (String) -> Unit, + onWeightChange: (String) -> Unit, + onCautionChange: (String) -> Unit, + onBirthdayChange: (LocalDate) -> Unit, + onPurposeSelected: (String) -> Unit, + onSubmitSignUp: (Uri?) -> Unit, onNextClick: () -> Unit, onBackClick: () -> Unit, ) { when (state.page) { TraineeSignUpPage.ProfileSetUp -> TraineeProfileSetupPage( + state = state, + onProfileImageSelect = onProfileImageSelect, + onNameChange = onNameChange, onBackClick = onBackClick, onNextClick = onNextClick, ) + TraineeSignUpPage.BasicInfo -> TraineeBasicInfoPage( + state = state, + onHeightChange = onHeightChange, + onWeightChange = onWeightChange, + onBirthdayChange = onBirthdayChange, onBackClick = onBackClick, onNextClick = onNextClick, ) + TraineeSignUpPage.NoteForTrainer -> TraineeNoteForTrainerPage( + caution = state.caution, + onCautionChange = onCautionChange, onBackClick = onBackClick, onNextClick = onNextClick, ) + TraineeSignUpPage.PTPurpose -> TraineePTPurposePage( + state = state, + onPurposeSelected = onPurposeSelected, onBackClick = onBackClick, onNextClick = onNextClick, ) + TraineeSignUpPage.SignUpComplete -> TraineeSignUpCompletePage( + state = state, onBackClick = onBackClick, - onNextClick = onNextClick, + onNextClick = onSubmitSignUp, ) } } diff --git a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpViewModel.kt b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpViewModel.kt index 8c2d38d..137ba1a 100644 --- a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpViewModel.kt +++ b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpViewModel.kt @@ -1,46 +1,137 @@ package co.kr.tnt.trainee.signup +import android.content.Context +import android.net.Uri +import androidx.lifecycle.viewModelScope +import co.kr.tnt.core.ui.R +import co.kr.tnt.domain.model.UserType +import co.kr.tnt.domain.repository.SignUpRepository import co.kr.tnt.trainee.signup.TraineeSignUpContract.TraineeSignUpEffect import co.kr.tnt.trainee.signup.TraineeSignUpContract.TraineeSignUpPage import co.kr.tnt.trainee.signup.TraineeSignUpContract.TraineeSignUpUiEvent import co.kr.tnt.trainee.signup.TraineeSignUpContract.TraineeSignUpUiState import co.kr.tnt.ui.base.BaseViewModel +import co.kr.tnt.ui.util.toFile import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import java.time.LocalDate import javax.inject.Inject @HiltViewModel -internal class TraineeSignUpViewModel @Inject constructor() : - BaseViewModel( +internal class TraineeSignUpViewModel @Inject constructor( + private val signUpRepository: SignUpRepository, +) : BaseViewModel( TraineeSignUpUiState(), ) { - override suspend fun handleEvent(event: TraineeSignUpUiEvent) { - when (event) { - TraineeSignUpUiEvent.OnBackClick -> navigateToBack() - TraineeSignUpUiEvent.OnNextClick -> navigateToNext() + override suspend fun handleEvent(event: TraineeSignUpUiEvent) { + when (event) { + is TraineeSignUpUiEvent.OnImageChange -> updateProfileImage(event.imageUri) + is TraineeSignUpUiEvent.OnNameChange -> updateName(event.name) + is TraineeSignUpUiEvent.OnHeightChange -> updateHeight(event.height) + is TraineeSignUpUiEvent.OnWeightChange -> updateWeight(event.weight) + is TraineeSignUpUiEvent.OnBirthdayChange -> updateBirthday(event.birthday) + is TraineeSignUpUiEvent.OnPurposeSelected -> updateSelectedPurposes(event.purpose) + is TraineeSignUpUiEvent.OnCautionChange -> updateCaution(event.text) + TraineeSignUpUiEvent.OnBackClick -> navigateToBack() + TraineeSignUpUiEvent.OnNextClick -> navigateToNext() + is TraineeSignUpUiEvent.RequestSignUp -> signUp( + context = event.context, + imageUri = event.imageUri, + id = event.id, + email = event.email, + authType = event.authType, + ) + } + } + + private fun signUp( + context: Context, + imageUri: Uri?, + id: String, + email: String, + authType: String, + ) { + viewModelScope.launch { + val state = currentState + val profileImagePart = imageUri?.toFile(context) + + runCatching { + signUpRepository.signUp( + profileImage = profileImagePart, + userType = UserType.Trainee( + id = id, + name = state.name, + image = state.image.toString(), + birthday = state.birthday, + age = null, + weight = state.weight.toDouble(), + height = state.height.toInt(), + ptPurpose = state.ptPurpose, + caution = state.caution, + ), + socialId = id, + socialType = authType, + email = email, + ) + }.onSuccess { + sendEffect(TraineeSignUpEffect.NavigateToConnect) + }.onFailure { + // TODO 디자인 시스템 Toast 적용 + val message = context.getString(R.string.error_server_request_failed) + sendEffect(TraineeSignUpEffect.ShowToast(message)) } } + } + + private fun updateProfileImage(imageUri: Uri) { + updateState { copy(image = imageUri) } + } + + private fun updateName(name: String) { + updateState { copy(name = name) } + } - private fun navigateToNext() { - val nextPage = when (currentState.page) { - TraineeSignUpPage.SignUpComplete -> { - sendEffect(TraineeSignUpEffect.NavigateToConnect) - return - } + private fun updateHeight(height: String) { + updateState { copy(height = height) } + } + + private fun updateWeight(weight: String) { + updateState { copy(weight = weight) } + } + + private fun updateBirthday(birthday: LocalDate) { + updateState { copy(birthday = birthday) } + } - else -> TraineeSignUpPage.getNextPage(currentState.page) + private fun updateSelectedPurposes(purpose: String) { + val updatedPurposes = currentState.ptPurpose.toMutableList().apply { + if (contains(purpose)) { + remove(purpose) + } else { + add(purpose) } - updateState { copy(page = nextPage) } } + updateState { copy(ptPurpose = updatedPurposes) } + } - private fun navigateToBack() { - val previousPage = when (currentState.page) { - TraineeSignUpPage.ProfileSetUp -> { - sendEffect(TraineeSignUpEffect.NavigateToBack) - return - } + private fun updateCaution(text: String) { + updateState { copy(caution = text) } + } - else -> TraineeSignUpPage.getPreviousPage(currentState.page) + private fun navigateToNext() { + val nextPage = TraineeSignUpPage.getNextPage(currentState.page) + updateState { copy(page = nextPage) } + } + + private fun navigateToBack() { + val previousPage = when (currentState.page) { + TraineeSignUpPage.ProfileSetUp -> { + sendEffect(TraineeSignUpEffect.NavigateToBack) + return } - updateState { copy(page = previousPage) } + + else -> TraineeSignUpPage.getPreviousPage(currentState.page) } + updateState { copy(page = previousPage) } } +} diff --git a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/navigation/TraineeSignUpNavigation.kt b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/navigation/TraineeSignUpNavigation.kt index c5fde06..a30ef28 100644 --- a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/navigation/TraineeSignUpNavigation.kt +++ b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/navigation/TraineeSignUpNavigation.kt @@ -9,9 +9,16 @@ import co.kr.tnt.navigation.Route import co.kr.tnt.trainee.signup.TraineeSignUpRoute fun NavController.navigateToTraineeSignUp( + authId: String, + authType: String, + email: String, navOptions: NavOptionsBuilder.() -> Unit = {}, ) = navigate( - route = Route.TraineeSignUp, + route = Route.TraineeSignUp( + authId = authId, + authType = authType, + email = email, + ), builder = navOptions, ) @@ -22,6 +29,9 @@ fun NavGraphBuilder.traineeSignUpScreen( composable { backstackEntry -> backstackEntry.toRoute().apply { TraineeSignUpRoute( + authId = authId, + authType = authType, + email = email, navigateToPrevious = navigateToPrevious, navigateToConnect = { navigateToConnect() }, ) diff --git a/feature/trainee/signup/src/main/res/values/strings.xml b/feature/trainee/signup/src/main/res/values/strings.xml index dc06f29..c81f3af 100644 --- a/feature/trainee/signup/src/main/res/values/strings.xml +++ b/feature/trainee/signup/src/main/res/values/strings.xml @@ -22,7 +22,7 @@ 트레이너가 꼭 알아야 할\n주의사항이 있나요? 트레이너에게 알려드릴게요. - 글자 수를 초과했어요 + 100자 미만으로 입력해주세요 만나서 반가워요\n%s 트레이니님! 트레이너와 함께\n케미를 터뜨려보세요! 🧨 diff --git a/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/CodeGenerationPage.kt b/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/CodeGenerationPage.kt index 7e3bd00..e11c649 100644 --- a/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/CodeGenerationPage.kt +++ b/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/CodeGenerationPage.kt @@ -177,7 +177,7 @@ private fun copyToClipboard(context: Context, text: String) { private fun CodeGenerationPagePreview() { TnTTheme { CodeGenerationPage( - state = TODO(), + state = TrainerConnectUiState(), onBackClick = {}, onSkipClick = {}, onRegenerateClick = {}, diff --git a/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TraineeProfilePage.kt b/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TraineeProfilePage.kt index 59b5805..5dfea4a 100644 --- a/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TraineeProfilePage.kt +++ b/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TraineeProfilePage.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp import co.kr.tnt.designsystem.component.button.TnTBottomButton import co.kr.tnt.designsystem.component.image.TnTProfileImage import co.kr.tnt.designsystem.theme.TnTTheme +import co.kr.tnt.domain.model.UserType import co.kr.tnt.feature.trainer.connect.R import co.kr.tnt.trainer.connect.TrainerConnectContract.TrainerConnectUiState import co.kr.tnt.ui.model.DefaultUserProfile @@ -106,10 +107,12 @@ internal fun TraineeProfilePage( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth(), ) { - TextWithLabel( - label = stringResource(uiResource.string.age_label), - text = trainee.age.toString() + stringResource(uiResource.string.age_unit), - ) + if (trainee.age.toString() != "null") { + TextWithLabel( + label = stringResource(uiResource.string.age_label), + text = trainee.age.toString() + stringResource(uiResource.string.age_unit), + ) + } TextWithLabel( label = stringResource(uiResource.string.height_label), text = trainee.height.toString() + stringResource(uiResource.string.height_unit), @@ -198,7 +201,19 @@ private fun TextWithBackground( private fun TraineeProfilePagePreview() { TnTTheme { TraineeProfilePage( - state = TODO(), + state = TrainerConnectUiState( + traineeState = UserType.Trainee( + id = "", + name = "김회원", + image = null, + birthday = null, + age = null, + weight = 55.0, + height = 150, + ptPurpose = listOf("체중 감량", "자세 교정"), + caution = null, + ), + ), onNextClick = {}, onBackClick = {}, ) diff --git a/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectCompletePage.kt b/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectCompletePage.kt index d2c6e33..eaa9e33 100644 --- a/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectCompletePage.kt +++ b/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectCompletePage.kt @@ -129,7 +129,7 @@ private fun ProfileSection( private fun TrainerConnectCompletePagePreview() { TnTTheme { TrainerConnectCompletePage( - state = TODO(), + state = TrainerConnectUiState(), onNextClick = {}, onBackClick = {}, ) diff --git a/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectContract.kt b/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectContract.kt index 2ccf95a..87ed5be 100644 --- a/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectContract.kt +++ b/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectContract.kt @@ -9,8 +9,22 @@ internal class TrainerConnectContract { data class TrainerConnectUiState( val page: TrainerConnectPage = TrainerConnectPage.CodeGeneration, val inviteCode: String = "", - val trainerState: UserType.Trainer = UserType.Trainer(), - val traineeState: UserType.Trainee = UserType.Trainee(), + val trainerState: UserType.Trainer = UserType.Trainer( + id = "", + name = "", + image = null, + ), + val traineeState: UserType.Trainee = UserType.Trainee( + id = "", + name = "", + image = null, + birthday = null, + age = 0, + weight = 0.0, + height = 0, + ptPurpose = emptyList(), + caution = null, + ), ) : UiState sealed interface TrainerConnectUiEvent : UiEvent { diff --git a/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectViewModel.kt b/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectViewModel.kt index 200c723..93f32b0 100644 --- a/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectViewModel.kt +++ b/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectViewModel.kt @@ -25,7 +25,6 @@ internal class TrainerConnectViewModel @Inject constructor() : TrainerConnectUiEvent.OnNextClick -> navigateToNext() TrainerConnectUiEvent.OnBackClick -> navigateToBack() TrainerConnectUiEvent.OnSkipClick -> navigateToHome() - else -> {} } } @@ -42,8 +41,9 @@ internal class TrainerConnectViewModel @Inject constructor() : id = "trainee", name = "김회원", image = "https://buly.kr/3j7VVqN", + birthday = null, age = 25, - weight = 100F, + weight = 100.0, height = 165, ptPurpose = listOf("체중 감량", "자세 교정"), caution = "발목이 안좋아서 발목에 무리가는 행동을 하면 안돼요. 잘 부탁드려요!", diff --git a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerProfileSetupPage.kt b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerProfileSetupPage.kt index 0cfce9d..4f2901a 100644 --- a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerProfileSetupPage.kt +++ b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerProfileSetupPage.kt @@ -19,9 +19,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -36,14 +34,20 @@ import co.kr.tnt.designsystem.component.image.TnTProfileImage import co.kr.tnt.designsystem.theme.TnTTheme import co.kr.tnt.domain.IMAGE_MAX_SIZE import co.kr.tnt.feature.trainer.signup.R +import co.kr.tnt.trainer.signup.TrainerSignUpContract.TrainerSignUpUiState import co.kr.tnt.ui.coil.ResizeTransformation import co.kr.tnt.ui.model.DefaultUserProfile import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import co.kr.tnt.core.ui.R as uiResource +private const val MAX_LENGTH = 15 + @Composable -fun TrainerProfileSetupPage( +internal fun TrainerProfileSetupPage( + state: TrainerSignUpUiState, + onProfileImageSelect: (Uri) -> Unit, + onNameChange: (String) -> Unit, onBackClick: () -> Unit, onNextClick: () -> Unit, ) { @@ -51,20 +55,18 @@ fun TrainerProfileSetupPage( val context = LocalContext.current - // TODO 상태 관리 따로 빼기 - val maxLength = 15 - var text by remember { mutableStateOf("") } - val isWarning by remember { derivedStateOf { text.length > maxLength } } - var profileImage by remember { mutableStateOf(null) } + val isWarning by remember(state.name) { + derivedStateOf { state.name.length > MAX_LENGTH } + } val pickMediaLauncher = rememberLauncherForActivityResult(PickVisualMedia()) { uri -> if (uri != null) { - profileImage = uri + onProfileImageSelect(uri) } } val painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) - .data(profileImage) + .data(state.image) .transformations(ResizeTransformation(IMAGE_MAX_SIZE)) .build(), ) @@ -92,7 +94,7 @@ fun TrainerProfileSetupPage( .fillMaxWidth() .padding(vertical = 12.dp), defaultImage = painterResource(DefaultUserProfile.Trainer.image), - image = profileImage?.let { painter }, + image = state.image?.let { painter }, onEditClick = { pickMediaLauncher.launch( PickVisualMediaRequest( @@ -104,24 +106,24 @@ fun TrainerProfileSetupPage( Spacer(Modifier.padding(top = 60.dp)) TnTLabeledTextFieldWithCounter( title = stringResource(uiResource.string.name), - value = text, + value = state.name, onValueChange = { newValue -> val filteredText = validateInput(newValue) - text = filteredText + onNameChange(filteredText) }, modifier = Modifier.padding(horizontal = 20.dp), placeholder = stringResource(R.string.name_placeholder), - maxLength = maxLength, + maxLength = MAX_LENGTH, isSingleLine = true, showWarning = isWarning, isRequired = true, - warningMessage = stringResource(R.string.text_length_warning, maxLength), + warningMessage = stringResource(R.string.text_length_warning, MAX_LENGTH), ) } TnTBottomButton( text = stringResource(uiResource.string.next), modifier = Modifier.align(Alignment.BottomCenter), - enabled = text.isNotBlank() && !isWarning, + enabled = state.name.isNotBlank() && !isWarning, onClick = onNextClick, ) } @@ -140,6 +142,9 @@ private fun validateInput(input: String): String { private fun TrainerProfileSetupPagePreview() { TnTTheme { TrainerProfileSetupPage( + state = TrainerSignUpUiState(), + onNameChange = {}, + onProfileImageSelect = {}, onBackClick = {}, onNextClick = {}, ) diff --git a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpCompletePage.kt b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpCompletePage.kt index c5c0840..7ebd3b9 100644 --- a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpCompletePage.kt +++ b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpCompletePage.kt @@ -1,5 +1,6 @@ package co.kr.tnt.trainer.signup +import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -22,21 +23,19 @@ import co.kr.tnt.designsystem.component.button.TnTBottomButton import co.kr.tnt.designsystem.component.image.TnTProfileImage import co.kr.tnt.designsystem.theme.TnTTheme import co.kr.tnt.feature.trainer.signup.R +import co.kr.tnt.trainer.signup.TrainerSignUpContract.TrainerSignUpUiState import co.kr.tnt.ui.model.DefaultUserProfile import coil.compose.rememberAsyncImagePainter import co.kr.tnt.core.ui.R as uiResource @Composable -fun TrainerSignUpCompletePage( +internal fun TrainerSignUpCompletePage( + state: TrainerSignUpUiState, onBackClick: () -> Unit, - onNextClick: () -> Unit, + onNextClick: (Uri?) -> Unit, ) { BackHandler { onBackClick() } - // TODO 이름, 프로필 이미지 불러오기 - val name = "김헬짱" - val profileImage = "https://buly.kr/7FQeS5M" - Scaffold( containerColor = TnTTheme.colors.commonColors.Common0, ) { innerPadding -> @@ -54,7 +53,7 @@ fun TrainerSignUpCompletePage( .padding(bottom = 66.dp), ) { Text( - text = stringResource(R.string.nice_to_meet_you_trainer, name), + text = stringResource(R.string.nice_to_meet_you_trainer, state.name), color = TnTTheme.colors.neutralColors.Neutral950, style = TnTTheme.typography.h1, textAlign = Center, @@ -70,14 +69,14 @@ fun TrainerSignUpCompletePage( Spacer(Modifier.padding(top = 28.dp)) TnTProfileImage( defaultImage = painterResource(DefaultUserProfile.Trainer.image), - image = rememberAsyncImagePainter(profileImage), + image = rememberAsyncImagePainter(state.image), imageSize = 200.dp, showEditButton = false, ) } TnTBottomButton( text = stringResource(uiResource.string.start), - onClick = onNextClick, + onClick = { onNextClick(state.image) }, modifier = Modifier.align(Alignment.BottomCenter), ) } @@ -91,6 +90,7 @@ private fun TrainerSignUpCompletePagePreview() { TrainerSignUpCompletePage( onBackClick = {}, onNextClick = {}, + state = TrainerSignUpUiState(), ) } } diff --git a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpContract.kt b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpContract.kt index a77a505..4b35640 100644 --- a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpContract.kt +++ b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpContract.kt @@ -1,5 +1,7 @@ package co.kr.tnt.trainer.signup +import android.content.Context +import android.net.Uri import co.kr.tnt.ui.base.UiEvent import co.kr.tnt.ui.base.UiSideEffect import co.kr.tnt.ui.base.UiState @@ -8,14 +10,25 @@ internal class TrainerSignUpContract { data class TrainerSignUpUiState( val page: TrainerSignUpPage = TrainerSignUpPage.ProfileSetUp, val name: String = "", + val image: Uri? = null, ) : UiState sealed interface TrainerSignUpUiEvent : UiEvent { + data class OnImageChange(val imageUri: Uri) : TrainerSignUpUiEvent + data class OnNameChange(val name: String) : TrainerSignUpUiEvent data object OnNextClick : TrainerSignUpUiEvent data object OnBackClick : TrainerSignUpUiEvent + data class RequestSignUp( + val context: Context, + val imageUri: Uri?, + val id: String, + val email: String, + val authType: String, + ) : TrainerSignUpUiEvent } sealed interface TrainerSignUpEffect : UiSideEffect { + data class ShowToast(val message: String) : TrainerSignUpEffect data object NavigateToBack : TrainerSignUpEffect data object NavigateToConnect : TrainerSignUpEffect } diff --git a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpScreen.kt b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpScreen.kt index d0a6d44..0aecbd5 100644 --- a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpScreen.kt +++ b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpScreen.kt @@ -1,8 +1,11 @@ package co.kr.tnt.trainer.signup +import android.net.Uri +import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.kr.tnt.trainer.signup.TrainerSignUpContract.TrainerSignUpUiEvent @@ -10,16 +13,33 @@ import co.kr.tnt.trainer.signup.TrainerSignUpContract.TrainerSignUpUiState @Composable internal fun TrainerSignUpRoute( + authId: String, + authType: String, + email: String, navigateToPrevious: () -> Unit, navigateToConnect: () -> Unit, viewModel: TrainerSignUpViewModel = hiltViewModel(), ) { + val context = LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() TrainerSignUpScreen( state = uiState, + onNameChange = { viewModel.setEvent(TrainerSignUpUiEvent.OnNameChange(it)) }, + onProfileImageSelect = { viewModel.setEvent(TrainerSignUpUiEvent.OnImageChange(it)) }, onNextClick = { viewModel.setEvent(TrainerSignUpUiEvent.OnNextClick) }, onBackClick = { viewModel.setEvent(TrainerSignUpUiEvent.OnBackClick) }, + onSubmitSignUp = { uri -> + viewModel.setEvent( + TrainerSignUpUiEvent.RequestSignUp( + context = context, + imageUri = uri, + id = authId, + email = email, + authType = authType, + ), + ) + }, ) LaunchedEffect(viewModel.effect) { @@ -27,6 +47,9 @@ internal fun TrainerSignUpRoute( when (effect) { TrainerSignUpContract.TrainerSignUpEffect.NavigateToBack -> navigateToPrevious() TrainerSignUpContract.TrainerSignUpEffect.NavigateToConnect -> navigateToConnect() + is TrainerSignUpContract.TrainerSignUpEffect.ShowToast -> { + Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + } } } } @@ -35,16 +58,24 @@ internal fun TrainerSignUpRoute( @Composable private fun TrainerSignUpScreen( state: TrainerSignUpUiState, + onProfileImageSelect: (Uri) -> Unit, + onNameChange: (String) -> Unit, + onSubmitSignUp: (Uri?) -> Unit, onNextClick: () -> Unit, onBackClick: () -> Unit, ) { when (state.page) { TrainerSignUpContract.TrainerSignUpPage.ProfileSetUp -> TrainerProfileSetupPage( + state = state, + onProfileImageSelect = onProfileImageSelect, + onNameChange = onNameChange, onNextClick = onNextClick, onBackClick = onBackClick, ) + TrainerSignUpContract.TrainerSignUpPage.SignUpComplete -> TrainerSignUpCompletePage( - onNextClick = onNextClick, + state = state, + onNextClick = onSubmitSignUp, onBackClick = onBackClick, ) } diff --git a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpViewModel.kt b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpViewModel.kt index 0ec565c..886876f 100644 --- a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpViewModel.kt +++ b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpViewModel.kt @@ -1,46 +1,97 @@ package co.kr.tnt.trainer.signup +import android.content.Context +import android.net.Uri +import androidx.lifecycle.viewModelScope +import co.kr.tnt.domain.model.UserType +import co.kr.tnt.domain.repository.SignUpRepository import co.kr.tnt.trainer.signup.TrainerSignUpContract.TrainerSignUpEffect import co.kr.tnt.trainer.signup.TrainerSignUpContract.TrainerSignUpPage import co.kr.tnt.trainer.signup.TrainerSignUpContract.TrainerSignUpUiEvent import co.kr.tnt.trainer.signup.TrainerSignUpContract.TrainerSignUpUiState import co.kr.tnt.ui.base.BaseViewModel +import co.kr.tnt.ui.util.toFile import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import javax.inject.Inject +import co.kr.tnt.core.ui.R as uiResource @HiltViewModel -internal class TrainerSignUpViewModel @Inject constructor() : - BaseViewModel( +internal class TrainerSignUpViewModel @Inject constructor( + private val signUpRepository: SignUpRepository, +) : BaseViewModel( TrainerSignUpUiState(), ) { - override suspend fun handleEvent(event: TrainerSignUpUiEvent) { - when (event) { - TrainerSignUpUiEvent.OnNextClick -> navigateToNext() - TrainerSignUpUiEvent.OnBackClick -> navigateToBack() - } + override suspend fun handleEvent(event: TrainerSignUpUiEvent) { + when (event) { + is TrainerSignUpUiEvent.OnImageChange -> setProfileImage(event.imageUri) + is TrainerSignUpUiEvent.OnNameChange -> setName(event.name) + TrainerSignUpUiEvent.OnNextClick -> navigateToNext() + TrainerSignUpUiEvent.OnBackClick -> navigateToBack() + is TrainerSignUpUiEvent.RequestSignUp -> signUp( + context = event.context, + imageUri = event.imageUri, + id = event.id, + email = event.email, + authType = event.authType, + ) } + } - private fun navigateToNext() { - val nextPage = when (currentState.page) { - TrainerSignUpPage.SignUpComplete -> { - sendEffect(TrainerSignUpEffect.NavigateToConnect) - return - } + private fun signUp( + context: Context, + imageUri: Uri?, + id: String, + email: String, + authType: String, + ) { + viewModelScope.launch { + val profileImagePart = imageUri?.toFile(context) - else -> TrainerSignUpPage.getNextPage(currentState.page) + runCatching { + signUpRepository.signUp( + profileImage = profileImagePart, + userType = UserType.Trainer( + id = id, + name = currentState.name, + image = currentState.image.toString(), + ), + socialId = id, + socialType = authType, + email = email, + ) + }.onSuccess { + sendEffect(TrainerSignUpEffect.NavigateToConnect) + }.onFailure { + // TODO 디자인 시스템 Toast 적용 + val message = context.getString(uiResource.string.error_server_request_failed) + sendEffect(TrainerSignUpEffect.ShowToast(message)) } - updateState { copy(page = nextPage) } } + } + + private fun setProfileImage(imageUri: Uri) { + updateState { copy(image = imageUri) } + } - private fun navigateToBack() { - val previousPage = when (currentState.page) { - TrainerSignUpPage.ProfileSetUp -> { - sendEffect(TrainerSignUpEffect.NavigateToBack) - return - } + private fun setName(name: String) { + updateState { copy(name = name) } + } - else -> TrainerSignUpPage.getPreviousPage(currentState.page) + private fun navigateToNext() { + val nextPage = TrainerSignUpPage.getNextPage(currentState.page) + updateState { copy(page = nextPage) } + } + + private fun navigateToBack() { + val previousPage = when (currentState.page) { + TrainerSignUpPage.ProfileSetUp -> { + sendEffect(TrainerSignUpEffect.NavigateToBack) + return } - updateState { copy(page = previousPage) } + + else -> TrainerSignUpPage.getPreviousPage(currentState.page) } + updateState { copy(page = previousPage) } } +} diff --git a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/navigation/TrainerSignUpNavigation.kt b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/navigation/TrainerSignUpNavigation.kt index c17b09b..06811d9 100644 --- a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/navigation/TrainerSignUpNavigation.kt +++ b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/navigation/TrainerSignUpNavigation.kt @@ -9,9 +9,16 @@ import co.kr.tnt.navigation.Route import co.kr.tnt.trainer.signup.TrainerSignUpRoute fun NavController.navigateToTrainerSignUp( + authId: String, + authType: String, + email: String, navOptions: NavOptionsBuilder.() -> Unit = {}, ) = navigate( - route = Route.TrainerSignUp, + route = Route.TrainerSignUp( + authId = authId, + authType = authType, + email = email, + ), builder = navOptions, ) @@ -21,8 +28,10 @@ fun NavGraphBuilder.trainerSignUpScreen( ) { composable { backstackEntry -> backstackEntry.toRoute().apply { - // TODO 115 머지되면 connect로 이동 TrainerSignUpRoute( + authId = authId, + authType = authType, + email = email, navigateToPrevious = navigateToPrevious, navigateToConnect = { navigateToConnect() }, )