diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
index 020e380f..22117b46 100644
--- a/config/detekt/detekt.yml
+++ b/config/detekt/detekt.yml
@@ -765,6 +765,8 @@ style:
# active: true
UnusedPrivateMember:
active: false
+ UnusedParameter:
+ active: false
# allowedNames: '(_|ignored|expected|serialVersionUID)'
# UseArrayLiteralsInAnnotations:
# active: false
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 b4da03d4..aceff7d1 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
@@ -102,6 +102,9 @@ sealed interface Route {
@Serializable
data object TrainerNotification : Route
+ @Serializable
+ data object TrainerModifyMyInfo : Route
+
@Serializable
data class AddPtSession(val selectedDate: String) : Route
diff --git a/core/ui/src/main/java/co/kr/tnt/ui/utils/FileUtils.kt b/core/ui/src/main/java/co/kr/tnt/ui/utils/FileUtils.kt
index d31d50cf..5fdfceff 100644
--- a/core/ui/src/main/java/co/kr/tnt/ui/utils/FileUtils.kt
+++ b/core/ui/src/main/java/co/kr/tnt/ui/utils/FileUtils.kt
@@ -6,7 +6,7 @@ import android.graphics.BitmapFactory
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
-import co.kr.tnt.domain.IMAGE_MAX_SIZE
+import co.kr.tnt.domain.UserProfilePolicy
import co.kr.tnt.ui.extensions.toResizedByteArray
import java.io.File
@@ -33,7 +33,7 @@ fun getRealPathFromUri(uri: Uri, context: Context): String? {
fun Uri.convertToAllowedImageFormat(
context: Context,
- maxSizeInBytes: Int = IMAGE_MAX_SIZE,
+ maxSizeInBytes: Int = UserProfilePolicy.USER_IMAGE_MAX_SIZE,
): File {
val inputStream = context.contentResolver.openInputStream(this)
val bitmap = BitmapFactory.decodeStream(inputStream)
diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml
index b2da9602..072adaac 100644
--- a/core/ui/src/main/res/values/strings.xml
+++ b/core/ui/src/main/res/values/strings.xml
@@ -20,6 +20,7 @@
닫기
확인
+ 완료
설정으로 이동
잘못된 수치를 입력했어요
@@ -42,6 +43,7 @@
세
cm
kg
+ 이름을 입력해주세요
알림
최근 받은 알림이 없어요
@@ -81,4 +83,6 @@
%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
new file mode 100644
index 00000000..facd2327
--- /dev/null
+++ b/data/network/src/main/java/co/kr/data/network/model/UpdateUserInfoRequest.kt
@@ -0,0 +1,16 @@
+package co.kr.data.network.model
+
+import co.kr.data.network.model.enum.MemberType
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class UpdateUserInfoRequest(
+ val removeImage: Boolean,
+ val memberType: MemberType,
+ val name: String,
+ val birthDay: String? = null,
+ val height: Double? = null,
+ val weight: Double? = null,
+ val cautionNote: String? = null,
+ val goalContents: List? = null,
+)
diff --git a/data/network/src/main/java/co/kr/data/network/model/enum/MemberType.kt b/data/network/src/main/java/co/kr/data/network/model/enum/MemberType.kt
index 17f0d45d..a701177f 100644
--- a/data/network/src/main/java/co/kr/data/network/model/enum/MemberType.kt
+++ b/data/network/src/main/java/co/kr/data/network/model/enum/MemberType.kt
@@ -8,6 +8,16 @@ enum class MemberType {
TRAINER,
TRAINEE,
UNREGISTERED,
+ ;
+
+ companion object {
+ fun from(userType: UserType): MemberType {
+ return when (userType) {
+ UserType.TRAINER -> TRAINER
+ UserType.TRAINEE -> TRAINEE
+ }
+ }
+ }
}
fun MemberType.toDomain(): UserType =
diff --git a/data/network/src/main/java/co/kr/data/network/service/ApiService.kt b/data/network/src/main/java/co/kr/data/network/service/ApiService.kt
index eeea938b..9fe93621 100644
--- a/data/network/src/main/java/co/kr/data/network/service/ApiService.kt
+++ b/data/network/src/main/java/co/kr/data/network/service/ApiService.kt
@@ -57,6 +57,13 @@ interface ApiService {
@GET("/members")
suspend fun getMyInfo(): UserResponse
+ @Multipart
+ @PUT("/members")
+ suspend fun putMyInfo(
+ @Part profileImage: MultipartBody.Part?,
+ @Part("request") request: RequestBody,
+ )
+
// Connect
@GET("/trainers/invitation-code")
suspend fun getInviteCode(): InviteCodeResponse
diff --git a/data/network/src/main/java/co/kr/data/network/source/TrainerRemoteDataSource.kt b/data/network/src/main/java/co/kr/data/network/source/TrainerRemoteDataSource.kt
index 45942c84..8b8a8f89 100644
--- a/data/network/src/main/java/co/kr/data/network/source/TrainerRemoteDataSource.kt
+++ b/data/network/src/main/java/co/kr/data/network/source/TrainerRemoteDataSource.kt
@@ -1,17 +1,26 @@
package co.kr.data.network.source
+import co.kr.data.network.model.UpdateUserInfoRequest
import co.kr.data.network.model.trainer.ActiveTraineesResponse
import co.kr.data.network.model.trainer.DailyPtSessionsResponse
import co.kr.data.network.model.trainer.MonthlyPtSessionCountsResponse
import co.kr.data.network.model.trainer.PtSessionRequest
import co.kr.data.network.service.ApiService
import co.kr.data.network.util.networkHandler
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TrainerRemoteDataSource @Inject constructor(
private val apiService: ApiService,
+ private val json: Json,
) {
suspend fun getMonthlyPtSessionCounts(
year: Int,
@@ -40,4 +49,26 @@ class TrainerRemoteDataSource @Inject constructor(
suspend fun putCompletePtSession(ptSessionId: String) = networkHandler {
apiService.putCompletePtSession(ptSessionId)
}
+
+ suspend fun putUserInfo(
+ profileImage: File?,
+ request: UpdateUserInfoRequest,
+ ) {
+ // TODO 공통 로직 추출
+ val profileImagePart = profileImage?.let {
+ val requestFile = it.asRequestBody("image/*".toMediaTypeOrNull())
+ MultipartBody.Part.createFormData("profileImage", it.name, requestFile)
+ }
+
+ val requestBody = json
+ .encodeToString(request)
+ .toRequestBody("application/json".toMediaTypeOrNull())
+
+ networkHandler {
+ apiService.putMyInfo(
+ profileImage = profileImagePart,
+ request = requestBody,
+ )
+ }
+ }
}
diff --git a/data/repository/src/main/java/co/kr/data/repository/TrainerRepositoryImpl.kt b/data/repository/src/main/java/co/kr/data/repository/TrainerRepositoryImpl.kt
index 5530f699..d374de47 100644
--- a/data/repository/src/main/java/co/kr/data/repository/TrainerRepositoryImpl.kt
+++ b/data/repository/src/main/java/co/kr/data/repository/TrainerRepositoryImpl.kt
@@ -1,16 +1,23 @@
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.trainer.PtSessionRequest
import co.kr.data.network.model.trainer.toDomain
import co.kr.data.network.source.TrainerRemoteDataSource
import co.kr.data.network.source.UserRemoteDataSource
import co.kr.tnt.domain.model.MemberInfo
+import co.kr.tnt.domain.model.ProfileImageUpdatePolicy
import co.kr.tnt.domain.model.User
+import co.kr.tnt.domain.model.UserType
import co.kr.tnt.domain.model.trainer.TrainerDailyPtSession
import co.kr.tnt.domain.model.trainer.TrainerDailyPtSessionCount
import co.kr.tnt.domain.repository.TrainerRepository
import co.kr.tnt.domain.utils.DateFormatter
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.onStart
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.YearMonth
@@ -23,6 +30,8 @@ internal class TrainerRepositoryImpl @Inject constructor(
private val trainerRemoteDataSource: TrainerRemoteDataSource,
private val dateFormatter: DateFormatter,
) : TrainerRepository {
+ private val cacheUserInfo = MutableStateFlow(User.Trainer.EMPTY)
+
override suspend fun getMonthlyPtSessionCounts(yearMonth: YearMonth): List =
trainerRemoteDataSource.getMonthlyPtSessionCounts(
year = yearMonth.year,
@@ -31,10 +40,13 @@ internal class TrainerRepositoryImpl @Inject constructor(
response.toDomain(dateFormatter)
}
- override suspend fun getMyInfo(): User.Trainer {
- val user = userRemoteDataSource.getMyInfo().toDomain(dateFormatter)
- require(user is User.Trainer)
- return user
+ override suspend fun getMyInfo(): Flow {
+ return cacheUserInfo
+ .onStart {
+ if (cacheUserInfo.value == User.Trainer.EMPTY) {
+ cacheUserInfo.value = fetchUserInfo()
+ }
+ }
}
override suspend fun getDailyPtSessions(day: LocalDate): TrainerDailyPtSession =
@@ -65,4 +77,36 @@ internal class TrainerRepositoryImpl @Inject constructor(
override suspend fun postCompleteSession(ptSessionId: String) =
trainerRemoteDataSource.putCompletePtSession(ptSessionId)
+
+ override suspend fun updateUserInfo(
+ profileImageUpdatePolicy: ProfileImageUpdatePolicy,
+ name: String,
+ ) {
+ val (profileImage, isRemoveProfileImage) = when (profileImageUpdatePolicy) {
+ is ProfileImageUpdatePolicy.Change -> profileImageUpdatePolicy.newProfileImage to false
+ ProfileImageUpdatePolicy.Keep -> null to false
+ ProfileImageUpdatePolicy.Remove -> null to true
+ }
+
+ runCatching {
+ trainerRemoteDataSource.putUserInfo(
+ profileImage = profileImage,
+ request = UpdateUserInfoRequest(
+ removeImage = isRemoveProfileImage,
+ memberType = MemberType.from(UserType.TRAINER),
+ name = name,
+ ),
+ )
+ }.onSuccess {
+ cacheUserInfo.value = fetchUserInfo()
+ }.onFailure { failure ->
+ throw failure
+ }
+ }
+
+ private suspend fun fetchUserInfo(): User.Trainer {
+ val user = userRemoteDataSource.getMyInfo().toDomain(dateFormatter)
+ require(user is User.Trainer)
+ return user
+ }
}
diff --git a/domain/src/main/java/co/kr/tnt/domain/Constants.kt b/domain/src/main/java/co/kr/tnt/domain/Constants.kt
deleted file mode 100644
index f5a3bcc0..00000000
--- a/domain/src/main/java/co/kr/tnt/domain/Constants.kt
+++ /dev/null
@@ -1,4 +0,0 @@
-package co.kr.tnt.domain
-
-// TnT 에서 사용자가 업로드할 수 있는 이미지의 최대 용량은 10MB이다.
-const val IMAGE_MAX_SIZE = 10 * 1024 * 1024
diff --git a/domain/src/main/java/co/kr/tnt/domain/Policy.kt b/domain/src/main/java/co/kr/tnt/domain/Policy.kt
new file mode 100644
index 00000000..13b8bc98
--- /dev/null
+++ b/domain/src/main/java/co/kr/tnt/domain/Policy.kt
@@ -0,0 +1,12 @@
+package co.kr.tnt.domain
+
+object UserProfilePolicy {
+ // TnT 에서 사용자가 업로드할 수 있는 이미지의 최대 용량은 10MB이다.
+ const val USER_IMAGE_MAX_SIZE = 10 * 1024 * 1024
+
+ // TnT 에서 사용자가 입력할 수 있는 이름의 최대 길이는 15자이다.
+ const val USER_NAME_MAX_LENGTH = 15
+
+ // TnT 에서 사용자가 입력할 수 있는 이름은 한글, 영어, 공백만 허용한다.
+ val USER_NAME_REGEX = Regex("^[a-zA-Zㄱ-ㅎㅏ-ㅣ가-힣 ]+\$")
+}
diff --git a/domain/src/main/java/co/kr/tnt/domain/model/ProfileImageUpdatePolicy.kt b/domain/src/main/java/co/kr/tnt/domain/model/ProfileImageUpdatePolicy.kt
new file mode 100644
index 00000000..0fd43cc1
--- /dev/null
+++ b/domain/src/main/java/co/kr/tnt/domain/model/ProfileImageUpdatePolicy.kt
@@ -0,0 +1,9 @@
+package co.kr.tnt.domain.model
+
+import java.io.File
+
+sealed interface ProfileImageUpdatePolicy {
+ data object Keep : ProfileImageUpdatePolicy
+ data class Change(val newProfileImage: File) : ProfileImageUpdatePolicy
+ data object Remove : ProfileImageUpdatePolicy
+}
diff --git a/domain/src/main/java/co/kr/tnt/domain/repository/TrainerRepository.kt b/domain/src/main/java/co/kr/tnt/domain/repository/TrainerRepository.kt
index 875a7218..9de975fb 100644
--- a/domain/src/main/java/co/kr/tnt/domain/repository/TrainerRepository.kt
+++ b/domain/src/main/java/co/kr/tnt/domain/repository/TrainerRepository.kt
@@ -1,15 +1,17 @@
package co.kr.tnt.domain.repository
import co.kr.tnt.domain.model.MemberInfo
+import co.kr.tnt.domain.model.ProfileImageUpdatePolicy
import co.kr.tnt.domain.model.User
import co.kr.tnt.domain.model.trainer.TrainerDailyPtSession
import co.kr.tnt.domain.model.trainer.TrainerDailyPtSessionCount
+import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.YearMonth
interface TrainerRepository {
- suspend fun getMyInfo(): User.Trainer
+ suspend fun getMyInfo(): Flow
suspend fun getMonthlyPtSessionCounts(yearMonth: YearMonth): List
suspend fun getDailyPtSessions(day: LocalDate): TrainerDailyPtSession
suspend fun getActiveMembers(): List
@@ -19,5 +21,10 @@ interface TrainerRepository {
memo: String,
traineeId: Long,
)
+
suspend fun postCompleteSession(ptSessionId: String)
+ suspend fun updateUserInfo(
+ profileImageUpdatePolicy: ProfileImageUpdatePolicy,
+ name: String,
+ )
}
diff --git a/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectCompletePage.kt b/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectCompletePage.kt
index 77b36441..117c62a0 100644
--- a/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectCompletePage.kt
+++ b/feature/trainee/connect/src/main/java/co/kr/tnt/trainee/connect/TraineeConnectCompletePage.kt
@@ -28,7 +28,7 @@ import co.kr.tnt.core.ui.R.string.core_next
import co.kr.tnt.designsystem.component.TnTProfileImage
import co.kr.tnt.designsystem.component.button.TnTBottomButton
import co.kr.tnt.designsystem.theme.TnTTheme
-import co.kr.tnt.domain.IMAGE_MAX_SIZE
+import co.kr.tnt.domain.UserProfilePolicy
import co.kr.tnt.domain.model.UserType
import co.kr.tnt.feature.trainee.connect.R
import co.kr.tnt.ui.coil.ResizeTransformation
@@ -130,7 +130,7 @@ private fun ProfileSection(
.data(image)
.placeholder(defaultImage)
.error(DefaultUserProfile.Trainer.image)
- .transformations(ResizeTransformation(IMAGE_MAX_SIZE))
+ .transformations(ResizeTransformation(UserProfilePolicy.USER_IMAGE_MAX_SIZE))
.build(),
)
Column(
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 f677dc52..43b24b35 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
@@ -53,7 +53,7 @@ import co.kr.tnt.designsystem.component.card.TnTRecordCard
import co.kr.tnt.designsystem.component.card.TnTSessionRecordCard
import co.kr.tnt.designsystem.snackbar.LocalSnackbar
import co.kr.tnt.designsystem.theme.TnTTheme
-import co.kr.tnt.domain.IMAGE_MAX_SIZE
+import co.kr.tnt.domain.UserProfilePolicy
import co.kr.tnt.domain.model.DailyRecord
import co.kr.tnt.domain.model.RecordType
import co.kr.tnt.domain.model.RecordType.PTSessionType
@@ -344,7 +344,7 @@ private fun DailyPtSession(
model = ImageRequest.Builder(context)
.data(session.trainerImage)
.placeholder(DefaultUserProfile.Trainer.image)
- .transformations(ResizeTransformation(IMAGE_MAX_SIZE))
+ .transformations(ResizeTransformation(UserProfilePolicy.USER_IMAGE_MAX_SIZE))
.build(),
)
@@ -399,7 +399,7 @@ private fun DailyRecords(
model = ImageRequest.Builder(context)
.data(record.recordImage)
.placeholder(co.kr.tnt.core.designsystem.R.drawable.img_default)
- .transformations(ResizeTransformation(IMAGE_MAX_SIZE))
+ .transformations(ResizeTransformation(UserProfilePolicy.USER_IMAGE_MAX_SIZE))
.build(),
)
diff --git a/feature/trainee/mealdetail/src/main/java/co/kr/tnt/trainee/mealdetail/TraineeMealDetailScreen.kt b/feature/trainee/mealdetail/src/main/java/co/kr/tnt/trainee/mealdetail/TraineeMealDetailScreen.kt
index c8c68bcf..29607ebf 100644
--- a/feature/trainee/mealdetail/src/main/java/co/kr/tnt/trainee/mealdetail/TraineeMealDetailScreen.kt
+++ b/feature/trainee/mealdetail/src/main/java/co/kr/tnt/trainee/mealdetail/TraineeMealDetailScreen.kt
@@ -37,7 +37,7 @@ import co.kr.tnt.designsystem.component.TnTTopBarWithBackButton
import co.kr.tnt.designsystem.component.chip.TnTChip
import co.kr.tnt.designsystem.snackbar.LocalSnackbar
import co.kr.tnt.designsystem.theme.TnTTheme
-import co.kr.tnt.domain.IMAGE_MAX_SIZE
+import co.kr.tnt.domain.UserProfilePolicy
import co.kr.tnt.domain.model.RecordType.MealType
import co.kr.tnt.domain.utils.DateFormatter
import co.kr.tnt.trainee.mealdetail.TraineeMealDetailContract.TraineeMealDetailSideEffect
@@ -94,7 +94,7 @@ private fun TraineeMealDetailScreen(
model = ImageRequest.Builder(context)
.data(state.image)
.placeholder(R.drawable.img_default)
- .transformations(ResizeTransformation(IMAGE_MAX_SIZE))
+ .transformations(ResizeTransformation(UserProfilePolicy.USER_IMAGE_MAX_SIZE))
.build(),
)
diff --git a/feature/trainee/mealrecord/src/main/java/co/kr/tnt/trainee/mealrecord/TraineeMealRecordScreen.kt b/feature/trainee/mealrecord/src/main/java/co/kr/tnt/trainee/mealrecord/TraineeMealRecordScreen.kt
index f84a08bd..bd47e785 100644
--- a/feature/trainee/mealrecord/src/main/java/co/kr/tnt/trainee/mealrecord/TraineeMealRecordScreen.kt
+++ b/feature/trainee/mealrecord/src/main/java/co/kr/tnt/trainee/mealrecord/TraineeMealRecordScreen.kt
@@ -72,7 +72,7 @@ import co.kr.tnt.designsystem.component.calendar.model.DayState
import co.kr.tnt.designsystem.component.calendar.utils.rememberMostVisibleMonth
import co.kr.tnt.designsystem.snackbar.LocalSnackbar
import co.kr.tnt.designsystem.theme.TnTTheme
-import co.kr.tnt.domain.IMAGE_MAX_SIZE
+import co.kr.tnt.domain.UserProfilePolicy
import co.kr.tnt.domain.model.RecordType.MealType
import co.kr.tnt.domain.utils.DateFormatter
import co.kr.tnt.feature.trainee.mealrecord.R
@@ -359,7 +359,7 @@ private fun MealImageSelector(
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.data(imageUri)
- .transformations(ResizeTransformation(IMAGE_MAX_SIZE))
+ .transformations(ResizeTransformation(UserProfilePolicy.USER_IMAGE_MAX_SIZE))
.build(),
)
Column(
diff --git a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeProfileSetupPage.kt b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeProfileSetupPage.kt
index 99c7a1f8..f13933ce 100644
--- a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeProfileSetupPage.kt
+++ b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeProfileSetupPage.kt
@@ -32,7 +32,7 @@ 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.theme.TnTTheme
-import co.kr.tnt.domain.IMAGE_MAX_SIZE
+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
@@ -42,8 +42,6 @@ import co.kr.tnt.ui.model.DefaultUserProfile
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
-private const val MAX_LENGTH = 15
-
@Composable
internal fun TraineeProfileSetupPage(
state: TraineeSignUpUiState,
@@ -63,7 +61,7 @@ internal fun TraineeProfileSetupPage(
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.data(state.image)
- .transformations(ResizeTransformation(IMAGE_MAX_SIZE))
+ .transformations(ResizeTransformation(UserProfilePolicy.USER_IMAGE_MAX_SIZE))
.build(),
)
@@ -109,11 +107,14 @@ internal fun TraineeProfileSetupPage(
},
modifier = Modifier.padding(horizontal = 20.dp),
placeholder = stringResource(R.string.enter_your_name),
- maxLength = MAX_LENGTH,
+ maxLength = UserProfilePolicy.USER_NAME_MAX_LENGTH,
isSingleLine = true,
showWarning = !state.isNameValid,
isRequired = true,
- warningMessage = stringResource(core_text_length_and_format_warning, MAX_LENGTH),
+ warningMessage = stringResource(
+ core_text_length_and_format_warning,
+ UserProfilePolicy.USER_NAME_MAX_LENGTH,
+ ),
)
}
TnTBottomButton(
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 e13d115f..0d11656a 100644
--- a/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpContract.kt
+++ b/feature/trainee/signup/src/main/java/co/kr/tnt/trainee/signup/TraineeSignUpContract.kt
@@ -1,6 +1,7 @@
package co.kr.tnt.trainee.signup
import android.net.Uri
+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
@@ -8,7 +9,6 @@ import co.kr.tnt.ui.resource.DisplayText
import java.io.File
import java.time.LocalDate
-private const val MAX_NAME_LENGTH = 15
private const val MAX_HEIGHT_LENGTH = 3
private const val MAX_WEIGHT_LENGTH = 5
@@ -24,11 +24,10 @@ internal class TraineeSignUpContract {
val caution: String? = "",
val isLoading: Boolean = false,
) : UiState {
- /**
- * 입력 값을 검사해 한글/영어/공백만 허용하고 특수문자는 제거
- */
- private val nameRegex = Regex("^[a-zA-Zㄱ-ㅎㅏ-ㅣ가-힣 ]+\$")
- val isNameValid get() = name.isBlank() || name.matches(nameRegex) && name.length <= MAX_NAME_LENGTH
+ val isNameValid
+ get() = name.isBlank() ||
+ name.matches(UserProfilePolicy.USER_NAME_REGEX) &&
+ name.length <= UserProfilePolicy.USER_NAME_MAX_LENGTH
/**
* 키가 유효한 입력값인지 검사
diff --git a/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TraineeProfilePage.kt b/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TraineeProfilePage.kt
index 483ad47f..808b81ff 100644
--- a/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TraineeProfilePage.kt
+++ b/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TraineeProfilePage.kt
@@ -42,7 +42,7 @@ import co.kr.tnt.core.ui.R.string.core_weight_unit
import co.kr.tnt.designsystem.component.TnTProfileImage
import co.kr.tnt.designsystem.component.button.TnTBottomButton
import co.kr.tnt.designsystem.theme.TnTTheme
-import co.kr.tnt.domain.IMAGE_MAX_SIZE
+import co.kr.tnt.domain.UserProfilePolicy
import co.kr.tnt.domain.model.User
import co.kr.tnt.feature.trainer.connect.R
import co.kr.tnt.trainer.connect.TrainerConnectContract.TrainerConnectUiState
@@ -100,7 +100,7 @@ internal fun TraineeProfilePage(
.data(trainee.image)
.placeholder(DefaultUserProfile.Trainee.image)
.error(DefaultUserProfile.Trainee.image)
- .transformations(ResizeTransformation(IMAGE_MAX_SIZE))
+ .transformations(ResizeTransformation(UserProfilePolicy.USER_IMAGE_MAX_SIZE))
.build(),
)
val defaultImage = painterResource(DefaultUserProfile.Trainee.image)
diff --git a/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectCompletePage.kt b/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectCompletePage.kt
index 3129fb28..22a641ac 100644
--- a/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectCompletePage.kt
+++ b/feature/trainer/connect/src/main/java/co/kr/tnt/trainer/connect/TrainerConnectCompletePage.kt
@@ -28,7 +28,7 @@ import co.kr.tnt.core.ui.R.string.core_next
import co.kr.tnt.designsystem.component.TnTProfileImage
import co.kr.tnt.designsystem.component.button.TnTBottomButton
import co.kr.tnt.designsystem.theme.TnTTheme
-import co.kr.tnt.domain.IMAGE_MAX_SIZE
+import co.kr.tnt.domain.UserProfilePolicy
import co.kr.tnt.domain.model.User
import co.kr.tnt.feature.trainer.connect.R
import co.kr.tnt.trainer.connect.TrainerConnectContract.TrainerConnectUiState
@@ -117,7 +117,7 @@ private fun ProfileSection(
.data(profile.image)
.placeholder(defaultImage)
.error(DefaultUserProfile.Trainer.image)
- .transformations(ResizeTransformation(IMAGE_MAX_SIZE))
+ .transformations(ResizeTransformation(UserProfilePolicy.USER_IMAGE_MAX_SIZE))
.build(),
)
Column(
diff --git a/feature/trainer/main/build.gradle.kts b/feature/trainer/main/build.gradle.kts
index 5ea8467e..7bf20a17 100644
--- a/feature/trainer/main/build.gradle.kts
+++ b/feature/trainer/main/build.gradle.kts
@@ -15,6 +15,7 @@ dependencies {
implementation(projects.feature.trainer.mypage)
implementation(projects.feature.trainer.notification)
implementation(projects.feature.trainer.addptsession)
+ implementation(projects.feature.trainer.modifymyinfo)
implementation(libs.kotlinx.immutable)
}
diff --git a/feature/trainer/main/src/main/java/co/kr/tnt/trainer/main/TrainerMainScreen.kt b/feature/trainer/main/src/main/java/co/kr/tnt/trainer/main/TrainerMainScreen.kt
index 259a26a8..e30c2965 100644
--- a/feature/trainer/main/src/main/java/co/kr/tnt/trainer/main/TrainerMainScreen.kt
+++ b/feature/trainer/main/src/main/java/co/kr/tnt/trainer/main/TrainerMainScreen.kt
@@ -14,6 +14,8 @@ import co.kr.tnt.trainer.addptsession.navigation.navigateToAddPtSession
import co.kr.tnt.trainer.feedback.navigation.trainerFeedbackNavGraph
import co.kr.tnt.trainer.home.navigation.trainerHomeNavGraph
import co.kr.tnt.trainer.members.navigation.trainerMembersNavGraph
+import co.kr.tnt.trainer.modifymyinfo.navigation.navigateToTrainerModifyMyInfo
+import co.kr.tnt.trainer.modifymyinfo.navigation.trainerModifyMyInfo
import co.kr.tnt.trainer.mypage.navigation.trainerMyPageNavGraph
import co.kr.tnt.trainer.notification.navigation.navigateToTrainerNotification
import co.kr.tnt.trainer.notification.navigation.trainerNotification
@@ -88,8 +90,13 @@ private fun TrainerMainScreen(
trainerMyPageNavGraph(
padding = innerPadding,
navigateToLogin = navigateToLogin,
+ navigateToModifyMyInfo = navController::navigateToTrainerModifyMyInfo,
navigateToWebView = navigateToWebView,
- )
+ ) {
+ trainerModifyMyInfo(
+ navigateToPrevious = navController::safePopBackStack,
+ )
+ }
}
}
}
diff --git a/feature/trainer/modifymyinfo/.gitignore b/feature/trainer/modifymyinfo/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/trainer/modifymyinfo/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/trainer/modifymyinfo/build.gradle.kts b/feature/trainer/modifymyinfo/build.gradle.kts
new file mode 100644
index 00000000..ff8dfdde
--- /dev/null
+++ b/feature/trainer/modifymyinfo/build.gradle.kts
@@ -0,0 +1,13 @@
+import co.kr.tnt.setNamespace
+
+plugins {
+ id("tnt.android.feature")
+}
+
+android {
+ setNamespace("feature.trainer.modifymyinfo")
+}
+
+dependencies {
+ implementation(libs.kotlinx.immutable)
+}
diff --git a/feature/trainer/modifymyinfo/src/main/AndroidManifest.xml b/feature/trainer/modifymyinfo/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8072ee00
--- /dev/null
+++ b/feature/trainer/modifymyinfo/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/feature/trainer/modifymyinfo/src/main/java/co/kr/tnt/trainer/modifymyinfo/TrainerModifyMyInfo.kt b/feature/trainer/modifymyinfo/src/main/java/co/kr/tnt/trainer/modifymyinfo/TrainerModifyMyInfo.kt
new file mode 100644
index 00000000..92157a2d
--- /dev/null
+++ b/feature/trainer/modifymyinfo/src/main/java/co/kr/tnt/trainer/modifymyinfo/TrainerModifyMyInfo.kt
@@ -0,0 +1,170 @@
+package co.kr.tnt.trainer.modifymyinfo
+
+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.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+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.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import co.kr.tnt.core.ui.R.string.core_cancel
+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_name
+import co.kr.tnt.core.ui.R.string.core_name_placeholder
+import co.kr.tnt.core.ui.R.string.core_ok
+import co.kr.tnt.core.ui.R.string.core_text_length_and_format_warning
+import co.kr.tnt.core.ui.R.string.core_unsaved_changes_warning
+import co.kr.tnt.designsystem.component.TnTIconPopupDialog
+import co.kr.tnt.designsystem.component.TnTLabeledTextFieldWithCounter
+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.snackbar.LocalSnackbar
+import co.kr.tnt.designsystem.theme.TnTTheme
+import co.kr.tnt.domain.UserProfilePolicy
+import co.kr.tnt.trainer.modifymyinfo.TrainerModifyMyInfoContract.TrainerModifyMyInfoEffect
+import co.kr.tnt.trainer.modifymyinfo.TrainerModifyMyInfoContract.TrainerModifyMyInfoUiEvent
+import co.kr.tnt.trainer.modifymyinfo.TrainerModifyMyInfoContract.TrainerModifyMyInfoUiState
+import co.kr.tnt.trainer.modifymyinfo.TrainerModifyMyInfoContract.TrainerModifyMyInfoUiState.DialogState
+import co.kr.tnt.ui.model.DefaultUserProfile
+import co.kr.tnt.ui.utils.convertToAllowedImageFormat
+import coil.compose.rememberAsyncImagePainter
+import coil.request.ImageRequest
+import kotlinx.coroutines.flow.collectLatest
+
+@Composable
+internal fun TrainerModifyMyInfoRoute(
+ viewModel: TrainerModifyMyInfoViewModel = hiltViewModel(),
+ navigateToPrevious: () -> Unit,
+) {
+ val context = LocalContext.current
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ val snackbar = LocalSnackbar.current
+
+ TrainerModifyMyInfoScreen(
+ state = state,
+ onClickBack = { viewModel.setEvent(TrainerModifyMyInfoUiEvent.OnClickBack) },
+ onNameChange = { name -> viewModel.setEvent(TrainerModifyMyInfoUiEvent.OnNameChange(name)) },
+ onClickComplete = { viewModel.setEvent(TrainerModifyMyInfoUiEvent.OnClickComplete) },
+ onProfileImageSelect = { uri ->
+ val profileImageFile = uri.convertToAllowedImageFormat(context)
+ viewModel.setEvent(TrainerModifyMyInfoUiEvent.OnProfileImageSelect(profileImageFile))
+ },
+ )
+
+ 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(core_cancel),
+ rightButtonText = stringResource(core_ok),
+ onLeftButtonClick = { viewModel.setEvent(TrainerModifyMyInfoUiEvent.OnDismissDialog) },
+ onRightButtonClick = { viewModel.setEvent(TrainerModifyMyInfoUiEvent.OnClickDialogConfirm) },
+ onDismiss = { viewModel.setEvent(TrainerModifyMyInfoUiEvent.OnDismissDialog) },
+ )
+ }
+ }
+
+ LaunchedEffect(viewModel.effect) {
+ viewModel.effect.collectLatest { effect ->
+ when (effect) {
+ TrainerModifyMyInfoEffect.NavigateToPrevious -> navigateToPrevious()
+ is TrainerModifyMyInfoEffect.ShowToast -> snackbar.show(effect.message)
+ }
+ }
+ }
+}
+
+@Composable
+private fun TrainerModifyMyInfoScreen(
+ state: TrainerModifyMyInfoUiState,
+ onClickBack: () -> Unit,
+ onNameChange: (name: String) -> Unit,
+ onClickComplete: () -> Unit,
+ onProfileImageSelect: (uri: Uri) -> Unit,
+) {
+ BackHandler { onClickBack() }
+
+ val pickMediaLauncher = rememberLauncherForActivityResult(PickVisualMedia()) { uri ->
+ uri?.let(onProfileImageSelect)
+ }
+
+ val painter = rememberAsyncImagePainter(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(state.profileImage)
+ .placeholder(DefaultUserProfile.Trainer.image)
+ .error(DefaultUserProfile.Trainer.image)
+ .build(),
+ )
+
+ Scaffold(
+ topBar = {
+ TnTTopBarWithBackButton(
+ title = "내 정보 수정",
+ onBackClick = onClickBack,
+ )
+ },
+ containerColor = TnTTheme.colors.commonColors.Common0,
+ ) { padding ->
+ Column(
+ modifier = Modifier.padding(padding),
+ ) {
+ TnTProfileImage(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp),
+ image = painter,
+ defaultImage = painterResource(DefaultUserProfile.Trainer.image),
+ imageSize = 132.dp,
+ onEditClick = {
+ pickMediaLauncher.launch(
+ PickVisualMediaRequest(
+ mediaType = PickVisualMedia.ImageOnly,
+ ),
+ )
+ },
+ )
+ Spacer(modifier = Modifier.height(48.dp))
+ TnTLabeledTextFieldWithCounter(
+ title = stringResource(core_name),
+ value = state.name,
+ onValueChange = { newValue ->
+ onNameChange(newValue)
+ },
+ modifier = Modifier.padding(horizontal = 20.dp),
+ placeholder = stringResource(core_name_placeholder),
+ maxLength = UserProfilePolicy.USER_NAME_MAX_LENGTH,
+ isSingleLine = true,
+ showWarning = state.isValidName.not(),
+ isRequired = true,
+ warningMessage = stringResource(
+ core_text_length_and_format_warning,
+ UserProfilePolicy.USER_NAME_MAX_LENGTH,
+ ),
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ TnTBottomButton(
+ text = stringResource(core_complete),
+ enabled = state.isEnableComplete,
+ onClick = onClickComplete,
+ )
+ }
+ }
+}
diff --git a/feature/trainer/modifymyinfo/src/main/java/co/kr/tnt/trainer/modifymyinfo/TrainerModifyMyInfoContract.kt b/feature/trainer/modifymyinfo/src/main/java/co/kr/tnt/trainer/modifymyinfo/TrainerModifyMyInfoContract.kt
new file mode 100644
index 00000000..c01240db
--- /dev/null
+++ b/feature/trainer/modifymyinfo/src/main/java/co/kr/tnt/trainer/modifymyinfo/TrainerModifyMyInfoContract.kt
@@ -0,0 +1,40 @@
+package co.kr.tnt.trainer.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 java.io.File
+
+internal class TrainerModifyMyInfoContract {
+ data class TrainerModifyMyInfoUiState(
+ val profileImage: String? = null,
+ val name: String = "",
+ val dialogState: DialogState = DialogState.NONE,
+ val isEnableComplete: Boolean = false,
+ ) : UiState {
+ val isValidName
+ get() = name.isBlank() ||
+ name.matches(UserProfilePolicy.USER_NAME_REGEX) &&
+ name.length <= UserProfilePolicy.USER_NAME_MAX_LENGTH
+
+ enum class DialogState {
+ NONE,
+ CONFIRM_EXIT,
+ }
+ }
+
+ sealed interface TrainerModifyMyInfoUiEvent : UiEvent {
+ data object OnClickBack : TrainerModifyMyInfoUiEvent
+ data object OnClickComplete : TrainerModifyMyInfoUiEvent
+ data class OnNameChange(val name: String) : TrainerModifyMyInfoUiEvent
+ data class OnProfileImageSelect(val image: File) : TrainerModifyMyInfoUiEvent
+ data object OnClickDialogConfirm : TrainerModifyMyInfoUiEvent
+ data object OnDismissDialog : TrainerModifyMyInfoUiEvent
+ }
+
+ sealed interface TrainerModifyMyInfoEffect : UiSideEffect {
+ data class ShowToast(val message: String) : TrainerModifyMyInfoEffect
+ data object NavigateToPrevious : TrainerModifyMyInfoEffect
+ }
+}
diff --git a/feature/trainer/modifymyinfo/src/main/java/co/kr/tnt/trainer/modifymyinfo/TrainerModifyMyInfoViewModel.kt b/feature/trainer/modifymyinfo/src/main/java/co/kr/tnt/trainer/modifymyinfo/TrainerModifyMyInfoViewModel.kt
new file mode 100644
index 00000000..7b25a2ca
--- /dev/null
+++ b/feature/trainer/modifymyinfo/src/main/java/co/kr/tnt/trainer/modifymyinfo/TrainerModifyMyInfoViewModel.kt
@@ -0,0 +1,136 @@
+package co.kr.tnt.trainer.modifymyinfo
+
+import androidx.lifecycle.viewModelScope
+import co.kr.tnt.domain.UserProfilePolicy
+import co.kr.tnt.domain.model.ProfileImageUpdatePolicy
+import co.kr.tnt.domain.model.User
+import co.kr.tnt.domain.repository.TrainerRepository
+import co.kr.tnt.trainer.modifymyinfo.TrainerModifyMyInfoContract.TrainerModifyMyInfoEffect
+import co.kr.tnt.trainer.modifymyinfo.TrainerModifyMyInfoContract.TrainerModifyMyInfoUiEvent
+import co.kr.tnt.trainer.modifymyinfo.TrainerModifyMyInfoContract.TrainerModifyMyInfoUiState
+import co.kr.tnt.trainer.modifymyinfo.TrainerModifyMyInfoContract.TrainerModifyMyInfoUiState.DialogState
+import co.kr.tnt.ui.base.BaseViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import java.io.File
+import javax.inject.Inject
+
+@HiltViewModel
+internal class TrainerModifyMyInfoViewModel @Inject constructor(
+ private val trainerRepository: TrainerRepository,
+) :
+ BaseViewModel(
+ TrainerModifyMyInfoUiState(),
+ ) {
+ private var initializedInfo: User.Trainer? = null
+ private var profileImageUpdatePolicy: ProfileImageUpdatePolicy = ProfileImageUpdatePolicy.Keep
+
+ init {
+ initMyInfo()
+ }
+
+ override suspend fun handleEvent(event: TrainerModifyMyInfoUiEvent) {
+ when (event) {
+ TrainerModifyMyInfoUiEvent.OnClickBack -> {
+ if (
+ isUpdateInfo(
+ initializedInfo = initializedInfo,
+ name = currentState.name,
+ image = currentState.profileImage,
+ )
+ ) {
+ updateState { copy(dialogState = DialogState.CONFIRM_EXIT) }
+ return
+ }
+
+ sendEffect(TrainerModifyMyInfoEffect.NavigateToPrevious)
+ }
+
+ is TrainerModifyMyInfoUiEvent.OnClickComplete -> {
+ viewModelScope.launch {
+ runCatching {
+ trainerRepository.updateUserInfo(
+ profileImageUpdatePolicy = profileImageUpdatePolicy,
+ name = currentState.name,
+ )
+ }.onSuccess {
+ sendEffect(TrainerModifyMyInfoEffect.NavigateToPrevious)
+ }.onFailure {
+ sendEffect(TrainerModifyMyInfoEffect.ShowToast("서버 요청에 실패했어요"))
+ }
+ }
+ }
+
+ is TrainerModifyMyInfoUiEvent.OnNameChange -> {
+ updateState {
+ copy(
+ name = event.name,
+ isEnableComplete = isEnableModifyInfo(
+ initializedInfo = initializedInfo,
+ name = event.name,
+ image = currentState.profileImage,
+ ),
+ )
+ }
+ }
+
+ is TrainerModifyMyInfoUiEvent.OnProfileImageSelect -> {
+ profileImageUpdatePolicy = ProfileImageUpdatePolicy.Change(File(event.image.path))
+ updateState {
+ copy(
+ profileImage = event.image.path,
+ isEnableComplete = isEnableModifyInfo(
+ initializedInfo = initializedInfo,
+ name = currentState.name,
+ image = event.image.path,
+ ),
+ )
+ }
+ }
+
+ TrainerModifyMyInfoUiEvent.OnClickDialogConfirm -> {
+ updateState { copy(dialogState = DialogState.NONE) }
+ sendEffect(TrainerModifyMyInfoEffect.NavigateToPrevious)
+ }
+
+ TrainerModifyMyInfoUiEvent.OnDismissDialog -> updateState { copy(dialogState = DialogState.NONE) }
+ }
+ }
+
+ private fun initMyInfo() {
+ viewModelScope.launch {
+ runCatching {
+ trainerRepository.getMyInfo().first()
+ }.onSuccess { myInfo ->
+ initializedInfo = myInfo
+
+ updateState {
+ copy(
+ name = myInfo.name,
+ profileImage = myInfo.image,
+ )
+ }
+ }.onFailure {
+ sendEffect(TrainerModifyMyInfoEffect.ShowToast("서버 요청에 실패했어요"))
+ }
+ }
+ }
+
+ private fun isEnableModifyInfo(
+ initializedInfo: User.Trainer?,
+ name: String,
+ image: String?,
+ ): Boolean =
+ isUpdateInfo(initializedInfo, name, image) &&
+ name.isNotBlank() &&
+ name.matches(UserProfilePolicy.USER_NAME_REGEX) &&
+ name.length <= UserProfilePolicy.USER_NAME_MAX_LENGTH
+
+ private fun isUpdateInfo(
+ initializedInfo: User.Trainer?,
+ name: String,
+ image: String?,
+ ): Boolean =
+ initializedInfo?.let { it.name != name || it.image != image } ?: false
+ }
diff --git a/feature/trainer/modifymyinfo/src/main/java/co/kr/tnt/trainer/modifymyinfo/navigation/TrainerModifyMyInfoNavigation.kt b/feature/trainer/modifymyinfo/src/main/java/co/kr/tnt/trainer/modifymyinfo/navigation/TrainerModifyMyInfoNavigation.kt
new file mode 100644
index 00000000..81f14e40
--- /dev/null
+++ b/feature/trainer/modifymyinfo/src/main/java/co/kr/tnt/trainer/modifymyinfo/navigation/TrainerModifyMyInfoNavigation.kt
@@ -0,0 +1,25 @@
+package co.kr.tnt.trainer.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.trainer.modifymyinfo.TrainerModifyMyInfoRoute
+
+fun NavController.navigateToTrainerModifyMyInfo(
+ navOptions: NavOptionsBuilder.() -> Unit = {},
+) = navigate(
+ route = Route.TrainerModifyMyInfo,
+ builder = navOptions,
+)
+
+fun NavGraphBuilder.trainerModifyMyInfo(
+ navigateToPrevious: () -> Unit,
+) {
+ composable {
+ TrainerModifyMyInfoRoute(
+ navigateToPrevious = navigateToPrevious,
+ )
+ }
+}
diff --git a/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/TrainerMyPageContract.kt b/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/TrainerMyPageContract.kt
index 767717b4..efae11c1 100644
--- a/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/TrainerMyPageContract.kt
+++ b/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/TrainerMyPageContract.kt
@@ -34,6 +34,7 @@ internal class TrainerMyPageContract {
data object OnClickOpenSourceLicense : TrainerMyPageUiEvent
data object OnClickLogout : TrainerMyPageUiEvent
data object OnClickDeleteAccount : TrainerMyPageUiEvent
+ data object OnClickModifyMyInfo : TrainerMyPageUiEvent
data object OnClickDialogConfirm : TrainerMyPageUiEvent
data object OnDismissDialog : TrainerMyPageUiEvent
}
@@ -42,6 +43,7 @@ internal class TrainerMyPageContract {
data object NavigateToLogin : TrainerMyPageSideEffect
data class NavigateToWebView(val url: String) : TrainerMyPageSideEffect
data object NavigateToOpenSourceLicense : TrainerMyPageSideEffect
+ data object NavigateToModifyMyInfo : TrainerMyPageSideEffect
data class RequestPermission(val isExplicitlyDenied: Boolean) : TrainerMyPageSideEffect
data class ShowToast(val message: DisplayText) : TrainerMyPageSideEffect
}
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 f7a43d1b..b0ac7ba4 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
@@ -40,6 +40,7 @@ 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
@@ -48,6 +49,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
@@ -70,6 +74,7 @@ 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 co.kr.tnt.core.designsystem.R as designSystemR
@OptIn(ExperimentalPermissionsApi::class)
@@ -77,6 +82,7 @@ import co.kr.tnt.core.designsystem.R as designSystemR
internal fun TrainerMyPageRoute(
padding: PaddingValues,
navigateToLogin: () -> Unit,
+ navigateToModifyMyInfo: () -> Unit,
navigateToWebView: (String) -> Unit,
viewModel: TrainerMyPageViewModel = hiltViewModel(),
) {
@@ -100,6 +106,7 @@ internal fun TrainerMyPageRoute(
onClickTermsOfService = { viewModel.setEvent(TrainerMyPageUiEvent.OnClickTermsOfService) },
onClickPrivacy = { viewModel.setEvent(TrainerMyPageUiEvent.OnClickPrivacy) },
onClickOpenSourceLicense = { viewModel.setEvent(TrainerMyPageUiEvent.OnClickOpenSourceLicense) },
+ onClickModifyMyInfo = { viewModel.setEvent(TrainerMyPageUiEvent.OnClickModifyMyInfo) },
onClickLogout = { viewModel.setEvent(TrainerMyPageUiEvent.OnClickLogout) },
onClickDeleteAccount = { viewModel.setEvent(TrainerMyPageUiEvent.OnClickDeleteAccount) },
)
@@ -115,16 +122,17 @@ internal fun TrainerMyPageRoute(
}
LaunchedEffect(viewModel.effect) {
- viewModel.effect.collect { effect ->
+ viewModel.effect.collectLatest { effect ->
when (effect) {
TrainerMyPageSideEffect.NavigateToLogin -> navigateToLogin()
+ TrainerMyPageSideEffect.NavigateToModifyMyInfo -> navigateToModifyMyInfo()
is TrainerMyPageSideEffect.NavigateToWebView -> navigateToWebView(effect.url)
is TrainerMyPageSideEffect.ShowToast -> toast.show(effect.message.asString(context))
is TrainerMyPageSideEffect.RequestPermission -> {
if (effect.isExplicitlyDenied) {
context.moveToAppSetting()
- return@collect
+ return@collectLatest
}
permissionState.launchMultiplePermissionRequest()
@@ -146,6 +154,7 @@ private fun TrainerMyPageScreen(
onClickTermsOfService: () -> Unit,
onClickPrivacy: () -> Unit,
onClickOpenSourceLicense: () -> Unit,
+ onClickModifyMyInfo: () -> Unit,
onClickLogout: () -> Unit,
onClickDeleteAccount: () -> Unit,
) {
@@ -180,7 +189,14 @@ private fun TrainerMyPageScreen(
style = TnTTheme.typography.h2,
color = TnTTheme.colors.neutralColors.Neutral950,
)
- 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,
+ )
+ Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
@@ -399,6 +415,7 @@ private fun TrainerMyPageScreenPreview() {
onClickOpenSourceLicense = { },
onClickLogout = { },
onClickDeleteAccount = { },
+ onClickModifyMyInfo = { },
)
}
}
diff --git a/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/TrainerMyPageViewModel.kt b/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/TrainerMyPageViewModel.kt
index eb859305..a301c523 100644
--- a/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/TrainerMyPageViewModel.kt
+++ b/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/TrainerMyPageViewModel.kt
@@ -15,6 +15,7 @@ import co.kr.tnt.trainer.mypage.TrainerMyPageContract.TrainerMyPageUiState.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
@@ -29,13 +30,14 @@ internal class TrainerMyPageViewModel @Inject constructor(
) : BaseViewModel(TrainerMyPageUiState()) {
init {
viewModelScope.launch {
- runCatching {
- trainerRepository.getMyInfo()
- }.onSuccess { user ->
- updateState { copy(user = user) }
- }.onFailure {
- sendEffect(TrainerMyPageSideEffect.ShowToast(DisplayText.Resource(core_failed_to_server_request)))
- }
+ trainerRepository.getMyInfo()
+ .onEach { user ->
+ updateState { copy(user = user) }
+ }
+ .catch {
+ sendEffect(TrainerMyPageSideEffect.ShowToast(DisplayText.Resource(core_failed_to_server_request)))
+ }
+ .launchIn(viewModelScope)
settingRepository.isEnablePushNotification()
.onEach { isEnablePushNotification ->
@@ -69,8 +71,12 @@ internal class TrainerMyPageViewModel @Inject constructor(
copy(dialogState = DialogState.DELETE_ACCOUNT_CONFIRM)
}
- TrainerMyPageUiEvent.OnDismissDialog -> updateState { copy(dialogState = DialogState.NONE) }
+ TrainerMyPageUiEvent.OnClickModifyMyInfo -> sendEffect(
+ TrainerMyPageSideEffect.NavigateToModifyMyInfo,
+ )
+
TrainerMyPageUiEvent.OnClickDialogConfirm -> handleDialogConfirm()
+ TrainerMyPageUiEvent.OnDismissDialog -> updateState { copy(dialogState = DialogState.NONE) }
}
}
diff --git a/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/navigation/TrainerMyPageNavigation.kt b/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/navigation/TrainerMyPageNavigation.kt
index 3e2bc093..82213f68 100644
--- a/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/navigation/TrainerMyPageNavigation.kt
+++ b/feature/trainer/mypage/src/main/java/co/kr/tnt/trainer/mypage/navigation/TrainerMyPageNavigation.kt
@@ -20,6 +20,7 @@ fun NavController.navigateToTrainerMyPage(
fun NavGraphBuilder.trainerMyPageNavGraph(
padding: PaddingValues,
navigateToLogin: () -> Unit,
+ navigateToModifyMyInfo: () -> Unit,
navigateToWebView: (String) -> Unit,
myPageDestination: NavGraphBuilder.() -> Unit = { },
) {
@@ -28,6 +29,7 @@ fun NavGraphBuilder.trainerMyPageNavGraph(
TrainerMyPageRoute(
padding = padding,
navigateToLogin = navigateToLogin,
+ navigateToModifyMyInfo = navigateToModifyMyInfo,
navigateToWebView = navigateToWebView,
)
}
diff --git a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerProfileSetupPage.kt b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerProfileSetupPage.kt
index abe5c358..2a9cf106 100644
--- a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerProfileSetupPage.kt
+++ b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerProfileSetupPage.kt
@@ -26,6 +26,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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_next
import co.kr.tnt.core.ui.R.string.core_text_length_and_format_warning
import co.kr.tnt.designsystem.component.TnTLabeledTextFieldWithCounter
@@ -33,7 +34,7 @@ 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.theme.TnTTheme
-import co.kr.tnt.domain.IMAGE_MAX_SIZE
+import co.kr.tnt.domain.UserProfilePolicy
import co.kr.tnt.feature.trainer.signup.R
import co.kr.tnt.trainer.signup.TrainerSignUpContract.TrainerSignUpUiState
import co.kr.tnt.ui.coil.ResizeTransformation
@@ -42,8 +43,6 @@ import co.kr.tnt.ui.model.DefaultUserProfile
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
-private const val MAX_LENGTH = 15
-
@Composable
internal fun TrainerProfileSetupPage(
state: TrainerSignUpUiState,
@@ -64,7 +63,7 @@ internal fun TrainerProfileSetupPage(
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.data(state.image)
- .transformations(ResizeTransformation(IMAGE_MAX_SIZE))
+ .transformations(ResizeTransformation(UserProfilePolicy.USER_IMAGE_MAX_SIZE))
.build(),
)
@@ -110,12 +109,15 @@ internal fun TrainerProfileSetupPage(
onChangeName(newValue)
},
modifier = Modifier.padding(horizontal = 20.dp),
- placeholder = stringResource(R.string.name_placeholder),
- maxLength = MAX_LENGTH,
+ placeholder = stringResource(core_name_placeholder),
+ maxLength = UserProfilePolicy.USER_NAME_MAX_LENGTH,
isSingleLine = true,
showWarning = state.isNameValid.not(),
isRequired = true,
- warningMessage = stringResource(core_text_length_and_format_warning, MAX_LENGTH),
+ warningMessage = stringResource(
+ core_text_length_and_format_warning,
+ UserProfilePolicy.USER_NAME_MAX_LENGTH,
+ ),
)
}
TnTBottomButton(
diff --git a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpContract.kt b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpContract.kt
index 98c9702c..bdb68945 100644
--- a/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpContract.kt
+++ b/feature/trainer/signup/src/main/java/co/kr/tnt/trainer/signup/TrainerSignUpContract.kt
@@ -1,14 +1,13 @@
package co.kr.tnt.trainer.signup
import android.net.Uri
+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
-private const val MAX_LENGTH = 15
-
internal class TrainerSignUpContract {
data class TrainerSignUpUiState(
val page: TrainerSignUpPage = TrainerSignUpPage.ProfileSetUp,
@@ -16,11 +15,10 @@ internal class TrainerSignUpContract {
val image: Uri? = null,
val isLoading: Boolean = false,
) : UiState {
- /**
- * 입력 값을 검사해 한글/영어/공백만 허용하고 특수문자는 제거
- */
- private val nameRegex = Regex("^[a-zA-Zㄱ-ㅎㅏ-ㅣ가-힣 ]+\$")
- val isNameValid get() = name.isBlank() || name.matches(nameRegex) && name.length <= MAX_LENGTH
+ val isNameValid
+ get() = name.isBlank() ||
+ name.matches(UserProfilePolicy.USER_NAME_REGEX) &&
+ name.length <= UserProfilePolicy.USER_NAME_MAX_LENGTH
}
sealed interface TrainerSignUpUiEvent : UiEvent {
diff --git a/feature/trainer/signup/src/main/res/values/strings.xml b/feature/trainer/signup/src/main/res/values/strings.xml
index 7da43b2e..5e2fb98f 100644
--- a/feature/trainer/signup/src/main/res/values/strings.xml
+++ b/feature/trainer/signup/src/main/res/values/strings.xml
@@ -1,7 +1,6 @@
이름이 어떻게 되세요?
- 이름을 입력해주세요
만나서 반가워요\n%s 트레이너님!
트레이니와 함께\n케미를 터뜨려보세요! 🧨
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 21dc40a0..148663ae 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -58,6 +58,7 @@ include(
":feature:trainer:mypage",
":feature:trainer:notification",
":feature:trainer:addptsession",
+ ":feature:trainer:modifymyinfo",
)
include(