diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e391541b..4b4dadf8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,11 +28,12 @@ android { buildConfigField("String", "BASE_URL", properties["base.url"].toString()) buildConfigField("String", "DEBUG_BASE_URL", properties["debug.base.url"].toString()) + buildConfigField("String", "DEBUG_BASE_URL", properties["debug.base.url"].toString()) // ✅ 여기로 buildConfigField("String", "KAKAO_NATIVE_KEY", properties["kakao.native.key"].toString()) buildConfigField("String", "KAKAO_REST_API_KEY", properties["kakao.rest.api"].toString()) buildConfigField("String", "NAVERMAP_CLIENT_SECRET", properties["NAVERMAP_CLIENT_SECRET"].toString()) buildConfigField("String", "NAVERMAP_CLIENT_ID", properties["NAVERMAP_CLIENT_ID"].toString()) - buildConfigField("String","GOOGLE_WEB_CLIENT_ID",properties["google.client.id"].toString()) + buildConfigField("String", "GOOGLE_WEB_CLIENT_ID", properties["google.client.id"].toString()) manifestPlaceholders["KAKAO_NATIVE_KEY"] = properties["kakao.native.key"].toString() } @@ -54,9 +55,6 @@ android { kotlinOptions { jvmTarget = "11" } - buildFeatures { - compose = true - } buildFeatures { compose = true buildConfig = true diff --git a/app/src/main/java/com/paw/key/data/di/RepositoryModule.kt b/app/src/main/java/com/paw/key/data/di/RepositoryModule.kt index b598a6f0..ba7d3b07 100644 --- a/app/src/main/java/com/paw/key/data/di/RepositoryModule.kt +++ b/app/src/main/java/com/paw/key/data/di/RepositoryModule.kt @@ -6,6 +6,9 @@ import com.paw.key.data.remote.datasource.datasourceimpl.KakaoAuthDataSourceImpl import com.paw.key.data.remote.datasource.login.AuthRemoteDataSource import com.paw.key.data.remote.datasource.login.GoogleAuthDataSource import com.paw.key.data.remote.datasource.login.KakaoAuthDataSource +import com.paw.key.data.remote.datasource.mypage.MypageDataSource +import com.paw.key.data.remote.datasource.mypage.MypageDataSourceImpl +import com.paw.key.data.repository.mypage.MypageRepositoryImpl import com.paw.key.data.repositoryimpl.ArchivedListRepositoryImpl import com.paw.key.data.repositoryimpl.LikeRepositoryImpl import com.paw.key.data.repositoryimpl.RegionRepositoryImpl @@ -30,6 +33,7 @@ import com.paw.key.domain.repository.home.RegionCurrentRepository import com.paw.key.domain.repository.image.ImageRepository import com.paw.key.domain.repository.localstorage.LocalStorageRepository import com.paw.key.domain.repository.login.AuthRepository +import com.paw.key.domain.repository.mypage.MypageRepository import com.paw.key.domain.repository.posts.PostsRepository import com.paw.key.domain.repository.user.UserRepository import com.paw.key.domain.repository.walk.WalkRepository @@ -56,6 +60,12 @@ interface RepositoryModule { impl: GoogleAuthDataSourceImpl, ): GoogleAuthDataSource + @Binds + @Singleton + fun bindMypageDataSource( + impl: MypageDataSourceImpl + ): MypageDataSource + @Binds abstract fun bindKakaoAuthDataSource( impl: KakaoAuthDataSourceImpl @@ -137,6 +147,12 @@ interface RepositoryModule { impl: LocalStorageRepositoryImpl ): LocalStorageRepository + @Binds + @Singleton + fun bindMypageRepository( + impl: MypageRepositoryImpl + ): MypageRepository + @Binds @Singleton fun bindWalkListRepository( @@ -148,4 +164,5 @@ interface RepositoryModule { fun bindWalkRepository( impl: WalkRepositoryImpl ) : WalkRepository + } diff --git a/app/src/main/java/com/paw/key/data/di/ServiceModule.kt b/app/src/main/java/com/paw/key/data/di/ServiceModule.kt index ec824e92..ab66d9b6 100644 --- a/app/src/main/java/com/paw/key/data/di/ServiceModule.kt +++ b/app/src/main/java/com/paw/key/data/di/ServiceModule.kt @@ -10,6 +10,7 @@ import com.paw.key.data.service.image.S3Service import com.paw.key.data.service.login.LoginService import com.paw.key.data.service.posts.PostsService import com.paw.key.data.service.region.RegionService +import com.paw.key.data.service.mypage.MypageService import com.paw.key.data.service.sharedwalk.SharedWalkService import com.paw.key.data.service.user.UserService import com.paw.key.data.service.walk.WalkService @@ -47,7 +48,7 @@ object ServiceModule { fun provideHomeRegionService(retrofit: Retrofit): HomeRegionService = retrofit.create() - //마이페이지 + @Provides @Singleton fun provideSavedListService(retrofit: Retrofit): SavedListService = @@ -97,4 +98,10 @@ object ServiceModule { @Singleton fun providePostsService(retrofit: Retrofit): PostsService = retrofit.create() + + + @Provides + @Singleton + fun provideMypageService(retrofit: Retrofit): MypageService = + retrofit.create() } diff --git a/app/src/main/java/com/paw/key/data/dto/request/mypage/MypageRequestDto.kt b/app/src/main/java/com/paw/key/data/dto/request/mypage/MypageRequestDto.kt new file mode 100644 index 00000000..51b72208 --- /dev/null +++ b/app/src/main/java/com/paw/key/data/dto/request/mypage/MypageRequestDto.kt @@ -0,0 +1,21 @@ +package com.paw.key.data.dto.request.mypage + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateUserRequestDto( + @SerialName("name") val name: String, + @SerialName("birth") val birth: String, + @SerialName("gender") val gender: String, +) + +@Serializable +data class UpdatePetRequestDto( + @SerialName("name") val name: String, + @SerialName("birth") val birth: String, + @SerialName("gender") val gender: String, + @SerialName("isNeutered") val isNeutered: Boolean, + @SerialName("breedId") val breedId: Int, + @SerialName("imageId") val imageId: Int, +) \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/data/dto/response/mypage/MypageResponseDto.kt b/app/src/main/java/com/paw/key/data/dto/response/mypage/MypageResponseDto.kt new file mode 100644 index 00000000..bf6e6563 --- /dev/null +++ b/app/src/main/java/com/paw/key/data/dto/response/mypage/MypageResponseDto.kt @@ -0,0 +1,34 @@ +package com.paw.key.data.dto.response.mypage + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RoutePostListResponseDto( + @SerialName("posts") val posts: List, +) + +@Serializable +data class RoutePostDto( + @SerialName("postId") val postId: Int, + @SerialName("regionName") val regionName: String, + @SerialName("title") val title: String, + @SerialName("date") val date: String, + @SerialName("durationMinutes") val durationMinutes: Int, + @SerialName("isLiked") val isLiked: Boolean, + @SerialName("imageUrl") val imageUrl: String, +) + +@Serializable +data class ReviewPostListResponseDto( + @SerialName("posts") val posts: List, +) + +@Serializable +data class ReviewPostDto( + @SerialName("postId") val postId: Int, + @SerialName("title") val title: String, + @SerialName("regionName") val regionName: String, + @SerialName("date") val date: String, + @SerialName("categoryOptionSummary") val categoryOptionSummary: List, +) \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/data/remote/datasource/datasourceimpl/MypageDataSourceImpl.kt b/app/src/main/java/com/paw/key/data/remote/datasource/datasourceimpl/MypageDataSourceImpl.kt new file mode 100644 index 00000000..41713a30 --- /dev/null +++ b/app/src/main/java/com/paw/key/data/remote/datasource/datasourceimpl/MypageDataSourceImpl.kt @@ -0,0 +1,29 @@ +package com.paw.key.data.remote.datasource.mypage + +import com.paw.key.data.dto.request.mypage.UpdatePetRequestDto +import com.paw.key.data.dto.request.mypage.UpdateUserRequestDto +import com.paw.key.data.dto.response.BaseResponse +import com.paw.key.data.dto.response.mypage.ReviewPostListResponseDto +import com.paw.key.data.dto.response.mypage.RoutePostListResponseDto +import com.paw.key.data.service.mypage.MypageService +import javax.inject.Inject + +class MypageDataSourceImpl @Inject constructor( + private val mypageService: MypageService, +) : MypageDataSource { + + override suspend fun updateUser(body: UpdateUserRequestDto): BaseResponse = + mypageService.updateUser(body) + + override suspend fun updatePet(body: UpdatePetRequestDto): BaseResponse = + mypageService.updatePet(body) + + override suspend fun getMyRoutes(): BaseResponse = + mypageService.getMyRoutes() + + override suspend fun getLikedPosts(): BaseResponse = + mypageService.getLikedPosts() + + override suspend fun getMyReviews(): BaseResponse = + mypageService.getMyReviews() +} \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/data/remote/datasource/mypage/MypageDataSource.kt b/app/src/main/java/com/paw/key/data/remote/datasource/mypage/MypageDataSource.kt new file mode 100644 index 00000000..6acd5c9d --- /dev/null +++ b/app/src/main/java/com/paw/key/data/remote/datasource/mypage/MypageDataSource.kt @@ -0,0 +1,15 @@ +package com.paw.key.data.remote.datasource.mypage + +import com.paw.key.data.dto.request.mypage.UpdatePetRequestDto +import com.paw.key.data.dto.request.mypage.UpdateUserRequestDto +import com.paw.key.data.dto.response.BaseResponse +import com.paw.key.data.dto.response.mypage.ReviewPostListResponseDto +import com.paw.key.data.dto.response.mypage.RoutePostListResponseDto + +interface MypageDataSource { + suspend fun updateUser(body: UpdateUserRequestDto): BaseResponse + suspend fun updatePet(body: UpdatePetRequestDto): BaseResponse + suspend fun getMyRoutes(): BaseResponse + suspend fun getLikedPosts(): BaseResponse + suspend fun getMyReviews(): BaseResponse +} \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/data/repositoryimpl/mypage/MypageRepositoryImpl.kt b/app/src/main/java/com/paw/key/data/repositoryimpl/mypage/MypageRepositoryImpl.kt new file mode 100644 index 00000000..21f4818d --- /dev/null +++ b/app/src/main/java/com/paw/key/data/repositoryimpl/mypage/MypageRepositoryImpl.kt @@ -0,0 +1,56 @@ +package com.paw.key.data.repository.mypage + +import com.paw.key.core.util.suspendRunCatching +import com.paw.key.data.dto.request.mypage.UpdatePetRequestDto +import com.paw.key.data.dto.request.mypage.UpdateUserRequestDto +import com.paw.key.data.remote.datasource.mypage.MypageDataSource +import com.paw.key.domain.entity.mypage.ReviewPostEntity +import com.paw.key.domain.entity.mypage.RoutePostEntity +import com.paw.key.domain.entity.mypage.toEntity +import com.paw.key.domain.repository.mypage.MypageRepository +import javax.inject.Inject + +class MypageRepositoryImpl @Inject constructor( + private val dataSource: MypageDataSource, +) : MypageRepository { + + override suspend fun updateUser(name: String, birth: String, gender: String): Result = + suspendRunCatching { + dataSource.updateUser(UpdateUserRequestDto(name = name, birth = birth, gender = gender)) + } + + override suspend fun updatePet( + name: String, + birth: String, + gender: String, + isNeutered: Boolean, + breedId: Int, + imageId: Int, + ): Result = suspendRunCatching { + dataSource.updatePet( + UpdatePetRequestDto( + name = name, + birth = birth, + gender = gender, + isNeutered = isNeutered, + breedId = breedId, + imageId = imageId, + ) + ) + } + + override suspend fun getMyRoutes(): Result> = + suspendRunCatching { + dataSource.getMyRoutes().data?.posts?.map { it.toEntity() } ?: emptyList() + } + + override suspend fun getLikedPosts(): Result> = + suspendRunCatching { + dataSource.getLikedPosts().data?.posts?.map { it.toEntity() } ?: emptyList() + } + + override suspend fun getMyReviews(): Result> = + suspendRunCatching { + dataSource.getMyReviews().data?.posts?.map { it.toEntity() } ?: emptyList() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/data/service/mypage/MypageService.kt b/app/src/main/java/com/paw/key/data/service/mypage/MypageService.kt new file mode 100644 index 00000000..2b3e6aa0 --- /dev/null +++ b/app/src/main/java/com/paw/key/data/service/mypage/MypageService.kt @@ -0,0 +1,32 @@ +package com.paw.key.data.service.mypage + +import com.paw.key.data.dto.request.mypage.UpdatePetRequestDto +import com.paw.key.data.dto.request.mypage.UpdateUserRequestDto +import com.paw.key.data.dto.response.BaseResponse +import com.paw.key.data.dto.response.mypage.ReviewPostListResponseDto +import com.paw.key.data.dto.response.mypage.RoutePostListResponseDto +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PATCH + +interface MypageService { + + @PATCH("users/me") + suspend fun updateUser( + @Body body: UpdateUserRequestDto, + ): BaseResponse + + @PATCH("pets/me") + suspend fun updatePet( + @Body body: UpdatePetRequestDto, + ): BaseResponse + + @GET("users/me/routes") + suspend fun getMyRoutes(): BaseResponse + + @GET("users/me/likes") + suspend fun getLikedPosts(): BaseResponse + + @GET("users/me/reviews") + suspend fun getMyReviews(): BaseResponse +} \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/domain/entity/mypage/MypageEntity.kt b/app/src/main/java/com/paw/key/domain/entity/mypage/MypageEntity.kt new file mode 100644 index 00000000..8770aebb --- /dev/null +++ b/app/src/main/java/com/paw/key/domain/entity/mypage/MypageEntity.kt @@ -0,0 +1,40 @@ +package com.paw.key.domain.entity.mypage + +import com.paw.key.data.dto.response.mypage.ReviewPostDto +import com.paw.key.data.dto.response.mypage.RoutePostDto + +data class RoutePostEntity( + val postId: Int, + val regionName: String, + val title: String, + val date: String, + val durationMinutes: Int, + val isLiked: Boolean, + val imageUrl: String, +) + +data class ReviewPostEntity( + val postId: Int, + val title: String, + val regionName: String, + val date: String, + val categoryOptionSummary: List, +) + +fun RoutePostDto.toEntity() = RoutePostEntity( + postId = postId, + regionName = regionName, + title = title, + date = date, + durationMinutes = durationMinutes, + isLiked = isLiked, + imageUrl = imageUrl, +) + +fun ReviewPostDto.toEntity() = ReviewPostEntity( + postId = postId, + title = title, + regionName = regionName, + date = date, + categoryOptionSummary = categoryOptionSummary, +) \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/domain/entity/petprofile/PetProfileEntity.kt b/app/src/main/java/com/paw/key/domain/entity/petprofile/PetProfileEntity.kt index 8d68a59b..3047643b 100644 --- a/app/src/main/java/com/paw/key/domain/entity/petprofile/PetProfileEntity.kt +++ b/app/src/main/java/com/paw/key/domain/entity/petprofile/PetProfileEntity.kt @@ -11,4 +11,4 @@ data class PetProfileEntity( val breed: String, val dbtiName: String?, val dbtiDescription: String?, -) +) \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/domain/repository/mypage/MypageRepository.kt b/app/src/main/java/com/paw/key/domain/repository/mypage/MypageRepository.kt new file mode 100644 index 00000000..a469f027 --- /dev/null +++ b/app/src/main/java/com/paw/key/domain/repository/mypage/MypageRepository.kt @@ -0,0 +1,12 @@ +package com.paw.key.domain.repository.mypage + +import com.paw.key.domain.entity.mypage.ReviewPostEntity +import com.paw.key.domain.entity.mypage.RoutePostEntity + +interface MypageRepository { + suspend fun updateUser(name: String, birth: String, gender: String): Result + suspend fun updatePet(name: String, birth: String, gender: String, isNeutered: Boolean, breedId: Int, imageId: Int): Result + suspend fun getMyRoutes(): Result> + suspend fun getLikedPosts(): Result> + suspend fun getMyReviews(): Result> +} \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/domain/repository/petprofile/PetProfileRepository.kt b/app/src/main/java/com/paw/key/domain/repository/petprofile/PetProfileRepository.kt deleted file mode 100644 index 03de6217..00000000 --- a/app/src/main/java/com/paw/key/domain/repository/petprofile/PetProfileRepository.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.paw.key.domain.repository.petprofile - -import com.paw.key.domain.entity.petprofile.PetProfileEntity - -interface PetProfileRepository { - suspend fun getPetProfiles(userId: Int): Result> -} \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/presentation/ui/main/MainNavigator.kt b/app/src/main/java/com/paw/key/presentation/ui/main/MainNavigator.kt index 18662d51..b3384541 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/main/MainNavigator.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/main/MainNavigator.kt @@ -18,7 +18,7 @@ import com.paw.key.presentation.ui.home.navigation.navigateHomeLocationSetting import com.paw.key.presentation.ui.login.navigation.navigateLogin import com.paw.key.presentation.ui.mypage.main.navigation.navigateMyPage import com.paw.key.presentation.ui.mypage.route.courseinfo.model.CourseType -import com.paw.key.presentation.ui.mypage.route.courseinfo.navigation.navigateCourseInfo +import com.paw.key.presentation.ui.mypage.route.courseinfo.navigation.navigateToCourseInfo import com.paw.key.presentation.ui.mypage.route.petinfo.navigation.navigatePetProfile import com.paw.key.presentation.ui.mypage.route.petinfo.navigation.navigatePetProfileList import com.paw.key.presentation.ui.mypage.route.userinfo.navigation.navigateUserProfile @@ -98,7 +98,7 @@ class MainNavigator( courseType: CourseType, navOptions: NavOptions? = null, ) { - navController.navigateCourseInfo(courseType = courseType, navOptions = navOptions) + navController.navigateToCourseInfo(courseType = courseType, navOptions = navOptions) } // Todo : 나중에 로직 플로우 확인하고 수정예정 diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/petinfo/viewmodel/PetProfileViewModel.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/petinfo/viewmodel/PetProfileViewModel.kt new file mode 100644 index 00000000..bc9ebf10 --- /dev/null +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/petinfo/viewmodel/PetProfileViewModel.kt @@ -0,0 +1,106 @@ +package com.paw.key.presentation.ui.mypage.petinfo.viewmodel + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.paw.key.domain.repository.localstorage.LocalStorageRepository +import com.paw.key.domain.repository.mypage.MypageRepository +import com.paw.key.domain.repository.user.UserRepository +import com.paw.key.presentation.ui.mypage.model.toUiModel +import com.paw.key.presentation.ui.mypage.route.petinfo.model.PetProfileSideEffect +import com.paw.key.presentation.ui.mypage.route.petinfo.model.PetProfileState +import com.paw.key.presentation.ui.signup.state.Gender +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PetProfileViewModel @Inject constructor( + private val mypageRepository: MypageRepository, + + private val userRepository: UserRepository, + private val localRepository: LocalStorageRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(PetProfileState()) + val state: StateFlow = _state.asStateFlow() + + private val _sideEffect = MutableSharedFlow() + val sideEffect = _sideEffect.asSharedFlow() + + init { + viewModelScope.launch { + getPetProfiles() + } + } + + fun onNameChange(value: String) = _state.update { it.copy(name = value) } + fun onBirthChange(value: String) = _state.update { it.copy(birthday = value) } + fun onGenderChange(value: Gender) = _state.update { it.copy(gender = value) } + fun onNeuteredChange(value: Boolean) = _state.update { it.copy(isNeutered = value) } + fun onBreedChange(breedName: String, breedId: Int) = _state.update { it.copy(breed = breedName, breedId = breedId) } + fun onImageChange(uri: Uri?) = _state.update { it.copy(imageUrl = uri) } + + fun getPetProfiles() { + viewModelScope.launch { + val petId = localRepository.getPetId() + + userRepository.getPetProfiles(petId) + .onSuccess { result -> + _state.update { currentState -> + currentState.copy( + petInfo = result.toUiModel() + ) + } + }.onFailure { + _sideEffect.emit(PetProfileSideEffect.ShowSnackBar("펫 프로필 불러오기 실패")) + } + } + } + + fun updatePet() { + if (_state.value.isLoading) return + val s = _state.value + + if (s.name.isBlank() || s.birthday.isBlank() || s.breedId == 0) { + viewModelScope.launch { + _sideEffect.emit(PetProfileSideEffect.ShowSnackBar("필수 정보를 모두 입력해주세요")) + } + return + } + + val formattedBirth = if (s.birthday.length == 8 && !s.birthday.contains("-")) { + "${s.birthday.substring(0, 4)}-${s.birthday.substring(4, 6)}-${s.birthday.substring(6, 8)}" + } else { + s.birthday.replace(".", "-").replace("/", "-") + } + + viewModelScope.launch { + _state.update { it.copy(isLoading = true) } + + mypageRepository.updatePet( + name = s.name, + birth = formattedBirth, + gender = if (s.gender == Gender.MALE) "M" else "F", + isNeutered = s.isNeutered, + breedId = s.breedId, + imageId = s.imageId + ) + .onSuccess { + _sideEffect.emit(PetProfileSideEffect.ShowSnackBar("반려견 정보가 수정되었습니다")) + _sideEffect.emit(PetProfileSideEffect.NavigateUp) + _state.update { it.copy(isLoading = false) } + } + .onFailure { e -> + _sideEffect.emit(PetProfileSideEffect.ShowSnackBar(e.message ?: "수정에 실패했습니다")) + _state.update { it.copy(isLoading = false) } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/CourseInfoScreen.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/CourseInfoScreen.kt index 60f321d9..c6448475 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/CourseInfoScreen.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/CourseInfoScreen.kt @@ -2,97 +2,146 @@ package com.paw.key.presentation.ui.mypage.route.courseinfo import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.paw.key.core.designsystem.component.TopBar import com.paw.key.core.designsystem.component.routeitem.RouteItem import com.paw.key.core.designsystem.theme.PawKeyTheme +import com.paw.key.core.util.UiState +import com.paw.key.presentation.ui.mypage.route.courseinfo.component.MyReviewCard import com.paw.key.presentation.ui.mypage.route.courseinfo.model.CourseData +import com.paw.key.presentation.ui.mypage.route.courseinfo.model.CourseInfoSideEffect +import com.paw.key.presentation.ui.mypage.route.courseinfo.model.CourseType import com.paw.key.presentation.ui.mypage.route.courseinfo.viewmodel.CourseInfoViewModel - @Composable fun CourseInfoRoute( navigateUp: () -> Unit, + courseType: CourseType, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, viewModel: CourseInfoViewModel = hiltViewModel(), ) { - val courseType = viewModel.courseType + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.sideEffect.collect { effect -> + when (effect) { + is CourseInfoSideEffect.ShowSnackBar -> snackbarHostState.showSnackbar(effect.message) + } + } + } CourseInfoScreen( - title = courseType.courseType, - courses = emptyList(), + title = viewModel.courseType.courseType, + uiState = state.courses, + courseType = viewModel.courseType, navigateUp = navigateUp, ) } - @Composable fun CourseInfoScreen( title: String, - courses: List, + courseType: CourseType, + uiState: UiState>, navigateUp: () -> Unit, modifier: Modifier = Modifier, ) { Column( modifier = modifier .fillMaxSize() - .background( - color = PawKeyTheme.colors.background - ) + .background(PawKeyTheme.colors.background), ) { - TopBar( - title = title, - onBackClick = navigateUp, - ) + TopBar(title = title, onBackClick = navigateUp) HorizontalDivider( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), thickness = 2.dp, - color = PawKeyTheme.colors.defaultButton + color = PawKeyTheme.colors.defaultButton, ) - LazyVerticalGrid( - modifier = Modifier.fillMaxSize(), - columns = GridCells.Fixed(2), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(20.dp) - ) { - items(courses.size) { index -> - val course = courses[index] - RouteItem( - location = course.location, - routeTitle = course.title, - routeImage = course.imageUrl, - routeTime = course.time, - routeDate = course.date, - onClick = {}, - onClickHeart = {} - ) + when (uiState) { + is UiState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } } - } - } -} + is UiState.Empty -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "저장된 산책이 없어요", + style = PawKeyTheme.typography.body14M, + color = PawKeyTheme.colors.gray50, + ) + } + } -@Preview(showBackground = true) -@Composable -private fun CourseInfoScreenPreview() { - PawKeyTheme { - CourseInfoScreen( - title = "", - courses = emptyList(), - navigateUp = { }, - ) + is UiState.Failure -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + // TODO: 뭘로 만들지 + } + } + + is UiState.Success -> { + when (courseType) { + CourseType.ReviewCourse -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(20.dp), + ) { + items(items = uiState.data, key = { it.postId }) { course -> + MyReviewCard( + cardTitle = course.title, + ) + } + } + } + else -> { + LazyVerticalGrid( + modifier = Modifier.fillMaxSize(), + columns = GridCells.Fixed(2), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(20.dp), + ) { + items(items = uiState.data, key = { it.postId }) { course -> + RouteItem( + location = course.location, + routeTitle = course.title, + routeImage = course.imageUrl, + routeTime = course.time, + routeDate = course.date, + onClick = {}, + onClickHeart = {}, + ) + } + } + } + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/model/CourseType.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/model/CourseType.kt index fd075b0e..edca478c 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/model/CourseType.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/model/CourseType.kt @@ -1,19 +1,53 @@ package com.paw.key.presentation.ui.mypage.route.courseinfo.model -enum class CourseType( - val courseType: String, -) { +import androidx.compose.runtime.Immutable +import com.paw.key.core.util.UiState +import com.paw.key.domain.entity.mypage.ReviewPostEntity +import com.paw.key.domain.entity.mypage.RoutePostEntity + +enum class CourseType(val courseType: String) { MyCourse(courseType = "내가 기록한 산책"), AllCourse(courseType = "저장 목록"), - ReviewCourse(courseType = "내가 남긴 후기") + ReviewCourse(courseType = "내가 남긴 후기"), } - data class CourseData( + val postId: Int, val location: String, val title: String, val imageUrl: String, - val distance: String, val time: String, val date: String, -) \ No newline at end of file + val isLiked: Boolean = false, + val categoryOptionSummary: List = emptyList(), +) + +@Immutable +data class CourseInfoState( + val courses: UiState> = UiState.Loading, +) + +sealed interface CourseInfoSideEffect { + data class ShowSnackBar(val message: String) : CourseInfoSideEffect +} + + +fun RoutePostEntity.toUiModel() = CourseData( + postId = postId, + location = regionName, + title = title, + imageUrl = imageUrl, + time = "${durationMinutes}분", + date = date.take(10), + isLiked = isLiked, +) + +fun ReviewPostEntity.toCourseData() = CourseData( + postId = postId, + location = regionName, + title = title, + imageUrl = "", + time = "", + date = date.take(10), + categoryOptionSummary = categoryOptionSummary, +) diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/model/MypageEditState.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/model/MypageEditState.kt new file mode 100644 index 00000000..c95d799b --- /dev/null +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/model/MypageEditState.kt @@ -0,0 +1,27 @@ +package com.paw.key.presentation.ui.mypage.courseinfo.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class EditUserState( + val name: String = "", + val birth: String = "", + val gender: String = "", + val isLoading: Boolean = false, +) + +@Immutable +data class EditPetState( + val name: String = "", + val birth: String = "", + val gender: String = "", + val isNeutered: Boolean = false, + val breedId: Int = 0, + val imageId: Int = 0, + val isLoading: Boolean = false, +) + +sealed interface EditSideEffect { + data class ShowSnackBar(val message: String) : EditSideEffect + data object NavigateUp : EditSideEffect +} \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/navigation/CourseInfoNavigation.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/navigation/CourseInfoNavigation.kt index bcfa510f..16478c3b 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/navigation/CourseInfoNavigation.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/navigation/CourseInfoNavigation.kt @@ -4,32 +4,27 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable +import androidx.navigation.toRoute import com.paw.key.presentation.ui.mypage.route.courseinfo.CourseInfoRoute import com.paw.key.presentation.ui.mypage.route.courseinfo.model.CourseType import kotlinx.serialization.Serializable +@Serializable +data class CourseInfoNavRoute(val courseType: CourseType) -fun NavController.navigateCourseInfo( +fun NavController.navigateToCourseInfo( courseType: CourseType, navOptions: NavOptions? = null, -) { - navigate( - CourseInfo(courseType = courseType.name), - navOptions - ) -} +) = navigate(CourseInfoNavRoute(courseType), navOptions) fun NavGraphBuilder.courseInfoNavGraph( navigateUp: () -> Unit, ) { - composable { + composable { backStackEntry -> + val route = backStackEntry.toRoute() CourseInfoRoute( navigateUp = navigateUp, + courseType = route.courseType, ) } -} - -@Serializable -data class CourseInfo( - val courseType: String, -) \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/viewmodel/CourseInfoViewModel.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/viewmodel/CourseInfoViewModel.kt index f0267c00..e7f8b4b5 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/viewmodel/CourseInfoViewModel.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/courseinfo/viewmodel/CourseInfoViewModel.kt @@ -2,35 +2,61 @@ package com.paw.key.presentation.ui.mypage.route.courseinfo.viewmodel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.paw.key.core.util.UiState +import com.paw.key.domain.repository.mypage.MypageRepository +import com.paw.key.presentation.ui.mypage.route.courseinfo.model.CourseInfoSideEffect +import com.paw.key.presentation.ui.mypage.route.courseinfo.model.CourseInfoState import com.paw.key.presentation.ui.mypage.route.courseinfo.model.CourseType -import com.paw.key.presentation.ui.mypage.route.courseinfo.navigation.CourseInfo +import com.paw.key.presentation.ui.mypage.route.courseinfo.model.toCourseData +import com.paw.key.presentation.ui.mypage.route.courseinfo.navigation.CourseInfoNavRoute import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class CourseInfoViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val mypageRepository: MypageRepository, ) : ViewModel() { - private val args = savedStateHandle.toRoute() + val courseType: CourseType = savedStateHandle.toRoute().courseType - val courseType: CourseType = CourseType.valueOf(args.courseType) + private val _state = MutableStateFlow(CourseInfoState()) + val state = _state.asStateFlow() + + private val _sideEffect = MutableSharedFlow() + val sideEffect = _sideEffect.asSharedFlow() init { - fetchCourseData() + fetchCourses() } - private fun fetchCourseData() { - when (courseType) { - CourseType.MyCourse -> { /* 내가 기록한 산책 로드 */ - } + fun fetchCourses() { + viewModelScope.launch { + _state.update { it.copy(courses = UiState.Loading) } - CourseType.AllCourse -> { /* 저장 목록 로드 */ + val result = when (courseType) { + CourseType.MyCourse -> mypageRepository.getMyRoutes().map { list -> list.map { it.toCourseData() } } + CourseType.AllCourse -> mypageRepository.getLikedPosts().map { list -> list.map { it.toCourseData() } } + CourseType.ReviewCourse -> mypageRepository.getMyReviews().map { list -> list.map { it.toCourseData() } } } - CourseType.ReviewCourse -> { /* 후기 로드 */ - } + result + .onSuccess { courses -> + _state.update { + it.copy(courses = if (courses.isEmpty()) UiState.Empty else UiState.Success(courses)) + } + } + .onFailure { e -> + _state.update { it.copy(courses = UiState.Failure(e.message ?: "불러오기 실패")) } + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/petinfo/PetProfileScreen.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/petinfo/PetProfileScreen.kt index e02843a4..b63b5a56 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/petinfo/PetProfileScreen.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/petinfo/PetProfileScreen.kt @@ -20,8 +20,10 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -47,6 +49,7 @@ import com.paw.key.core.designsystem.component.TopBar import com.paw.key.core.designsystem.theme.PawKeyTheme import com.paw.key.core.extension.noRippleClickable import com.paw.key.presentation.ui.mypage.route.petinfo.viewmodel.PetProfileViewModel +import com.paw.key.presentation.ui.mypage.route.petinfo.model.PetProfileSideEffect import com.paw.key.presentation.ui.signup.component.FormField import com.paw.key.presentation.ui.signup.component.GenderSelector import com.paw.key.presentation.ui.signup.component.PetBreedSearchContent @@ -61,26 +64,37 @@ import kotlinx.coroutines.launch @Composable fun PetProfileRoute( navigateUp: () -> Unit, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, viewModel: PetProfileViewModel = hiltViewModel(), ) { - val state = viewModel.state.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + viewModel.sideEffect.collect { effect -> + when (effect) { + is PetProfileSideEffect.ShowSnackBar -> snackbarHostState.showSnackbar(effect.message) + PetProfileSideEffect.NavigateUp -> navigateUp() + } + } + } PetProfileScreen( - petName = state.value.name, - petBirthDate = state.value.birthday, - petGender = Gender.MALE, - petNeutered = state.value.isNeutered, - petBreed = state.value.breed, - selectedImageUri = state.value.imageUrl, - navigateUp = navigateUp, - deniedPermission = {}, - onPetNameChanged = {}, - onPetBirthDateChanged = {}, - onPetGenderChanged = {}, - onPetNeuteredChanged = {}, - onPetBreedChanged = {}, - onSelectedImage = {} + petName = state.name, + petBirthDate = state.birthday, + petGender = state.gender, + petNeutered = state.isNeutered, + petBreed = state.breed, + selectedImageUri = state.imageUrl, + isLoading = state.isLoading, + navigateUp = navigateUp, + deniedPermission = {}, + onPetNameChanged = viewModel::onNameChange, + onPetBirthDateChanged = viewModel::onBirthChange, + onPetGenderChanged = viewModel::onGenderChange, + onPetNeuteredChanged = viewModel::onNeuteredChange, + onPetBreedChanged = { viewModel.onBreedChange(it.name, it.id) }, + onSelectedImage = viewModel::onImageChange, + onSaveClick = viewModel::updatePet, ) } @@ -93,6 +107,7 @@ fun PetProfileScreen( petNeutered: Boolean, petBreed: String, selectedImageUri: Uri?, + isLoading: Boolean, navigateUp: () -> Unit, deniedPermission: () -> Unit, onPetNameChanged: (String) -> Unit, @@ -101,6 +116,7 @@ fun PetProfileScreen( onPetNeuteredChanged: (Boolean) -> Unit, onPetBreedChanged: (PetInfoItemModel) -> Unit, onSelectedImage: (Uri?) -> Unit, + onSaveClick: () -> Unit, modifier: Modifier = Modifier, ) { var isSheetOpen by remember { mutableStateOf(false) } @@ -112,70 +128,52 @@ fun PetProfileScreen( val photoPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia(), - onResult = { uri -> - onSelectedImage(uri) - } + onResult = onSelectedImage, ) - // 구버전 권한 요청용 val legacyGalleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent(), - onResult = { uri -> - onSelectedImage(uri) - } + onResult = onSelectedImage, ) val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), onResult = { isGranted -> - if (isGranted) { - legacyGalleryLauncher.launch("image/*") - } else { - deniedPermission - } - } + if (isGranted) legacyGalleryLauncher.launch("image/*") + else deniedPermission() + }, ) - - Column( modifier = modifier .fillMaxSize() - .background(color = PawKeyTheme.colors.background) + .background(PawKeyTheme.colors.background), ) { - TopBar( - title = " 반려견 정보 수정", - onBackClick = navigateUp, - modifier = Modifier, - ) + TopBar(title = "반려견 정보 수정", onBackClick = navigateUp) HorizontalDivider( - modifier = Modifier - .fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), thickness = 2.dp, - color = PawKeyTheme.colors.defaultButton + color = PawKeyTheme.colors.defaultButton, ) LazyColumn( - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 18.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.padding(horizontal = 16.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { item { SignUpPetImageHolder( uri = selectedImageUri, - modifier = Modifier - .noRippleClickable { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - photoPickerLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - } else { - permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) - } + modifier = Modifier.noRippleClickable { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } else { + permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) } + }, ) - } item { @@ -183,23 +181,15 @@ fun PetProfileScreen( label = "이름", content = { SignUpTextField( - value = petName, - onValueChange = { - if (it.length <= 8) { - onPetNameChanged(it) - } - }, - placeholder = "최대 8글자 이내로 입력해주세요", - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Next - ), + value = petName, + onValueChange = { if (it.length <= 8) onPetNameChanged(it) }, + placeholder = "최대 8글자 이내로 입력해주세요", + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardActions = KeyboardActions( - onDone = { - petBirthDateFocusRequester.requestFocus() - } + onNext = { petBirthDateFocusRequester.requestFocus() } ), ) - } + }, ) } @@ -208,99 +198,81 @@ fun PetProfileScreen( label = "생년월일", content = { SignUpTextField( - modifier = Modifier - .focusRequester(petBirthDateFocusRequester), - value = petBirthDate, - onValueChange = { - if (it.length <= 8) { - onPetBirthDateChanged(it) - } - }, - placeholder = "YYYYMMDD", + modifier = Modifier.focusRequester(petBirthDateFocusRequester), + value = petBirthDate, + onValueChange = { if (it.length <= 8) onPetBirthDateChanged(it) }, + placeholder = "YYYYMMDD", keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done + imeAction = ImeAction.Done, ), keyboardActions = KeyboardActions( - onDone = { - focusManager.clearFocus() - } + onDone = { focusManager.clearFocus() } ), -// visualTransformation = DateVisualTransformation() ) - } + }, ) - } + item { FormField( label = "성별", content = { GenderSelector( - selectedGender = petGender, + selectedGender = petGender, onGenderSelected = onPetGenderChanged, - type = "반려 동물" + type = "반려 동물", ) - } + }, ) - } item { SignUpNeuteringCheckRadio( isNeutered = petNeutered, - onToggle = { onPetNeuteredChanged(!petNeutered) }, - modifier = Modifier - .padding(top = 8.dp) + onToggle = { onPetNeuteredChanged(!petNeutered) }, + modifier = Modifier.padding(top = 8.dp), ) - } item { - FormField( label = "견종", content = { SignUpTextField( - value = petBreed, + value = petBreed, onValueChange = {}, - enabled = false, - placeholder = "견종을 검색해보세요", + enabled = false, + placeholder = "견종을 검색해보세요", suffix = { Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_signup_search), + imageVector = ImageVector.vectorResource(R.drawable.ic_signup_search), contentDescription = "breed search", - tint = Color.Unspecified + tint = Color.Unspecified, ) }, - modifier = Modifier - .noRippleClickable { - scope.launch { - isSheetOpen = true - } - } + modifier = Modifier.noRippleClickable { + scope.launch { isSheetOpen = true } + }, ) - } + }, ) + if (isSheetOpen) { PawKeyBottomSheet( onDismissRequest = { isSheetOpen = false }, - sheetState = sheetState, - //sheetGesturesEnabled = false, - ) { sheetState -> + sheetState = sheetState, + ) { state -> PetBreedSearchContent( - petBreedList = persistentListOf(), - sheetState = sheetState, + petBreedList = persistentListOf(), + sheetState = state, selectedBreed = petBreed, - onBreedSelected = { - onPetBreedChanged(it) + onBreedSelected = { breed -> + onPetBreedChanged(breed) scope.launch { - - sheetState.hide() + state.hide() }.invokeOnCompletion { - if (!sheetState.isVisible) { - isSheetOpen = false - } + if (!state.isVisible) isSheetOpen = false } }, ) @@ -309,18 +281,16 @@ fun PetProfileScreen( } } - Spacer(modifier = Modifier.weight(1F)) + Spacer(modifier = Modifier.weight(1f)) PawkeyButton( - text = "저장하기", - enabled = true, - onClick = { }, - modifier = Modifier - .padding(horizontal = 16.dp) + text = "저장하기", + enabled = !isLoading, + onClick = onSaveClick, + modifier = Modifier.padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(34.dp)) - } } @@ -329,20 +299,22 @@ fun PetProfileScreen( private fun PetProfileScreenPreview() { PawKeyTheme { PetProfileScreen( - petName = "꾸꾸", - petBirthDate = "꾸꾸", - petGender = Gender.MALE, - petNeutered = true, - petBreed = "꾸꾸", - selectedImageUri = null, - navigateUp = {}, - deniedPermission = {}, - onPetNameChanged = {}, + petName = "꾸꾸", + petBirthDate = "20220101", + petGender = Gender.MALE, + petNeutered = true, + petBreed = "말티즈", + selectedImageUri = null, + isLoading = false, + navigateUp = {}, + deniedPermission = {}, + onPetNameChanged = {}, onPetBirthDateChanged = {}, - onPetGenderChanged = {}, + onPetGenderChanged = {}, onPetNeuteredChanged = {}, - onPetBreedChanged = {}, - onSelectedImage = {} + onPetBreedChanged = {}, + onSelectedImage = {}, + onSaveClick = {}, ) } } \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/petinfo/model/PetProfileContract.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/petinfo/model/PetProfileContract.kt index 539b1130..131b3ae2 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/petinfo/model/PetProfileContract.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/petinfo/model/PetProfileContract.kt @@ -3,23 +3,26 @@ package com.paw.key.presentation.ui.mypage.route.petinfo.model import android.net.Uri import androidx.compose.runtime.Immutable import com.paw.key.presentation.ui.mypage.model.PetInfoModel +import com.paw.key.presentation.ui.signup.state.Gender @Immutable data class PetProfileState( + val name: String = "", + val birthday: String = "", + val gender: Gender = Gender.MALE, + val isNeutered: Boolean = false, + val breed: String = "", + val breedId: Int = 0, val imageUrl: Uri? = null, - val name: String = "까루", - val gender: String = "남아", - val birthday : String = "2020/01/01", - val breed: String = "코리안 숏헤어", - val age: String = "4세", - val isNeutered: Boolean = true, - val energyLevel: String = "활동적이에요", - val socialLevel: String = "불편해해요", - + val imageId: Int = 0, + val age: String = "", + val energyLevel: String = "", + val socialLevel: String = "", + val isLoading: Boolean = false, val petInfo: PetInfoModel = PetInfoModel() ) -sealed class PetProfileSideEffect { - data class ShowSnackBar(val message: String) : PetProfileSideEffect() - data object NavigateUp : PetProfileSideEffect() - data object NavigateNext : PetProfileSideEffect() + +sealed interface PetProfileSideEffect { + data class ShowSnackBar(val message: String) : PetProfileSideEffect + data object NavigateUp : PetProfileSideEffect } \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/petinfo/viewmodel/PetProfileViewModel.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/petinfo/viewmodel/PetProfileViewModel.kt index 2583596d..d6f72542 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/petinfo/viewmodel/PetProfileViewModel.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/petinfo/viewmodel/PetProfileViewModel.kt @@ -1,12 +1,15 @@ package com.paw.key.presentation.ui.mypage.route.petinfo.viewmodel +import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.paw.key.domain.repository.localstorage.LocalStorageRepository +import com.paw.key.domain.repository.mypage.MypageRepository import com.paw.key.domain.repository.user.UserRepository import com.paw.key.presentation.ui.mypage.model.toUiModel import com.paw.key.presentation.ui.mypage.route.petinfo.model.PetProfileSideEffect import com.paw.key.presentation.ui.mypage.route.petinfo.model.PetProfileState +import com.paw.key.presentation.ui.signup.state.Gender import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -19,20 +22,32 @@ import javax.inject.Inject @HiltViewModel class PetProfileViewModel @Inject constructor( + private val mypageRepository: MypageRepository, + private val localRepository: LocalStorageRepository, private val userRepository: UserRepository, - private val localRepository: LocalStorageRepository ) : ViewModel() { private val _state = MutableStateFlow(PetProfileState()) val state: StateFlow = _state.asStateFlow() - private val _sideEffect = MutableSharedFlow() // 필요 시 따로 Contract로 분리 가능 + private val _sideEffect = MutableSharedFlow() val sideEffect = _sideEffect.asSharedFlow() init { - getPetProfiles() + viewModelScope.launch { + getPetProfiles() + } } + fun onNameChange(value: String) = _state.update { it.copy(name = value) } + fun onBirthChange(value: String) = _state.update { it.copy(birthday = value) } + fun onGenderChange(value: Gender) = _state.update { it.copy(gender = value) } + fun onNeuteredChange(value: Boolean) = _state.update { it.copy(isNeutered = value) } + fun onBreedChange(breedName: String, breedId: Int) = + _state.update { it.copy(breed = breedName, breedId = breedId) } + + fun onImageChange(uri: Uri?) = _state.update { it.copy(imageUrl = uri) } + fun getPetProfiles() { viewModelScope.launch { val petId = localRepository.getPetId() @@ -49,4 +64,49 @@ class PetProfileViewModel @Inject constructor( } } } + + fun updatePet() { + if (_state.value.isLoading) return + val s = _state.value + + if (s.name.isBlank() || s.birthday.isBlank() || s.breedId == 0) { + viewModelScope.launch { + _sideEffect.emit(PetProfileSideEffect.ShowSnackBar("필수 정보를 모두 입력해주세요")) + } + return + } + + val formattedBirth = if (s.birthday.length == 8 && !s.birthday.contains("-")) { + "${s.birthday.substring(0, 4)}-${s.birthday.substring(4, 6)}-${ + s.birthday.substring( + 6, + 8 + ) + }" + } else { + s.birthday.replace(".", "-").replace("/", "-") + } + + viewModelScope.launch { + _state.update { it.copy(isLoading = true) } + + mypageRepository.updatePet( + name = s.name, + birth = formattedBirth, + gender = if (s.gender == Gender.MALE) "M" else "F", + isNeutered = s.isNeutered, + breedId = s.breedId, + imageId = s.imageId + ) + .onSuccess { + _sideEffect.emit(PetProfileSideEffect.ShowSnackBar("반려견 정보가 수정되었습니다")) + _sideEffect.emit(PetProfileSideEffect.NavigateUp) + _state.update { it.copy(isLoading = false) } + } + .onFailure { e -> + _sideEffect.emit(PetProfileSideEffect.ShowSnackBar(e.message ?: "수정에 실패했습니다")) + _state.update { it.copy(isLoading = false) } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/userinfo/UserProfileScreen.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/userinfo/UserProfileScreen.kt index 6ae531cd..158ef10a 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/userinfo/UserProfileScreen.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/userinfo/UserProfileScreen.kt @@ -1,5 +1,7 @@ package com.paw.key.presentation.ui.mypage.route.userinfo +import android.widget.Toast +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -9,7 +11,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -20,6 +24,7 @@ import com.paw.key.core.designsystem.theme.PawKeyTheme import com.paw.key.presentation.ui.mypage.route.userinfo.component.UserEditTextField import com.paw.key.presentation.ui.mypage.route.userinfo.component.UserGenderButton import com.paw.key.presentation.ui.mypage.route.userinfo.component.UserProfileItem +import com.paw.key.presentation.ui.mypage.route.userinfo.model.UserProfileSideEffect import com.paw.key.presentation.ui.mypage.route.userinfo.viewmodel.UserProfileViewModel @Composable @@ -29,12 +34,31 @@ fun UserProfileRoute( viewModel: UserProfileViewModel = hiltViewModel(), ) { val state = viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collect { effect -> + when (effect) { + is UserProfileSideEffect.ShowSnackBar -> { + Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + } + is UserProfileSideEffect.NavigateUp -> { + navigateUp() + } + else -> Unit + } + } + } UserProfileScreen( name = state.value.name, gender = state.value.gender, - birth = state.value.name, + birth = state.value.birth, navigateUp = navigateUp, + onNameChange = viewModel::onNameChange, + onBirthChange = viewModel::onBirthChange, + onGenderChange = viewModel::onGenderChange, + onSaveClick = viewModel::updateUser, modifier = modifier ) } @@ -45,11 +69,16 @@ private fun UserProfileScreen( gender: String, birth: String, navigateUp: () -> Unit, + onNameChange: (String) -> Unit, + onBirthChange: (String) -> Unit, + onGenderChange: (String) -> Unit, + onSaveClick: () -> Unit, modifier: Modifier = Modifier, ) { Column( modifier = modifier - .fillMaxSize(), + .fillMaxSize() + .background(color = PawKeyTheme.colors.white1), verticalArrangement = Arrangement.spacedBy(16.dp) ) { TopBar( @@ -57,7 +86,6 @@ private fun UserProfileScreen( onBackClick = navigateUp ) - Spacer(modifier = Modifier.height(4.dp)) UserProfileItem( @@ -65,7 +93,7 @@ private fun UserProfileScreen( profileItem = { UserEditTextField( value = name, - onValueChange = {}, + onValueChange = onNameChange, placeholder = "닉네임을 입력해주세요", modifier = Modifier.fillMaxWidth(), enabled = true, @@ -75,12 +103,12 @@ private fun UserProfileScreen( ) UserProfileItem( - label = "생년원일", + label = "생년월일", profileItem = { UserEditTextField( value = birth, - onValueChange = {}, - placeholder = "닉네임을 입력해주세요", + onValueChange = onBirthChange, + placeholder = "YYYY-MM-DD", modifier = Modifier.fillMaxWidth(), enabled = true, singleLine = true @@ -93,21 +121,19 @@ private fun UserProfileScreen( profileItem = { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth() ) { UserGenderButton( user = "남성", - isSelect = gender == "남성", - onClick = { }, - modifier = Modifier - .weight(1f) + isSelect = gender == "M", + onClick = { onGenderChange("M") }, + modifier = Modifier.weight(1f) ) UserGenderButton( user = "여성", - isSelect = gender == "여성", - onClick = { }, - modifier = Modifier - .weight(1f) + isSelect = gender == "F", + onClick = { onGenderChange("F") }, + modifier = Modifier.weight(1f) ) } } @@ -117,10 +143,9 @@ private fun UserProfileScreen( PawkeyButton( text = "저장하기", - enabled = true, - onClick = { }, - modifier = Modifier - .padding(horizontal = 16.dp) + enabled = name.isNotBlank() && birth.isNotBlank() && gender.isNotBlank(), + onClick = onSaveClick, + modifier = Modifier.padding(horizontal = 16.dp) ) Spacer(modifier = Modifier.height(34.dp)) @@ -133,9 +158,13 @@ private fun UserProfileScreenPreview() { PawKeyTheme { UserProfileScreen( name = "김도기", - gender = "여성", - birth = "2002/06/21", - navigateUp = {} + gender = "F", + birth = "2002-06-21", + navigateUp = {}, + onNameChange = {}, + onBirthChange = {}, + onGenderChange = {}, + onSaveClick = {} ) } } \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/userinfo/model/UserProfileContract.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/userinfo/model/UserProfileContract.kt index c5dc7941..8aee062a 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/userinfo/model/UserProfileContract.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/userinfo/model/UserProfileContract.kt @@ -4,14 +4,16 @@ import androidx.compose.runtime.Immutable @Immutable data class UserProfileState( - val name: String = "김도기", - val gender: String = "여성", - val email: String = "", - val birth: String = "" + val name: String = "", + val birth: String = "", + val gender: String = "", + val age: Int = 0, + val activeRegion: String = "", + val isLoading: Boolean = false, ) -sealed class UserProfileSideEffect{ - data class ShowSnackBar(val message: String) : UserProfileSideEffect() - data object NavigateUp : UserProfileSideEffect() - data object NavigateNext : UserProfileSideEffect() +sealed interface UserProfileSideEffect { + data class ShowSnackBar(val message: String) : UserProfileSideEffect + data object NavigateUp : UserProfileSideEffect + data object NavigateNext : UserProfileSideEffect } diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/userinfo/viewmodel/UserProfileViewModel.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/userinfo/viewmodel/UserProfileViewModel.kt index ddbf76d2..64ead0f6 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/mypage/route/userinfo/viewmodel/UserProfileViewModel.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/route/userinfo/viewmodel/UserProfileViewModel.kt @@ -1,8 +1,9 @@ package com.paw.key.presentation.ui.mypage.route.userinfo.viewmodel -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.paw.key.domain.repository.localstorage.LocalStorageRepository +import com.paw.key.domain.repository.mypage.MypageRepository import com.paw.key.domain.repository.user.UserRepository import com.paw.key.presentation.ui.mypage.route.userinfo.model.UserProfileSideEffect import com.paw.key.presentation.ui.mypage.route.userinfo.model.UserProfileState @@ -17,7 +18,9 @@ import javax.inject.Inject @HiltViewModel class UserProfileViewModel @Inject constructor( + private val userRepository: UserRepository, + private val mypageRepository: MypageRepository, ) : ViewModel() { private val _state = MutableStateFlow(UserProfileState()) @@ -30,6 +33,9 @@ class UserProfileViewModel @Inject constructor( getUserProfiles() } + fun onNameChange(value: String) = _state.update { it.copy(name = value) } + fun onBirthChange(value: String) = _state.update { it.copy(birth = value) } + fun onGenderChange(value: String) = _state.update { it.copy(gender = value) } fun getUserProfiles() { viewModelScope.launch { userRepository.getUserProfiles() @@ -41,14 +47,43 @@ class UserProfileViewModel @Inject constructor( name = result.name, gender = result.gender, birth = result.birth.orEmpty(), - email = result.email +// email = result.email ) } } .onFailure { e -> - Log.e("UserProfileViewModel", "유저 프로필 불러오기 실패", e) - _sideEffect.emit(UserProfileSideEffect.ShowSnackBar(e.message ?: "알 수 없는 오류")) + _sideEffect.emit(UserProfileSideEffect.ShowSnackBar(e.message ?: "프로필을 불러오지 못했습니다")) } } } + + fun updateUser() { + if (_state.value.isLoading) return + val s = _state.value + + if (s.name.isBlank() || s.birth.isBlank() || s.gender.isBlank()) { + viewModelScope.launch { + _sideEffect.emit(UserProfileSideEffect.ShowSnackBar("모든 정보를 입력해주세요.")) + } + return + } + + val formattedBirth = s.birth.replace(".", "-").replace("/", "-") + + viewModelScope.launch { + _state.update { it.copy(isLoading = true) } + mypageRepository.updateUser( + name = s.name, + birth = formattedBirth, + gender = s.gender + ).onSuccess { + _sideEffect.emit(UserProfileSideEffect.ShowSnackBar("프로필이 수정되었습니다")) + _sideEffect.emit(UserProfileSideEffect.NavigateUp) + _state.update { it.copy(isLoading = false) } + }.onFailure { e -> + _sideEffect.emit(UserProfileSideEffect.ShowSnackBar(e.message ?: "수정에 실패했습니다")) + _state.update { it.copy(isLoading = false) } + } + } + } } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755