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 aceff7d1..03a99829 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 @@ -96,6 +96,9 @@ sealed interface Route { @Serializable data object TraineeMyPage : Route + @Serializable + data object TraineeModifyMyInfo : Route + @Serializable data object TraineeNotification : Route diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 072adaac..e8b3858f 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -25,6 +25,7 @@ 잘못된 수치를 입력했어요 %s자 미만의 한글 또는 영문으로 입력해주세요 + %s자 미만으로 입력해주세요 트레이니 트레이너 @@ -67,6 +68,14 @@ %d회차 수업 + + 체중 감량 + 근력 향상 + 건강 관리 + 유연성 향상 + 바디프로필 + 자세 교정 + 개인정보 수정 앱 푸시 알림 @@ -81,6 +90,10 @@ 언제든지 다시 로그인 할 수 있어요! 로그아웃이 완료되었어요 + 계정을 탈퇴할까요? + 계정 탈퇴가 완료되었어요 + 다음에 더 폭발적인 케미로 다시 만나요! 💣 + %d자 미만으로 입력해주세요. 아직 등록된 기록이 없어요 "정보 수정을 종료할까요?" diff --git a/data/network/src/main/java/co/kr/data/network/model/UpdateUserInfoRequest.kt b/data/network/src/main/java/co/kr/data/network/model/UpdateUserInfoRequest.kt index 130dcdc5..5eaa6c10 100644 --- a/data/network/src/main/java/co/kr/data/network/model/UpdateUserInfoRequest.kt +++ b/data/network/src/main/java/co/kr/data/network/model/UpdateUserInfoRequest.kt @@ -8,7 +8,7 @@ data class UpdateUserInfoRequest( val removeImage: Boolean, val memberType: MemberType, val name: String, - val birthDay: String? = null, + val birthday: String? = null, val height: Double? = null, val weight: Double? = null, val cautionNote: String? = null, diff --git a/data/network/src/main/java/co/kr/data/network/source/TraineeRemoteDataSource.kt b/data/network/src/main/java/co/kr/data/network/source/TraineeRemoteDataSource.kt index 3f6db23d..05d6662b 100644 --- a/data/network/src/main/java/co/kr/data/network/source/TraineeRemoteDataSource.kt +++ b/data/network/src/main/java/co/kr/data/network/source/TraineeRemoteDataSource.kt @@ -34,4 +34,11 @@ class TraineeRemoteDataSource @Inject constructor( suspend fun getMealRecord(dietId: Long) = networkHandler { apiService.getMealRecord(dietId) } + + suspend fun putUserInfo( + profileImage: MultipartBody.Part?, + request: RequestBody, + ) = networkHandler { + apiService.putMyInfo(profileImage, request) + } } diff --git a/data/repository/src/main/java/co/kr/data/repository/TraineeRepositoryImpl.kt b/data/repository/src/main/java/co/kr/data/repository/TraineeRepositoryImpl.kt index 87a39421..1eccb7d0 100644 --- a/data/repository/src/main/java/co/kr/data/repository/TraineeRepositoryImpl.kt +++ b/data/repository/src/main/java/co/kr/data/repository/TraineeRepositoryImpl.kt @@ -1,21 +1,26 @@ package co.kr.data.repository +import co.kr.data.network.model.UpdateUserInfoRequest +import co.kr.data.network.model.enum.MemberType import co.kr.data.network.model.toDomain import co.kr.data.network.model.trainee.MealRecordRequest import co.kr.data.network.model.trainee.toDomain import co.kr.data.network.source.TraineeRemoteDataSource import co.kr.data.network.source.UserRemoteDataSource +import co.kr.tnt.domain.model.ProfileImageUpdatePolicy import co.kr.tnt.domain.model.User import co.kr.tnt.domain.model.trainee.TraineeDailyRecord import co.kr.tnt.domain.model.trainee.TraineeDailyRecordStatus import co.kr.tnt.domain.model.trainee.TraineeMealRecordDetail import co.kr.tnt.domain.repository.TraineeRepository import co.kr.tnt.domain.utils.DateFormatter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.onStart 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 @@ -30,10 +35,15 @@ internal class TraineeRepositoryImpl @Inject constructor( private val dateFormatter: DateFormatter, private val json: Json, ) : TraineeRepository { - override suspend fun getMyInfo(): User.Trainee { - val user = userRemoteDataSource.getMyInfo().toDomain(dateFormatter) - require(user is User.Trainee) - return user + private val cacheUserInfo = MutableStateFlow(User.Trainee.EMPTY) + + override suspend fun getMyInfo(): Flow { + return cacheUserInfo + .onStart { + if (cacheUserInfo.value == User.Trainee.EMPTY) { + cacheUserInfo.value = fetchUserInfo() + } + } } override suspend fun getWeeklyRecordedDate( @@ -67,7 +77,9 @@ internal class TraineeRepositoryImpl @Inject constructor( dietType = mealType, memo = memo, ) - val requestBody = mealRecordRequest.toRequestBody() + val requestBody = json + .encodeToString(mealRecordRequest) + .toRequestBody("application/json".toMediaTypeOrNull()) traineeRemoteDataSource.postMealRecord( dietImage = imagePart, @@ -75,11 +87,61 @@ internal class TraineeRepositoryImpl @Inject constructor( ) } - private fun MealRecordRequest.toRequestBody(): RequestBody { - val jsonString = json.encodeToString(this) - return jsonString.toRequestBody("application/json".toMediaTypeOrNull()) - } - override suspend fun getMealRecord(dietId: Long): TraineeMealRecordDetail = traineeRemoteDataSource.getMealRecord(dietId).toDomain(dateFormatter) + + override suspend fun updateUserInfo( + profileImageUpdatePolicy: ProfileImageUpdatePolicy, + userInfo: User.Trainee, + ) { + val (profileImage, isRemoveProfileImage) = when (profileImageUpdatePolicy) { + is ProfileImageUpdatePolicy.Change -> profileImageUpdatePolicy.newProfileImage to false + ProfileImageUpdatePolicy.Keep -> null to false + ProfileImageUpdatePolicy.Remove -> null to true + } + val imagePart = profileImage?.let { + val requestFile = it.asRequestBody("image/*".toMediaTypeOrNull()) + MultipartBody.Part.createFormData("profileImage", it.name, requestFile) + } + val selectedDate = userInfo.birthday?.let { dateFormatter.format(it, "yyyy-MM-dd") } + + val request = UpdateUserInfoRequest( + removeImage = isRemoveProfileImage, + memberType = MemberType.TRAINEE, + name = userInfo.name, + birthday = selectedDate, + height = userInfo.height?.toDouble(), + weight = userInfo.weight, + cautionNote = userInfo.caution, + ptGoals = userInfo.ptPurpose, + ) + val requestBody = json + .encodeToString(request) + .toRequestBody("application/json".toMediaTypeOrNull()) + + runCatching { + traineeRemoteDataSource.putUserInfo( + profileImage = imagePart, + request = requestBody, + ) + }.onSuccess { + refreshCachedUserInfo() + }.onFailure { failure -> + throw failure + } + } + + private suspend fun fetchUserInfo(): User.Trainee { + val user = userRemoteDataSource.getMyInfo().toDomain(dateFormatter) + require(user is User.Trainee) + return user + } + + override suspend fun refreshCachedUserInfo() { + cacheUserInfo.value = fetchUserInfo() + } + + override suspend fun clearCachedUserInfo() { + cacheUserInfo.value = User.Trainee.EMPTY + } } diff --git a/domain/src/main/java/co/kr/tnt/domain/Policy.kt b/domain/src/main/java/co/kr/tnt/domain/Policy.kt index 13b8bc98..17553b3f 100644 --- a/domain/src/main/java/co/kr/tnt/domain/Policy.kt +++ b/domain/src/main/java/co/kr/tnt/domain/Policy.kt @@ -7,6 +7,18 @@ object UserProfilePolicy { // TnT 에서 사용자가 입력할 수 있는 이름의 최대 길이는 15자이다. const val USER_NAME_MAX_LENGTH = 15 + // TnT 에서 사용자가 입력할 수 있는 키의 최대 길이는 3자이다. + const val USER_HEIGHT_MAX_LENGTH = 3 + + // TnT 에서 사용자가 입력할 수 있는 몸무게의 최대 길이는 5자이다. (000.0) + const val USER_WEIGHT_MAX_LENGTH = 5 + + // TnT 에서 사용자가 입력할 수 있는 주의사항의 최대 길이는 100자이다. + const val USER_CAUTION_MAX_LENGTH = 100 + // TnT 에서 사용자가 입력할 수 있는 이름은 한글, 영어, 공백만 허용한다. val USER_NAME_REGEX = Regex("^[a-zA-Zㄱ-ㅎㅏ-ㅣ가-힣 ]+\$") + + // TnT 에서 사용자가 입력할 수 있는 몸무게는 소수점 이하 한 자리까지만 허용한다. (000, 00, 00.0, 000.0) + val USER_WEIGHT_REGEX = Regex("^(\\d{1,3}(\\.\\d)?)?\$") } diff --git a/domain/src/main/java/co/kr/tnt/domain/model/PtPurpose.kt b/domain/src/main/java/co/kr/tnt/domain/model/PtPurpose.kt new file mode 100644 index 00000000..f66e3d2e --- /dev/null +++ b/domain/src/main/java/co/kr/tnt/domain/model/PtPurpose.kt @@ -0,0 +1,10 @@ +package co.kr.tnt.domain.model + +enum class PtPurpose { + LOSS_WEIGHT, + STRENGTH, + HEALTH_CARE, + FLEXIBILITY, + BODY_PROFILE, + POSTURE_CORRECTION, +} diff --git a/domain/src/main/java/co/kr/tnt/domain/repository/TraineeRepository.kt b/domain/src/main/java/co/kr/tnt/domain/repository/TraineeRepository.kt index 44693fea..478debb7 100644 --- a/domain/src/main/java/co/kr/tnt/domain/repository/TraineeRepository.kt +++ b/domain/src/main/java/co/kr/tnt/domain/repository/TraineeRepository.kt @@ -1,14 +1,16 @@ package co.kr.tnt.domain.repository +import co.kr.tnt.domain.model.ProfileImageUpdatePolicy import co.kr.tnt.domain.model.User import co.kr.tnt.domain.model.trainee.TraineeDailyRecord import co.kr.tnt.domain.model.trainee.TraineeDailyRecordStatus import co.kr.tnt.domain.model.trainee.TraineeMealRecordDetail +import kotlinx.coroutines.flow.Flow import java.io.File import java.time.LocalDate interface TraineeRepository { - suspend fun getMyInfo(): User.Trainee + suspend fun getMyInfo(): Flow suspend fun postMealRecord( mealImage: File?, date: String, @@ -23,4 +25,10 @@ interface TraineeRepository { suspend fun getMealRecord( dietId: Long, ): TraineeMealRecordDetail + suspend fun updateUserInfo( + profileImageUpdatePolicy: ProfileImageUpdatePolicy, + userInfo: User.Trainee, + ) + suspend fun refreshCachedUserInfo() + suspend fun clearCachedUserInfo() } 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 73cec5ae..87648c5b 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 @@ -3,6 +3,7 @@ package co.kr.tnt.trainee.connect import androidx.lifecycle.viewModelScope import co.kr.tnt.core.ui.R.string.core_failed_to_server_request import co.kr.tnt.domain.repository.ConnectRepository +import co.kr.tnt.domain.repository.TraineeRepository import co.kr.tnt.trainee.connect.TraineeConnectContract.TraineeConnectPage import co.kr.tnt.trainee.connect.TraineeConnectContract.TraineeConnectSideEffect import co.kr.tnt.trainee.connect.TraineeConnectContract.TraineeConnectUiEvent @@ -20,6 +21,7 @@ import javax.inject.Inject @HiltViewModel internal class TraineeConnectViewModel @Inject constructor( private val connectRepository: ConnectRepository, + private val traineeRepository: TraineeRepository, ) : BaseViewModel( TraineeConnectUiState(), @@ -100,6 +102,7 @@ internal class TraineeConnectViewModel @Inject constructor( traineeImage = result.traineeImage, ) } + refreshCachedUserInfo() navigateToNext() }.onFailure { sendEffect( @@ -113,6 +116,12 @@ internal class TraineeConnectViewModel @Inject constructor( } } + private fun refreshCachedUserInfo() { + viewModelScope.launch { + traineeRepository.refreshCachedUserInfo() + } + } + private fun navigateToBack() { if (currentState.page == TraineeConnectPage.firstPage) { handleDialogState() diff --git a/feature/trainee/home/src/main/java/co/kr/tnt/trainee/home/TraineeHomeScreen.kt b/feature/trainee/home/src/main/java/co/kr/tnt/trainee/home/TraineeHomeScreen.kt index 43b24b35..abf20783 100644 --- a/feature/trainee/home/src/main/java/co/kr/tnt/trainee/home/TraineeHomeScreen.kt +++ b/feature/trainee/home/src/main/java/co/kr/tnt/trainee/home/TraineeHomeScreen.kt @@ -73,6 +73,7 @@ import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import com.kizitonwose.calendar.compose.weekcalendar.WeekCalendarState import com.kizitonwose.calendar.compose.weekcalendar.rememberWeekCalendarState +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.time.DayOfWeek import java.time.LocalDate @@ -145,7 +146,7 @@ internal fun TraineeHomeRoute( } LaunchedEffect(viewModel.effect) { - viewModel.effect.collect { effect -> + viewModel.effect.collectLatest { effect -> when (effect) { TraineeHomeEffect.NavigateToExerciseRecord -> { showBottomSheet = false diff --git a/feature/trainee/home/src/main/java/co/kr/tnt/trainee/home/TraineeHomeViewModel.kt b/feature/trainee/home/src/main/java/co/kr/tnt/trainee/home/TraineeHomeViewModel.kt index 08a082ab..2c7c2458 100644 --- a/feature/trainee/home/src/main/java/co/kr/tnt/trainee/home/TraineeHomeViewModel.kt +++ b/feature/trainee/home/src/main/java/co/kr/tnt/trainee/home/TraineeHomeViewModel.kt @@ -12,7 +12,10 @@ import co.kr.tnt.ui.base.BaseViewModel import co.kr.tnt.ui.resource.DisplayText import com.kizitonwose.calendar.core.yearMonth import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import java.time.Duration import java.time.LocalDate @@ -161,28 +164,32 @@ internal class TraineeHomeViewModel @Inject constructor( cachedMonthlyRecordState.clear() handleChangeVisibleMonth(currentState.selectedDay.yearMonth) selectDay(currentState.selectedDay) - showConnectDialog() + getUserInfo() } - private fun showConnectDialog() { - val currentDateTime = LocalDateTime.now() - + private fun getUserInfo() { viewModelScope.launch { - runCatching { - traineeRepository.getMyInfo() - }.onSuccess { result -> - updateState { copy(isConnected = result.isConnected) } - if (result.isConnected) { - return@launch + traineeRepository.getMyInfo() + .onEach { user -> + updateState { copy(isConnected = user.isConnected) } + if (user.isConnected.not()) { + showConnectDialog() + } } - }.onFailure { - sendEffect( - TraineeHomeEffect.ShowToast( - DisplayText.Resource(core_failed_to_server_request), - ), - ) - } + .catch { + sendEffect( + TraineeHomeEffect.ShowToast( + DisplayText.Resource(core_failed_to_server_request), + ), + ) + } + .launchIn(viewModelScope) + } + } + private fun showConnectDialog() { + val currentDateTime = LocalDateTime.now() + viewModelScope.launch { val lastHiddenDate = connectRepository.getExplicitDeniedConnectDate().firstOrNull() val isHidden = lastHiddenDate != null && Duration.between(lastHiddenDate, currentDateTime).toHours() < DIALOG_HIDE_DURATION_HOURS diff --git a/feature/trainee/main/build.gradle.kts b/feature/trainee/main/build.gradle.kts index 83f2de46..8e438b33 100644 --- a/feature/trainee/main/build.gradle.kts +++ b/feature/trainee/main/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(projects.feature.trainee.notification) implementation(projects.feature.trainee.mealrecord) implementation(projects.feature.trainee.mealdetail) + implementation(projects.feature.trainee.modifymyinfo) implementation(libs.kotlinx.immutable) } diff --git a/feature/trainee/main/src/main/java/co/kr/tnt/trainee/main/TraineeMainScreen.kt b/feature/trainee/main/src/main/java/co/kr/tnt/trainee/main/TraineeMainScreen.kt index f2cc394e..de771310 100644 --- a/feature/trainee/main/src/main/java/co/kr/tnt/trainee/main/TraineeMainScreen.kt +++ b/feature/trainee/main/src/main/java/co/kr/tnt/trainee/main/TraineeMainScreen.kt @@ -15,6 +15,8 @@ import co.kr.tnt.trainee.mealdetail.navigation.navigateToTraineeMealDetail import co.kr.tnt.trainee.mealdetail.navigation.traineeMealDetail import co.kr.tnt.trainee.mealrecord.navigation.navigateToTraineeMealRecord import co.kr.tnt.trainee.mealrecord.navigation.traineeMealRecord +import co.kr.tnt.trainee.modifymyinfo.navigation.navigateToTraineeModifyMyInfo +import co.kr.tnt.trainee.modifymyinfo.navigation.traineeModifyMyInfo import co.kr.tnt.trainee.mypage.navigation.traineeMyPageNavGraph import co.kr.tnt.trainee.notification.navigation.navigateToTraineeNotification import co.kr.tnt.trainee.notification.navigation.traineeNotification @@ -49,7 +51,8 @@ private fun TraineeMainScreen( val navController = state.navController Scaffold( - containerColor = state.currentMainTab?.containerColor?.invoke() ?: TnTTheme.colors.commonColors.Common0, + containerColor = state.currentMainTab?.containerColor?.invoke() + ?: TnTTheme.colors.commonColors.Common0, modifier = Modifier.fillMaxSize(), bottomBar = { TnTBottomBar( @@ -86,8 +89,13 @@ private fun TraineeMainScreen( padding = innerPadding, navigateToLogin = navigateToLogin, navigateToWebView = navigateToWebView, + navigateToModifyMyInfo = navController::navigateToTraineeModifyMyInfo, navigateToTraineeConnect = navigateToConnect, - ) + ) { + traineeModifyMyInfo( + navigateToPrevious = navController::safePopBackStack, + ) + } } } } diff --git a/feature/trainee/modifymyinfo/.gitignore b/feature/trainee/modifymyinfo/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/trainee/modifymyinfo/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/trainee/modifymyinfo/build.gradle.kts b/feature/trainee/modifymyinfo/build.gradle.kts new file mode 100644 index 00000000..91a86101 --- /dev/null +++ b/feature/trainee/modifymyinfo/build.gradle.kts @@ -0,0 +1,13 @@ +import co.kr.tnt.setNamespace + +plugins { + id("tnt.android.feature") +} + +android { + setNamespace("feature.trainee.modifymyinfo") +} + +dependencies { + implementation(libs.kotlinx.immutable) +} diff --git a/feature/trainee/modifymyinfo/src/main/AndroidManifest.xml b/feature/trainee/modifymyinfo/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/trainee/modifymyinfo/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/trainee/modifymyinfo/src/main/java/co/kr/tnt/trainee/modifymyinfo/TraineeModifyMyInfoContract.kt b/feature/trainee/modifymyinfo/src/main/java/co/kr/tnt/trainee/modifymyinfo/TraineeModifyMyInfoContract.kt new file mode 100644 index 00000000..b8d22a30 --- /dev/null +++ b/feature/trainee/modifymyinfo/src/main/java/co/kr/tnt/trainee/modifymyinfo/TraineeModifyMyInfoContract.kt @@ -0,0 +1,73 @@ +package co.kr.tnt.trainee.modifymyinfo + +import co.kr.tnt.domain.UserProfilePolicy +import co.kr.tnt.ui.base.UiEvent +import co.kr.tnt.ui.base.UiSideEffect +import co.kr.tnt.ui.base.UiState +import co.kr.tnt.ui.resource.DisplayText +import java.io.File +import java.time.LocalDate + +internal class TraineeModifyMyInfoContract { + data class TraineeModifyMyInfoUiState( + val profileImage: String? = null, + val name: String = "", + val birthday: LocalDate? = null, + val height: String? = null, + val weight: String? = null, + val ptPurpose: List? = emptyList(), + val caution: String? = "", + val dialogState: DialogState = DialogState.NONE, + val isEnableComplete: Boolean = false, + val isLoading: Boolean = false, + ) : UiState { + val isNameValid + get() = name.isNotBlank() && + name.matches(UserProfilePolicy.USER_NAME_REGEX) && + name.length <= UserProfilePolicy.USER_NAME_MAX_LENGTH + + val isHeightValid + get() = height.isNullOrBlank() || ( + height.toIntOrNull() != null && + height.startsWith("0").not() && + height.length <= UserProfilePolicy.USER_HEIGHT_MAX_LENGTH + ) + + val isWeightValid + get() = weight.isNullOrBlank() || ( + weight.matches(UserProfilePolicy.USER_WEIGHT_REGEX) && + weight.startsWith("0").not() && + weight.length <= UserProfilePolicy.USER_WEIGHT_MAX_LENGTH + ) + + val isCautionNoteValid + get() = caution.isNullOrBlank() || ( + caution.length < UserProfilePolicy.USER_CAUTION_MAX_LENGTH + ) + + enum class DialogState { + NONE, + CONFIRM_EXIT, + } + } + + sealed interface TraineeModifyMyInfoUiEvent : UiEvent { + data object OnDeleteProfileImage : TraineeModifyMyInfoUiEvent + data class OnProfileImageSelect(val image: File) : TraineeModifyMyInfoUiEvent + data class OnChangeName(val name: String) : TraineeModifyMyInfoUiEvent + data class OnChangeHeight(val height: String) : TraineeModifyMyInfoUiEvent + data class OnChangeWeight(val weight: String) : TraineeModifyMyInfoUiEvent + data class OnChangeBirthday(val birthday: LocalDate) : TraineeModifyMyInfoUiEvent + data class OnSelectPurpose(val purpose: String) : TraineeModifyMyInfoUiEvent + data class OnChangeCaution(val text: String) : TraineeModifyMyInfoUiEvent + data object OnDismissDialog : TraineeModifyMyInfoUiEvent + data object OnClickDialogConfirm : TraineeModifyMyInfoUiEvent + data object OnClickBack : TraineeModifyMyInfoUiEvent + data object OnClickComplete : TraineeModifyMyInfoUiEvent + } + + sealed interface TraineeModifyMyInfoEffect : UiSideEffect { + data class ShowToast(val message: DisplayText) : TraineeModifyMyInfoEffect + data object NavigateToBack : TraineeModifyMyInfoEffect + } +} diff --git a/feature/trainee/modifymyinfo/src/main/java/co/kr/tnt/trainee/modifymyinfo/TraineeModifyMyInfoScreen.kt b/feature/trainee/modifymyinfo/src/main/java/co/kr/tnt/trainee/modifymyinfo/TraineeModifyMyInfoScreen.kt new file mode 100644 index 00000000..16761246 --- /dev/null +++ b/feature/trainee/modifymyinfo/src/main/java/co/kr/tnt/trainee/modifymyinfo/TraineeModifyMyInfoScreen.kt @@ -0,0 +1,501 @@ +package co.kr.tnt.trainee.modifymyinfo + +import android.app.DatePickerDialog +import android.content.Context +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import co.kr.tnt.core.designsystem.R.string.placeholder_content_input +import co.kr.tnt.core.ui.R.string.core_complete +import co.kr.tnt.core.ui.R.string.core_confirm_modify_info_exit +import co.kr.tnt.core.ui.R.string.core_entered_wrong_text +import co.kr.tnt.core.ui.R.string.core_height_label +import co.kr.tnt.core.ui.R.string.core_height_unit +import co.kr.tnt.core.ui.R.string.core_name +import co.kr.tnt.core.ui.R.string.core_name_placeholder +import co.kr.tnt.core.ui.R.string.core_text_length_and_format_warning +import co.kr.tnt.core.ui.R.string.core_text_length_warning +import co.kr.tnt.core.ui.R.string.core_unsaved_changes_warning +import co.kr.tnt.core.ui.R.string.core_weight_label +import co.kr.tnt.core.ui.R.string.core_weight_unit +import co.kr.tnt.designsystem.component.TnTIconPopupDialog +import co.kr.tnt.designsystem.component.TnTModalBottomSheet +import co.kr.tnt.designsystem.component.TnTProfileImage +import co.kr.tnt.designsystem.component.TnTTopBarWithBackButton +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.component.textfield.TnTLabeledTextField +import co.kr.tnt.designsystem.component.textfield.TnTSelectableLabeledTextField +import co.kr.tnt.designsystem.component.textfield.model.TnTTextFieldSize +import co.kr.tnt.designsystem.snackbar.LocalSnackbar +import co.kr.tnt.designsystem.theme.TnTTheme +import co.kr.tnt.domain.UserProfilePolicy +import co.kr.tnt.feature.trainee.modifymyinfo.R +import co.kr.tnt.trainee.modifymyinfo.TraineeModifyMyInfoContract.TraineeModifyMyInfoEffect +import co.kr.tnt.trainee.modifymyinfo.TraineeModifyMyInfoContract.TraineeModifyMyInfoUiEvent +import co.kr.tnt.trainee.modifymyinfo.TraineeModifyMyInfoContract.TraineeModifyMyInfoUiState +import co.kr.tnt.trainee.modifymyinfo.TraineeModifyMyInfoContract.TraineeModifyMyInfoUiState.DialogState +import co.kr.tnt.trainee.modifymyinfo.model.TraineePtPurpose +import co.kr.tnt.ui.component.TnTLoadingScreen +import co.kr.tnt.ui.extensions.clearFocusOnTap +import co.kr.tnt.ui.model.DefaultUserProfile +import co.kr.tnt.ui.utils.convertToAllowedImageFormat +import co.kr.tnt.ui.utils.throttled +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private const val ROW_NUM = 3 +private const val COLUMNS_NUM = 2 +private val DEFAULT_DATE = LocalDate.of(2000, 1, 1) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TraineeModifyMyInfoRoute( + viewModel: TraineeModifyMyInfoViewModel = hiltViewModel(), + navigateToPrevious: () -> Unit, +) { + val context = LocalContext.current + val state by viewModel.uiState.collectAsStateWithLifecycle() + val coroutineScope = rememberCoroutineScope() + val snackbar = LocalSnackbar.current + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showBottomSheet by rememberSaveable { mutableStateOf(false) } + + TraineeModifyMyInfoScreen( + state = state, + onClickEditImage = { showBottomSheet = true }, + onChangeName = { name -> viewModel.setEvent(TraineeModifyMyInfoUiEvent.OnChangeName(name)) }, + onChangeBirthday = { birthday -> + viewModel.setEvent(TraineeModifyMyInfoUiEvent.OnChangeBirthday(birthday)) + }, + onChangeHeight = { height -> + viewModel.setEvent(TraineeModifyMyInfoUiEvent.OnChangeHeight(height)) + }, + onChangeWeight = { weight -> + viewModel.setEvent(TraineeModifyMyInfoUiEvent.OnChangeWeight(weight)) + }, + onChangeCaution = { caution -> + viewModel.setEvent(TraineeModifyMyInfoUiEvent.OnChangeCaution(caution)) + }, + onSelectPurpose = { purpose -> + viewModel.setEvent(TraineeModifyMyInfoUiEvent.OnSelectPurpose(purpose)) + }, + onClickBack = { viewModel.setEvent(TraineeModifyMyInfoUiEvent.OnClickBack) }, + onClickComplete = { viewModel.setEvent(TraineeModifyMyInfoUiEvent.OnClickComplete) }, + ) + + if (showBottomSheet) { + TnTModalBottomSheet( + sheetState = sheetState, + onDismissRequest = { + showBottomSheet = false + }, + content = { + EditImageBottomSheetContent( + onClickDelete = { + viewModel.setEvent(TraineeModifyMyInfoUiEvent.OnDeleteProfileImage) + showBottomSheet = false + }, + onClickAlbum = { uri -> + coroutineScope.launch { + val profileImageFile = uri.convertToAllowedImageFormat(context) + viewModel.setEvent(TraineeModifyMyInfoUiEvent.OnProfileImageSelect(profileImageFile)) + showBottomSheet = false + } + }, + ) + }, + ) + } + + when (state.dialogState) { + DialogState.NONE -> Unit + DialogState.CONFIRM_EXIT -> { + TnTIconPopupDialog( + title = stringResource(core_confirm_modify_info_exit), + content = stringResource(core_unsaved_changes_warning), + leftButtonText = stringResource(R.string.end), + rightButtonText = stringResource(R.string.keep_edit), + onLeftButtonClick = { viewModel.setEvent(TraineeModifyMyInfoUiEvent.OnClickDialogConfirm) }, + onRightButtonClick = { viewModel.setEvent(TraineeModifyMyInfoUiEvent.OnDismissDialog) }, + onDismiss = { viewModel.setEvent(TraineeModifyMyInfoUiEvent.OnDismissDialog) }, + ) + } + } + + LaunchedEffect(viewModel.effect) { + viewModel.effect.collectLatest { effect -> + when (effect) { + TraineeModifyMyInfoEffect.NavigateToBack -> navigateToPrevious() + is TraineeModifyMyInfoEffect.ShowToast -> snackbar.show(effect.message.asString(context)) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TraineeModifyMyInfoScreen( + state: TraineeModifyMyInfoUiState, + onClickEditImage: () -> Unit, + onChangeName: (name: String) -> Unit, + onChangeBirthday: (birthday: LocalDate) -> Unit, + onChangeHeight: (height: String) -> Unit, + onChangeWeight: (weight: String) -> Unit, + onSelectPurpose: (purpose: String) -> Unit, + onChangeCaution: (caution: String) -> Unit, + onClickBack: () -> Unit, + onClickComplete: () -> Unit, +) { + BackHandler { onClickBack() } + + val context = LocalContext.current + val today = LocalDate.now() + + val painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(state.profileImage) + .placeholder(DefaultUserProfile.Trainee.image) + .error(DefaultUserProfile.Trainee.image) + .build(), + ) + + Scaffold( + topBar = { + TnTTopBarWithBackButton( + title = stringResource(R.string.modifying_my_info), + onBackClick = onClickBack, + ) + }, + bottomBar = { + TnTBottomButton( + text = stringResource(core_complete), + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding(), + enabled = state.isEnableComplete, + onClick = throttled { onClickComplete() }, + ) + }, + containerColor = TnTTheme.colors.commonColors.Common0, + modifier = Modifier.clearFocusOnTap(), + ) { padding -> + Box(modifier = Modifier.padding(padding)) { + Column( + modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(padding) + .imePadding() + .background(TnTTheme.colors.commonColors.Common0) + .verticalScroll(rememberScrollState()), + ) { + TnTProfileImage( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + defaultImage = painterResource(DefaultUserProfile.Trainee.image), + image = painter, + onEditClick = { onClickEditImage() }, + ) + Spacer(Modifier.padding(top = 32.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(48.dp), + modifier = Modifier.fillMaxWidth(), + ) { + TnTLabeledTextField( + title = stringResource(core_name), + value = state.name, + onValueChange = onChangeName, + modifier = Modifier.padding(horizontal = 20.dp), + placeholder = stringResource(core_name_placeholder), + size = TnTTextFieldSize.SMALL, + isWarning = state.isNameValid.not(), + warningMessage = stringResource( + core_text_length_and_format_warning, + UserProfilePolicy.USER_NAME_MAX_LENGTH, + ), + maxLength = UserProfilePolicy.USER_NAME_MAX_LENGTH, + showRequiredTitleBadge = true, + ) + BirthdayPicker( + state = state, + context = context, + today = today, + onChangeBirthday = onChangeBirthday, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + TnTLabeledTextField( + title = stringResource(core_height_label), + value = state.height ?: "", + placeholder = "0", + isWarning = state.isHeightValid.not(), + warningMessage = stringResource(core_entered_wrong_text), + keyboardType = KeyboardType.Number, + trailing = { + UnitLabel( + modifier = Modifier.align(Alignment.CenterVertically), + stringResId = core_height_unit, + ) + }, + onValueChange = onChangeHeight, + modifier = Modifier.weight(1f), + ) + TnTLabeledTextField( + title = stringResource(core_weight_label), + value = state.weight ?: "", + placeholder = "00.0", + isWarning = state.isWeightValid.not(), + warningMessage = stringResource(core_entered_wrong_text), + keyboardType = KeyboardType.Number, + trailing = { + UnitLabel( + modifier = Modifier.align(Alignment.CenterVertically), + stringResId = core_weight_unit, + ) + }, + onValueChange = onChangeWeight, + modifier = Modifier.weight(1f), + ) + } + Column(Modifier.padding(horizontal = 20.dp)) { + Text( + text = "PT 목적", + style = TnTTheme.typography.body1Bold, + color = TnTTheme.colors.neutralColors.Neutral900, + ) + Spacer(Modifier.padding(top = 12.dp)) + Column { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + maxItemsInEachRow = COLUMNS_NUM, + maxLines = ROW_NUM, + modifier = Modifier.fillMaxWidth(), + ) { + TraineePtPurpose.entries.forEach { purpose -> + val purposeText = stringResource(purpose.textResId) + PurposeButton( + text = purposeText, + isSelected = state.ptPurpose?.contains(purposeText) == true, + onClick = { onSelectPurpose(purposeText) }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + TnTLabeledTextField( + title = stringResource(R.string.edit_caution_that_trainer_must_know), + value = state.caution ?: "", + onValueChange = onChangeCaution, + modifier = Modifier.padding(horizontal = 20.dp), + size = TnTTextFieldSize.LARGE, + placeholder = stringResource(placeholder_content_input), + isWarning = state.isCautionNoteValid.not(), + warningMessage = stringResource( + core_text_length_warning, + UserProfilePolicy.USER_CAUTION_MAX_LENGTH, + ), + maxLength = UserProfilePolicy.USER_CAUTION_MAX_LENGTH, + ) + } + Spacer(Modifier.padding(top = 32.dp)) + } + } + } + + if (state.isLoading) { + TnTLoadingScreen() + } +} + +@Composable +private fun EditImageBottomSheetContent( + onClickDelete: () -> Unit, + onClickAlbum: (uri: Uri) -> Unit, +) { + val pickMediaLauncher = rememberLauncherForActivityResult(PickVisualMedia()) { uri -> + uri?.let(onClickAlbum) + } + + Column( + modifier = Modifier.padding(horizontal = 20.dp), + ) { + Text( + text = stringResource(R.string.delete_image), + modifier = Modifier + .padding(vertical = 4.dp) + .clickable(onClick = onClickDelete), + style = TnTTheme.typography.h4, + color = TnTTheme.colors.neutralColors.Neutral600, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.select_image_from_album), + modifier = Modifier + .padding(vertical = 4.dp) + .clickable( + onClick = { + pickMediaLauncher.launch( + PickVisualMediaRequest( + mediaType = PickVisualMedia.ImageOnly, + ), + ) + }, + ), + style = TnTTheme.typography.h4, + color = TnTTheme.colors.neutralColors.Neutral600, + ) + Spacer(Modifier.height(54.dp)) + } +} + +@Composable +private fun BirthdayPicker( + state: TraineeModifyMyInfoUiState, + context: Context, + today: LocalDate, + onChangeBirthday: (birthday: LocalDate) -> Unit, +) { + TnTSelectableLabeledTextField( + modifier = Modifier.padding(horizontal = 20.dp), + value = state.birthday?.format(DateTimeFormatter.ofPattern("yyyy/MM/dd")) ?: "", + placeholder = stringResource(R.string.birthday_placeholder), + onClickTextField = { + DatePickerDialog( + context, + { _, selectedYear, selectedMonth, selectedDay -> + val newDate = LocalDate.of(selectedYear, selectedMonth + 1, selectedDay) + onChangeBirthday(newDate) + }, + state.birthday?.year ?: DEFAULT_DATE.year, + (state.birthday?.monthValue?.minus(1)) ?: (DEFAULT_DATE.monthValue - 1), + state.birthday?.dayOfMonth ?: DEFAULT_DATE.dayOfMonth, + ) + .apply { + // 오늘 이후는 선택 불가능 + datePicker.maxDate = + today + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + } + .show() + }, + title = stringResource(R.string.birthday_label), + size = TnTTextFieldSize.SMALL, + ) +} + +@Composable +private fun UnitLabel( + modifier: Modifier = Modifier, + stringResId: Int, +) { + Text( + modifier = modifier.padding(end = 12.dp), + text = stringResource(stringResId), + style = TnTTheme.typography.body1Medium, + color = TnTTheme.colors.neutralColors.Neutral400, + ) +} + +@Composable +fun PurposeButton( + text: String, + isSelected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + TnTTextButton( + text = text, + modifier = modifier, + size = ButtonSize.XLarge, + type = if (isSelected) ButtonType.RedOutline else ButtonType.GrayOutline, + onClick = onClick, + ) +} + +@Preview(heightDp = 1000) +@Composable +private fun TraineeModifyMyScreenPreview() { + TnTTheme { + TraineeModifyMyInfoScreen( + state = TraineeModifyMyInfoUiState(name = "김회원"), + onClickEditImage = { }, + onChangeName = { }, + onChangeBirthday = { }, + onChangeHeight = { }, + onChangeWeight = { }, + onSelectPurpose = { }, + onChangeCaution = { }, + onClickBack = { }, + onClickComplete = { }, + ) + } +} + +@Preview +@Composable +private fun ModifyMyInfoBottomSheetContentPreview() { + TnTTheme { + EditImageBottomSheetContent( + onClickDelete = { }, + onClickAlbum = { }, + ) + } +} diff --git a/feature/trainee/modifymyinfo/src/main/java/co/kr/tnt/trainee/modifymyinfo/TraineeModifyMyInfoViewModel.kt b/feature/trainee/modifymyinfo/src/main/java/co/kr/tnt/trainee/modifymyinfo/TraineeModifyMyInfoViewModel.kt new file mode 100644 index 00000000..c19fd7cc --- /dev/null +++ b/feature/trainee/modifymyinfo/src/main/java/co/kr/tnt/trainee/modifymyinfo/TraineeModifyMyInfoViewModel.kt @@ -0,0 +1,227 @@ +package co.kr.tnt.trainee.modifymyinfo + +import androidx.lifecycle.viewModelScope +import co.kr.tnt.core.ui.R.string.core_failed_to_server_request +import co.kr.tnt.domain.model.ProfileImageUpdatePolicy +import co.kr.tnt.domain.model.User +import co.kr.tnt.domain.repository.TraineeRepository +import co.kr.tnt.trainee.modifymyinfo.TraineeModifyMyInfoContract.TraineeModifyMyInfoEffect +import co.kr.tnt.trainee.modifymyinfo.TraineeModifyMyInfoContract.TraineeModifyMyInfoUiEvent +import co.kr.tnt.trainee.modifymyinfo.TraineeModifyMyInfoContract.TraineeModifyMyInfoUiState +import co.kr.tnt.trainee.modifymyinfo.TraineeModifyMyInfoContract.TraineeModifyMyInfoUiState.DialogState +import co.kr.tnt.ui.base.BaseViewModel +import co.kr.tnt.ui.resource.DisplayText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.io.File +import java.time.LocalDate +import javax.inject.Inject + +@HiltViewModel +internal class TraineeModifyMyInfoViewModel @Inject constructor( + private val traineeRepository: TraineeRepository, +) : + BaseViewModel( + TraineeModifyMyInfoUiState(), + ) { + private var initializedInfo: User.Trainee? = null + private var profileImageUpdatePolicy: ProfileImageUpdatePolicy = ProfileImageUpdatePolicy.Keep + + init { + loadUserInfo() + } + + override suspend fun handleEvent(event: TraineeModifyMyInfoUiEvent) { + when (event) { + TraineeModifyMyInfoUiEvent.OnDeleteProfileImage -> { + profileImageUpdatePolicy = ProfileImageUpdatePolicy.Remove + deleteProfileImage() + } + + is TraineeModifyMyInfoUiEvent.OnProfileImageSelect -> { + profileImageUpdatePolicy = ProfileImageUpdatePolicy.Change(File(event.image.path)) + updateProfileImage(event.image.path) + } + + is TraineeModifyMyInfoUiEvent.OnChangeName -> updateName(event.name) + is TraineeModifyMyInfoUiEvent.OnChangeBirthday -> updateBirthday(event.birthday) + is TraineeModifyMyInfoUiEvent.OnChangeHeight -> updateHeight(event.height) + is TraineeModifyMyInfoUiEvent.OnChangeWeight -> updateWeight(event.weight) + is TraineeModifyMyInfoUiEvent.OnSelectPurpose -> updateSelectedPurposes(event.purpose) + is TraineeModifyMyInfoUiEvent.OnChangeCaution -> updateCaution(event.text) + TraineeModifyMyInfoUiEvent.OnClickDialogConfirm -> { + updateState { copy(dialogState = DialogState.NONE) } + sendEffect(TraineeModifyMyInfoEffect.NavigateToBack) + } + + TraineeModifyMyInfoUiEvent.OnDismissDialog -> updateState { copy(dialogState = DialogState.NONE) } + TraineeModifyMyInfoUiEvent.OnClickBack -> navigateToBack() + TraineeModifyMyInfoUiEvent.OnClickComplete -> updateUserInfo() + } + } + + private fun loadUserInfo() { + viewModelScope.launch { + runCatching { + traineeRepository.getMyInfo().first() + }.onSuccess { user -> + initializedInfo = user + + updateState { + copy( + profileImage = user.image, + name = user.name, + birthday = user.birthday, + height = user.height?.toString(), + weight = user.weight?.toString(), + ptPurpose = user.ptPurpose, + caution = user.caution, + ) + } + }.onFailure { + sendEffect( + TraineeModifyMyInfoEffect.ShowToast( + DisplayText.Resource(core_failed_to_server_request), + ), + ) + } + } + } + + private fun updateProfileImage(image: String) { + updateState { copy(profileImage = image) } + isEnableModifyInfo(initializedInfo) + } + + private fun deleteProfileImage() { + updateState { copy(profileImage = null) } + isEnableModifyInfo(initializedInfo) + } + + private fun updateName(name: String) { + updateState { copy(name = name) } + isEnableModifyInfo(initializedInfo) + } + + private fun updateBirthday(birthday: LocalDate) { + updateState { copy(birthday = birthday) } + isEnableModifyInfo(initializedInfo) + } + + private fun updateHeight(height: String) { + updateState { copy(height = height) } + isEnableModifyInfo(initializedInfo) + } + + private fun updateWeight(weight: String) { + updateState { copy(weight = weight) } + isEnableModifyInfo(initializedInfo) + } + + private fun updateSelectedPurposes(purpose: String) { + val updatedPurposes = currentState.ptPurpose.orEmpty().toMutableList().apply { + if (contains(purpose)) { + remove(purpose) + } else { + add(purpose) + } + } + updateState { copy(ptPurpose = updatedPurposes) } + isEnableModifyInfo(initializedInfo) + } + + private fun updateCaution(caution: String) { + updateState { copy(caution = caution) } + isEnableModifyInfo(initializedInfo) + } + + private fun updateUserInfo() { + viewModelScope.launch { + updateState { copy(isLoading = true) } + val userInfo = User.Trainee.EMPTY + runCatching { + traineeRepository.updateUserInfo( + profileImageUpdatePolicy = profileImageUpdatePolicy, + userInfo = userInfo.copy( + name = currentState.name, + image = currentState.profileImage, + birthday = currentState.birthday, + weight = currentState.weight?.toDoubleOrNull(), + height = currentState.height?.toIntOrNull(), + ptPurpose = currentState.ptPurpose, + caution = currentState.caution, + ), + ) + }.onSuccess { + sendEffect(TraineeModifyMyInfoEffect.NavigateToBack) + }.onFailure { + sendEffect( + TraineeModifyMyInfoEffect.ShowToast( + DisplayText.Resource(core_failed_to_server_request), + ), + ) + }.also { + updateState { copy(isLoading = false) } + } + } + } + + private fun navigateToBack() { + if ( + isUpdateInfo( + initializedInfo = initializedInfo, + name = currentState.name, + image = currentState.profileImage, + birthday = currentState.birthday, + height = currentState.height?.toIntOrNull(), + weight = currentState.weight?.toDoubleOrNull(), + ptPurpose = currentState.ptPurpose, + caution = currentState.caution, + ) + ) { + updateState { copy(dialogState = DialogState.CONFIRM_EXIT) } + return + } + + sendEffect(TraineeModifyMyInfoEffect.NavigateToBack) + } + + private fun isEnableModifyInfo(initializedInfo: User.Trainee?) { + val isEnable = isUpdateInfo( + initializedInfo, + currentState.name, + currentState.profileImage, + currentState.birthday, + currentState.height?.toIntOrNull(), + currentState.weight?.toDoubleOrNull(), + currentState.ptPurpose, + currentState.caution, + ) && currentState.isNameValid && + currentState.isWeightValid && + currentState.isWeightValid && + currentState.isCautionNoteValid + + updateState { copy(isEnableComplete = isEnable) } + } + + private fun isUpdateInfo( + initializedInfo: User.Trainee?, + name: String, + image: String?, + birthday: LocalDate?, + height: Int?, + weight: Double?, + ptPurpose: List?, + caution: String?, + ): Boolean = + initializedInfo?.let { + it.name != name || + it.image != image || + it.birthday != birthday || + it.height != height || + it.weight != weight || + it.ptPurpose?.toSet() != ptPurpose?.toSet() || + it.caution != caution + } ?: false + } diff --git a/feature/trainee/modifymyinfo/src/main/java/co/kr/tnt/trainee/modifymyinfo/model/TraineePtPurpose.kt b/feature/trainee/modifymyinfo/src/main/java/co/kr/tnt/trainee/modifymyinfo/model/TraineePtPurpose.kt new file mode 100644 index 00000000..ab10eb49 --- /dev/null +++ b/feature/trainee/modifymyinfo/src/main/java/co/kr/tnt/trainee/modifymyinfo/model/TraineePtPurpose.kt @@ -0,0 +1,35 @@ +package co.kr.tnt.trainee.modifymyinfo.model + +import androidx.annotation.StringRes +import co.kr.tnt.core.ui.R.string.core_body_profile +import co.kr.tnt.core.ui.R.string.core_flexibility +import co.kr.tnt.core.ui.R.string.core_health_care +import co.kr.tnt.core.ui.R.string.core_loss_weight +import co.kr.tnt.core.ui.R.string.core_posture_correction +import co.kr.tnt.core.ui.R.string.core_strength_improvement +import co.kr.tnt.domain.model.PtPurpose + +enum class TraineePtPurpose( + @StringRes val textResId: Int, +) { + LOSS_WEIGHT(core_loss_weight), + STRENGTH(core_strength_improvement), + HEALTH_CARE(core_health_care), + FLEXIBILITY(core_flexibility), + BODY_PROFILE(core_body_profile), + POSTURE_CORRECTION(core_posture_correction), + ; + + companion object { + fun from(purpose: PtPurpose): TraineePtPurpose { + return when (purpose) { + PtPurpose.LOSS_WEIGHT -> LOSS_WEIGHT + PtPurpose.STRENGTH -> STRENGTH + PtPurpose.HEALTH_CARE -> HEALTH_CARE + PtPurpose.FLEXIBILITY -> FLEXIBILITY + PtPurpose.BODY_PROFILE -> BODY_PROFILE + PtPurpose.POSTURE_CORRECTION -> POSTURE_CORRECTION + } + } + } +} diff --git a/feature/trainee/modifymyinfo/src/main/java/co/kr/tnt/trainee/modifymyinfo/navigation/TraineeModifyMyInfoNavigation.kt b/feature/trainee/modifymyinfo/src/main/java/co/kr/tnt/trainee/modifymyinfo/navigation/TraineeModifyMyInfoNavigation.kt new file mode 100644 index 00000000..f93144fe --- /dev/null +++ b/feature/trainee/modifymyinfo/src/main/java/co/kr/tnt/trainee/modifymyinfo/navigation/TraineeModifyMyInfoNavigation.kt @@ -0,0 +1,25 @@ +package co.kr.tnt.trainee.modifymyinfo.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.compose.composable +import co.kr.tnt.navigation.Route +import co.kr.tnt.trainee.modifymyinfo.TraineeModifyMyInfoRoute + +fun NavController.navigateToTraineeModifyMyInfo( + navOptions: NavOptionsBuilder.() -> Unit = {}, +) = navigate( + route = Route.TraineeModifyMyInfo, + builder = navOptions, +) + +fun NavGraphBuilder.traineeModifyMyInfo( + navigateToPrevious: () -> Unit, +) { + composable { + TraineeModifyMyInfoRoute( + navigateToPrevious = navigateToPrevious, + ) + } +} diff --git a/feature/trainee/modifymyinfo/src/main/res/values/strings.xml b/feature/trainee/modifymyinfo/src/main/res/values/strings.xml new file mode 100644 index 00000000..f95a775c --- /dev/null +++ b/feature/trainee/modifymyinfo/src/main/res/values/strings.xml @@ -0,0 +1,14 @@ + + + 내 정보 수정 + + 생년월일 + 2001/01/01 + 트레이너가 알아야 할 주의사항 + + 삭제하기 + 앨범에서 사진 선택 + + 종료 + 계속 수정 + diff --git a/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/TraineeMyPageContract.kt b/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/TraineeMyPageContract.kt index 20364dfc..ad67570a 100644 --- a/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/TraineeMyPageContract.kt +++ b/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/TraineeMyPageContract.kt @@ -30,6 +30,7 @@ internal class TraineeMyPageContract { val shouldShowRationale: Boolean, ) : TraineeMyPageUiEvent + data object OnClickModifyMyInfo : TraineeMyPageUiEvent data object OnClickConnect : TraineeMyPageUiEvent data object OnClickTermsOfService : TraineeMyPageUiEvent data object OnClickPrivacy : TraineeMyPageUiEvent @@ -42,6 +43,7 @@ internal class TraineeMyPageContract { sealed interface TraineeMyPageEffect : UiSideEffect { data class ShowToast(val message: DisplayText) : TraineeMyPageEffect + data object NavigateToModifyMyInfo : TraineeMyPageEffect data object NavigateToConnect : TraineeMyPageEffect data object NavigateToLogin : TraineeMyPageEffect data class NavigateToWebView(val url: String) : TraineeMyPageEffect diff --git a/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/TraineeMyPageScreen.kt b/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/TraineeMyPageScreen.kt index 989e70b3..46a3d433 100644 --- a/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/TraineeMyPageScreen.kt +++ b/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/TraineeMyPageScreen.kt @@ -31,10 +31,13 @@ import co.kr.tnt.core.ui.R.string.core_app_push_notification import co.kr.tnt.core.ui.R.string.core_app_version import co.kr.tnt.core.ui.R.string.core_cancel import co.kr.tnt.core.ui.R.string.core_delete_account +import co.kr.tnt.core.ui.R.string.core_delete_account_complete_content +import co.kr.tnt.core.ui.R.string.core_delete_account_title import co.kr.tnt.core.ui.R.string.core_logout import co.kr.tnt.core.ui.R.string.core_logout_complete_title import co.kr.tnt.core.ui.R.string.core_logout_content import co.kr.tnt.core.ui.R.string.core_logout_title +import co.kr.tnt.core.ui.R.string.core_modifying_personal_info import co.kr.tnt.core.ui.R.string.core_ok import co.kr.tnt.core.ui.R.string.core_open_source_license import co.kr.tnt.core.ui.R.string.core_privacy_policy @@ -43,6 +46,9 @@ import co.kr.tnt.designsystem.component.TnTIconPopupDialog import co.kr.tnt.designsystem.component.TnTProfileImage import co.kr.tnt.designsystem.component.TnTSingleButtonPopupDialog import co.kr.tnt.designsystem.component.TnTSwitch +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.snackbar.LocalSnackbar import co.kr.tnt.designsystem.theme.TnTTheme import co.kr.tnt.domain.model.User @@ -65,12 +71,14 @@ import coil.request.ImageRequest import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.android.gms.oss.licenses.OssLicensesMenuActivity +import kotlinx.coroutines.flow.collectLatest import java.time.LocalDate @OptIn(ExperimentalPermissionsApi::class) @Composable internal fun TraineeMyPageRoute( padding: PaddingValues, + navigateToModifyMyInfo: () -> Unit, navigateToConnect: (ScreenMode) -> Unit, navigateToLogin: () -> Unit, navigateToWebView: (url: String) -> Unit, @@ -86,11 +94,14 @@ internal fun TraineeMyPageRoute( state = uiState, padding = padding, appVersion = context.getAppVersion(), + onClickModifyMyInfo = { viewModel.setEvent(TraineeMyPageUiEvent.OnClickModifyMyInfo) }, onClickConnect = { viewModel.setEvent(TraineeMyPageUiEvent.OnClickConnect) }, onTogglePushNotification = { viewModel.setEvent( TraineeMyPageUiEvent.OnToggleNotification( - isGrantedPermission = TnTPermission.NOTIFICATION.isRequireGranted(permissionState), + isGrantedPermission = TnTPermission.NOTIFICATION.isRequireGranted( + permissionState, + ), shouldShowRationale = permissionState.shouldShowRationale, ), ) @@ -113,8 +124,9 @@ internal fun TraineeMyPageRoute( } LaunchedEffect(viewModel.effect) { - viewModel.effect.collect { effect -> + viewModel.effect.collectLatest { effect -> when (effect) { + TraineeMyPageEffect.NavigateToModifyMyInfo -> navigateToModifyMyInfo() TraineeMyPageEffect.NavigateToConnect -> navigateToConnect(ScreenMode.BACK) TraineeMyPageEffect.NavigateToLogin -> navigateToLogin() is TraineeMyPageEffect.ShowToast -> snackbar.show(effect.message.asString(context)) @@ -122,9 +134,8 @@ internal fun TraineeMyPageRoute( is TraineeMyPageEffect.RequestPermission -> { if (effect.isExplicitlyDenied) { context.moveToAppSetting() - return@collect + return@collectLatest } - permissionState.launchMultiplePermissionRequest() } @@ -140,6 +151,7 @@ private fun TraineeMyPageScreen( state: TraineeMyPageUiState, padding: PaddingValues, appVersion: String, + onClickModifyMyInfo: () -> Unit, onClickConnect: () -> Unit, onTogglePushNotification: () -> Unit, onClickTermsOfService: () -> Unit, @@ -178,7 +190,13 @@ private fun TraineeMyPageScreen( color = TnTTheme.colors.neutralColors.Neutral950, style = TnTTheme.typography.h2, ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(8.dp)) + TnTTextButton( + text = stringResource(core_modifying_personal_info), + size = ButtonSize.Small, + type = ButtonType.Gray, + onClick = onClickModifyMyInfo, + ) Column( verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier @@ -292,7 +310,7 @@ private fun Dialog( DialogState.DELETE_ACCOUNT_CONFIRM -> { TnTIconPopupDialog( - title = stringResource(R.string.delete_account_title), + title = stringResource(core_delete_account_title), content = stringResource(R.string.delete_account_content), leftButtonText = stringResource(core_cancel), rightButtonText = stringResource(core_ok), @@ -304,8 +322,8 @@ private fun Dialog( DialogState.DELETE_ACCOUNT -> { TnTSingleButtonPopupDialog( - title = stringResource(R.string.delete_account_complete_title), - content = stringResource(R.string.delete_account_complete_content), + title = stringResource(core_delete_account_title), + content = stringResource(core_delete_account_complete_content), buttonText = stringResource(core_ok), cancelable = false, onButtonClick = onClickConfirm, @@ -346,6 +364,7 @@ private fun TraineeMyPageScreenPreview() { ), padding = PaddingValues(), appVersion = "1.0", + onClickModifyMyInfo = { }, onClickConnect = { }, onTogglePushNotification = { }, onClickTermsOfService = { }, diff --git a/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/TraineeMyPageViewModel.kt b/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/TraineeMyPageViewModel.kt index 67a4f272..38c67153 100644 --- a/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/TraineeMyPageViewModel.kt +++ b/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/TraineeMyPageViewModel.kt @@ -15,6 +15,7 @@ import co.kr.tnt.trainee.mypage.TraineeMyPageContract.TraineeMyPageUiState.Dialo import co.kr.tnt.ui.base.BaseViewModel import co.kr.tnt.ui.resource.DisplayText import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -36,6 +37,7 @@ internal class TraineeMyPageViewModel @Inject constructor( override suspend fun handleEvent(event: TraineeMyPageUiEvent) { when (event) { + TraineeMyPageUiEvent.OnClickModifyMyInfo -> navigateToModifyMyInfo() TraineeMyPageUiEvent.OnClickConnect -> navigateToConnect() is TraineeMyPageUiEvent.OnToggleNotification -> handleToggleNotification( @@ -57,9 +59,7 @@ internal class TraineeMyPageViewModel @Inject constructor( TraineeMyPageUiEvent.OnClickLogout -> updateState { copy(dialogState = DialogState.LOGOUT_CONFIRM) } TraineeMyPageUiEvent.OnClickDeleteAccount -> updateState { - copy( - dialogState = DialogState.DELETE_ACCOUNT_CONFIRM, - ) + copy(dialogState = DialogState.DELETE_ACCOUNT_CONFIRM) } TraineeMyPageUiEvent.OnClickDialogConfirm -> handleDialogConfirm() @@ -69,17 +69,19 @@ internal class TraineeMyPageViewModel @Inject constructor( private fun loadUserData() { viewModelScope.launch { - runCatching { - traineeRepository.getMyInfo() - }.onSuccess { user -> - updateState { copy(user = user) } - }.onFailure { - sendEffect( - TraineeMyPageEffect.ShowToast( - DisplayText.Resource(core_failed_to_server_request), - ), - ) - } + traineeRepository.getMyInfo() + .onEach { user -> + updateState { copy(user = user) } + }.catch { + sendEffect( + TraineeMyPageEffect.ShowToast( + DisplayText.Resource( + core_failed_to_server_request, + ), + ), + ) + } + .launchIn(viewModelScope) settingRepository.isEnablePushNotification() .onEach { isEnablePushNotification -> @@ -89,6 +91,10 @@ internal class TraineeMyPageViewModel @Inject constructor( } } + private fun navigateToModifyMyInfo() { + sendEffect(TraineeMyPageEffect.NavigateToModifyMyInfo) + } + private fun navigateToConnect() { sendEffect(TraineeMyPageEffect.NavigateToConnect) } @@ -126,12 +132,14 @@ internal class TraineeMyPageViewModel @Inject constructor( DialogState.LOGOUT -> { updateState { copy(dialogState = DialogState.NONE) } sendEffect(TraineeMyPageEffect.NavigateToLogin) + clearCachedUserInfo() } DialogState.DELETE_ACCOUNT_CONFIRM -> withdraw() DialogState.DELETE_ACCOUNT -> { updateState { copy(dialogState = DialogState.NONE) } sendEffect(TraineeMyPageEffect.NavigateToLogin) + clearCachedUserInfo() } DialogState.SHOULD_ALLOW_PERMISSION -> { @@ -176,4 +184,10 @@ internal class TraineeMyPageViewModel @Inject constructor( } } } + + private fun clearCachedUserInfo() { + viewModelScope.launch { + traineeRepository.clearCachedUserInfo() + } + } } diff --git a/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/navigation/TraineeMyPageNavigation.kt b/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/navigation/TraineeMyPageNavigation.kt index db318bab..32aa5a3b 100644 --- a/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/navigation/TraineeMyPageNavigation.kt +++ b/feature/trainee/mypage/src/main/java/co/kr/tnt/trainee/mypage/navigation/TraineeMyPageNavigation.kt @@ -19,18 +19,22 @@ fun NavController.navigateToTraineeMyPage( fun NavGraphBuilder.traineeMyPageNavGraph( padding: PaddingValues, + navigateToModifyMyInfo: () -> Unit, navigateToTraineeConnect: (ScreenMode) -> Unit, navigateToLogin: () -> Unit, navigateToWebView: (url: String) -> Unit, + myPageDestination: NavGraphBuilder.() -> Unit = { }, ) { navigation(startDestination = Route.TraineeMyPage) { composable { TraineeMyPageRoute( padding = padding, + navigateToModifyMyInfo = navigateToModifyMyInfo, navigateToConnect = navigateToTraineeConnect, navigateToLogin = navigateToLogin, navigateToWebView = navigateToWebView, ) } + myPageDestination() } } diff --git a/feature/trainee/mypage/src/main/res/values/strings.xml b/feature/trainee/mypage/src/main/res/values/strings.xml index a5221b2e..55652c2c 100644 --- a/feature/trainee/mypage/src/main/res/values/strings.xml +++ b/feature/trainee/mypage/src/main/res/values/strings.xml @@ -8,10 +8,7 @@ %s 트레이너와 연결이 해제되었어요 더 폭발적인 케미로 다시 만나요! - 계정을 탈퇴할까요? 운동 및 식단 기록에 대한 데이터가 사라져요! - 계정 탈퇴가 완료되었어요 - 다음에 더 폭발적인 케미로 다시 만나요! 💣 로그아웃에 실패하였습니다. 탈퇴에 실패하였습니다. 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 baa3a6eb..08cb5726 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 @@ -19,20 +19,21 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import co.kr.tnt.core.designsystem.R.string.placeholder_content_input import co.kr.tnt.core.ui.R.string.core_next +import co.kr.tnt.core.ui.R.string.core_text_length_warning import co.kr.tnt.designsystem.component.TnTTopBarWithBackButton import co.kr.tnt.designsystem.component.button.TnTBottomButton import co.kr.tnt.designsystem.component.textfield.TnTLabeledTextField import co.kr.tnt.designsystem.component.textfield.model.TnTTextFieldSize import co.kr.tnt.designsystem.theme.TnTTheme +import co.kr.tnt.domain.UserProfilePolicy 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.extensions.clearFocusOnTap -private const val MAX_LENGTH = 100 - @Composable internal fun TraineeNoteForTrainerPage( - caution: String?, + state: TraineeSignUpUiState, onChangeCaution: (String) -> Unit, onClickBack: () -> Unit, onClickNext: () -> Unit, @@ -60,20 +61,23 @@ internal fun TraineeNoteForTrainerPage( ) Spacer(Modifier.padding(top = 48.dp)) TnTLabeledTextField( - value = caution ?: "", + value = state.caution ?: "", onValueChange = onChangeCaution, modifier = Modifier.padding(horizontal = 20.dp), size = TnTTextFieldSize.LARGE, placeholder = stringResource(placeholder_content_input), - isWarning = (caution?.length ?: 0) >= MAX_LENGTH, - warningMessage = stringResource(R.string.text_length_overflow), - maxLength = 100, + isWarning = state.isCautionNoteValid.not(), + warningMessage = stringResource( + core_text_length_warning, + UserProfilePolicy.USER_CAUTION_MAX_LENGTH, + ), + maxLength = UserProfilePolicy.USER_CAUTION_MAX_LENGTH, ) } TnTBottomButton( text = stringResource(core_next), modifier = Modifier.align(Alignment.BottomCenter), - enabled = (caution?.length ?: 0) < MAX_LENGTH, + enabled = state.isCautionNoteValid, onClick = onClickNext, ) } @@ -85,7 +89,7 @@ internal fun TraineeNoteForTrainerPage( private fun TraineeNoteForTrainerPagePreview() { TnTTheme { TraineeNoteForTrainerPage( - caution = "", + state = TraineeSignUpUiState(), onClickBack = {}, onClickNext = {}, onChangeCaution = {}, 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 d6241011..a41504ad 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 @@ -27,7 +27,7 @@ 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.trainee.signup.model.TraineePtPurpose private const val ROW_NUM = 3 private const val COLUMNS_NUM = 2 @@ -63,7 +63,7 @@ internal fun TraineePTPurposePage( maxLines = ROW_NUM, modifier = Modifier.fillMaxWidth(), ) { - PTPurpose.entries.forEach { purpose -> + TraineePtPurpose.entries.forEach { purpose -> val purposeText = stringResource(purpose.textResId) PurposeButton( text = purposeText, 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 0d11656a..c91081d4 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 @@ -9,9 +9,6 @@ import co.kr.tnt.ui.resource.DisplayText import java.io.File import java.time.LocalDate -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, @@ -29,31 +26,27 @@ internal class TraineeSignUpContract { name.matches(UserProfilePolicy.USER_NAME_REGEX) && name.length <= UserProfilePolicy.USER_NAME_MAX_LENGTH - /** - * 키가 유효한 입력값인지 검사 - * 형식: 정수 3자 - */ val isHeightValid get() = height.isNullOrBlank() || ( height.toIntOrNull() != null && - !height.startsWith("0") && - height.length <= MAX_HEIGHT_LENGTH + height.startsWith("0").not() && + height.length <= UserProfilePolicy.USER_HEIGHT_MAX_LENGTH ) - /** - * 몸무게가 유효한 입력값인지 검사 - * 형식: 5자 이하의 실수 (000, 00, 00.0, 000.0) - */ - private val weightRegex = Regex("^(\\d{1,3}(\\.\\d)?)?\$") val isWeightValid get() = weight.isNullOrBlank() || ( - weight.matches(weightRegex) && - !weight.startsWith("0") && - weight.length <= MAX_WEIGHT_LENGTH + weight.matches(UserProfilePolicy.USER_WEIGHT_REGEX) && + weight.startsWith("0").not() && + weight.length <= UserProfilePolicy.USER_WEIGHT_MAX_LENGTH ) val isBasicInfoValid get() = isWeightValid && isHeightValid + + val isCautionNoteValid + get() = caution.isNullOrBlank() || ( + caution.length < UserProfilePolicy.USER_CAUTION_MAX_LENGTH + ) } sealed interface TraineeSignUpUiEvent : UiEvent { 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 702e64d7..cb4b229b 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 @@ -97,7 +97,7 @@ private fun TraineeSignUpScreen( ) TraineeSignUpPage.NoteForTrainer -> TraineeNoteForTrainerPage( - caution = state.caution, + state = state, onChangeCaution = onChangeCaution, onClickBack = onClickBack, onClickNext = onClickNext, diff --git a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/model/PTPurpose.kt b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/model/PTPurpose.kt deleted file mode 100644 index 4b992b92..00000000 --- a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/model/PTPurpose.kt +++ /dev/null @@ -1,15 +0,0 @@ -package co.kr.tnt.trainee.signup.model - -import androidx.annotation.StringRes -import co.kr.tnt.feature.trainee.signup.R - -enum class PTPurpose( - @StringRes val textResId: Int, -) { - LOSS_WEIGHT(R.string.loss_weight), - STRENGTH(R.string.strength_improvement), - HEALTH_CARE(R.string.health_care), - FLEXIBILITY(R.string.flexibility), - BODY_PROFILE(R.string.body_profile), - POSTURE_CORRECTION(R.string.posture_correction), -} diff --git a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/model/TraineePtPurpose.kt b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/model/TraineePtPurpose.kt new file mode 100644 index 00000000..0fd2b456 --- /dev/null +++ b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/model/TraineePtPurpose.kt @@ -0,0 +1,35 @@ +package co.kr.tnt.trainee.signup.model + +import androidx.annotation.StringRes +import co.kr.tnt.core.ui.R.string.core_body_profile +import co.kr.tnt.core.ui.R.string.core_flexibility +import co.kr.tnt.core.ui.R.string.core_health_care +import co.kr.tnt.core.ui.R.string.core_loss_weight +import co.kr.tnt.core.ui.R.string.core_posture_correction +import co.kr.tnt.core.ui.R.string.core_strength_improvement +import co.kr.tnt.domain.model.PtPurpose + +enum class TraineePtPurpose( + @StringRes val textResId: Int, +) { + LOSS_WEIGHT(core_loss_weight), + STRENGTH(core_strength_improvement), + HEALTH_CARE(core_health_care), + FLEXIBILITY(core_flexibility), + BODY_PROFILE(core_body_profile), + POSTURE_CORRECTION(core_posture_correction), + ; + + companion object { + fun from(purpose: PtPurpose): TraineePtPurpose { + return when (purpose) { + PtPurpose.LOSS_WEIGHT -> LOSS_WEIGHT + PtPurpose.STRENGTH -> STRENGTH + PtPurpose.HEALTH_CARE -> HEALTH_CARE + PtPurpose.FLEXIBILITY -> FLEXIBILITY + PtPurpose.BODY_PROFILE -> BODY_PROFILE + PtPurpose.POSTURE_CORRECTION -> POSTURE_CORRECTION + } + } + } +} diff --git a/feature/trainee/signup/src/main/res/values/strings.xml b/feature/trainee/signup/src/main/res/values/strings.xml index 0955dc7f..d8459589 100644 --- a/feature/trainee/signup/src/main/res/values/strings.xml +++ b/feature/trainee/signup/src/main/res/values/strings.xml @@ -11,16 +11,9 @@ PT를 받는 목적에 대해\n알려주세요! 다중 선택이 가능해요. - 체중 감량 - 근력 향상 - 건강 관리 - 유연성 향상 - 바디프로필 - 자세 교정 트레이너가 꼭 알아야 할\n주의사항이 있나요? 트레이너에게 알려드릴게요. - 100자 미만으로 입력해주세요 만나서 반가워요\n%s 트레이니님! 트레이너와 함께\n케미를 터뜨려보세요! 🧨 diff --git a/feature/trainer/invite/src/main/java/co/kr/tnt/trainer/invite/TrainerInviteContract.kt b/feature/trainer/invite/src/main/java/co/kr/tnt/trainer/invite/TrainerInviteContract.kt index 46b6793f..c0c72cf9 100644 --- a/feature/trainer/invite/src/main/java/co/kr/tnt/trainer/invite/TrainerInviteContract.kt +++ b/feature/trainer/invite/src/main/java/co/kr/tnt/trainer/invite/TrainerInviteContract.kt @@ -3,6 +3,7 @@ package co.kr.tnt.trainer.invite import co.kr.tnt.ui.base.UiEvent import co.kr.tnt.ui.base.UiSideEffect import co.kr.tnt.ui.base.UiState +import co.kr.tnt.ui.model.SnackbarType import co.kr.tnt.ui.resource.DisplayText internal class TrainerInviteContract { @@ -20,7 +21,11 @@ internal class TrainerInviteContract { sealed interface TrainerInviteSideEffect : UiSideEffect { data object NavigateToBack : TrainerInviteSideEffect data object NavigateToHome : TrainerInviteSideEffect - data class ShowToast(val message: DisplayText) : TrainerInviteSideEffect + data class ShowToast( + val message: DisplayText, + val type: SnackbarType = SnackbarType.WARNING, + ) : TrainerInviteSideEffect + data class CopyToClipBoard(val value: String) : TrainerInviteSideEffect } } diff --git a/feature/trainer/invite/src/main/java/co/kr/tnt/trainer/invite/TrainerInviteScreen.kt b/feature/trainer/invite/src/main/java/co/kr/tnt/trainer/invite/TrainerInviteScreen.kt index e9b4df90..1a102d91 100644 --- a/feature/trainer/invite/src/main/java/co/kr/tnt/trainer/invite/TrainerInviteScreen.kt +++ b/feature/trainer/invite/src/main/java/co/kr/tnt/trainer/invite/TrainerInviteScreen.kt @@ -66,7 +66,10 @@ internal fun TrainerInviteRoute( when (effect) { TrainerInviteSideEffect.NavigateToBack -> navigateToPrevious() TrainerInviteSideEffect.NavigateToHome -> navigateToHome(true) - is TrainerInviteSideEffect.ShowToast -> snackbar.show(effect.message.asString(context)) + is TrainerInviteSideEffect.ShowToast -> snackbar.show( + message = effect.message.asString(context), + icon = effect.type.iconRes, + ) is TrainerInviteSideEffect.CopyToClipBoard -> clipboardManager.setText(AnnotatedString(effect.value)) } diff --git a/feature/trainer/invite/src/main/java/co/kr/tnt/trainer/invite/TrainerInviteViewModel.kt b/feature/trainer/invite/src/main/java/co/kr/tnt/trainer/invite/TrainerInviteViewModel.kt index 635f5806..e354306b 100644 --- a/feature/trainer/invite/src/main/java/co/kr/tnt/trainer/invite/TrainerInviteViewModel.kt +++ b/feature/trainer/invite/src/main/java/co/kr/tnt/trainer/invite/TrainerInviteViewModel.kt @@ -8,6 +8,7 @@ import co.kr.tnt.trainer.invite.TrainerInviteContract.TrainerInviteSideEffect import co.kr.tnt.trainer.invite.TrainerInviteContract.TrainerInviteUiEvent import co.kr.tnt.trainer.invite.TrainerInviteContract.TrainerInviteUiState import co.kr.tnt.ui.base.BaseViewModel +import co.kr.tnt.ui.model.SnackbarType import co.kr.tnt.ui.resource.DisplayText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch @@ -28,7 +29,8 @@ internal class TrainerInviteViewModel @Inject constructor( sendEffect(TrainerInviteSideEffect.CopyToClipBoard(event.code)) sendEffect( TrainerInviteSideEffect.ShowToast( - DisplayText.Resource(R.string.code_is_copied), + message = DisplayText.Resource(R.string.code_is_copied), + type = SnackbarType.SUCCESS, ), ) } diff --git a/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/TrainerMyPageScreen.kt b/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/TrainerMyPageScreen.kt index b0ac7ba4..17c3f25d 100644 --- a/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/TrainerMyPageScreen.kt +++ b/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/TrainerMyPageScreen.kt @@ -36,6 +36,9 @@ import co.kr.tnt.core.ui.R.string.core_app_push_notification import co.kr.tnt.core.ui.R.string.core_app_version import co.kr.tnt.core.ui.R.string.core_cancel import co.kr.tnt.core.ui.R.string.core_delete_account +import co.kr.tnt.core.ui.R.string.core_delete_account_complete_content +import co.kr.tnt.core.ui.R.string.core_delete_account_complete_title +import co.kr.tnt.core.ui.R.string.core_delete_account_title import co.kr.tnt.core.ui.R.string.core_logout import co.kr.tnt.core.ui.R.string.core_logout_complete_title import co.kr.tnt.core.ui.R.string.core_logout_content @@ -358,7 +361,7 @@ private fun Dialog( DialogState.DELETE_ACCOUNT_CONFIRM -> { TnTIconPopupDialog( - title = stringResource(R.string.delete_account_title), + title = stringResource(core_delete_account_title), content = stringResource(R.string.delete_account_content), leftButtonText = stringResource(core_cancel), rightButtonText = stringResource(core_ok), @@ -370,8 +373,8 @@ private fun Dialog( DialogState.DELETE_ACCOUNT -> { TnTSingleButtonPopupDialog( - title = stringResource(R.string.delete_account_complete_title), - content = stringResource(R.string.delete_account_complete_content), + title = stringResource(core_delete_account_complete_title), + content = stringResource(core_delete_account_complete_content), buttonText = stringResource(core_ok), cancelable = false, onButtonClick = onClickConfirm, diff --git a/feature/trainer/mypage/src/main/res/values/strings.xml b/feature/trainer/mypage/src/main/res/values/strings.xml index 63e2dff2..6ebc2f6d 100644 --- a/feature/trainer/mypage/src/main/res/values/strings.xml +++ b/feature/trainer/mypage/src/main/res/values/strings.xml @@ -1,10 +1,7 @@ - 계정을 탈퇴할까요? 함께 했던 회원들에 대한 데이터가 사라져요! - 계정 탈퇴가 완료되었어요 - 다음에 더 폭발적인 케미로 다시 만나요! 💣 탈퇴에 실패하였습니다. 로그아웃에 실패하였습니다. 관리 중인 회원 diff --git a/settings.gradle.kts b/settings.gradle.kts index 148663ae..56498a55 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -70,4 +70,5 @@ include( ":feature:trainee:notification", ":feature:trainee:mealrecord", ":feature:trainee:mealdetail", + ":feature:trainee:modifymyinfo", )