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",
)