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(