diff --git a/app/src/main/java/com/paw/key/core/designsystem/component/CourseDetail.kt b/app/src/main/java/com/paw/key/core/designsystem/component/CourseDetail.kt index cd62305e..32ad988b 100644 --- a/app/src/main/java/com/paw/key/core/designsystem/component/CourseDetail.kt +++ b/app/src/main/java/com/paw/key/core/designsystem/component/CourseDetail.kt @@ -40,7 +40,9 @@ import coil.request.ImageRequest import com.paw.key.R import com.paw.key.core.designsystem.theme.PawKeyTheme import com.paw.key.core.designsystem.theme.Gray100 +import com.paw.key.core.util.noRippleClickable import com.paw.key.domain.model.entity.walklist.CategoryTop3Entity +import kotlinx.serialization.json.JsonNull.content @Composable fun CourseDetail( @@ -48,15 +50,14 @@ fun CourseDetail( petName : String, date : String, location : String, - isLike : Boolean, - content : String, + onClickLike: (Boolean) -> Unit, petProfileImage : String, routeMapImageUrl : String, categorySummary : List, categoryTop3 : List, totalReviewCount : Int, walkingImageUrls : List, - + content: String, onImageClick: (String) -> Unit, modifier: Modifier = Modifier ) { @@ -110,16 +111,18 @@ fun CourseDetail( ) Icon( - imageVector = if (isLiked.value) { - ImageVector.vectorResource(id = R.drawable.ic_eye_linear_gray_valid) - } else { - ImageVector.vectorResource(id = R.drawable.ic_eye_linear_gray_invalid) - }, + imageVector = if (isLiked.value) + ImageVector.vectorResource(id = R.drawable.ic_heart_filled) + else + ImageVector.vectorResource(id = R.drawable.ic_heart_default), contentDescription = "좋아요", tint = Color.Unspecified, - modifier = Modifier.clickable { - isLiked.value = !isLiked.value - } + modifier = Modifier + .size(24.dp) + .noRippleClickable { + isLiked.value = !isLiked.value // 로컬 상태 먼저 변경 + onClickLike(!isLiked.value) // 변경된 값을 넘김 + } ) } @@ -324,35 +327,13 @@ fun CourseDetailPreview() { petName = "핑구", date = "2025/06/30", location = "홍대입구역", - isLike = true, - content = "산책로가 깨끗하고 벚꽃이 예뻐요!", + onClickLike = {}, petProfileImage = "https://pawkey-server.com/image/profile.png", routeMapImageUrl = "https://pawkey-server.com/image/map.png", categoryTop3 = listOf( - CategoryTop3Entity( - rank = 1, - optionText = "산책로가 어쩌구 저꾸", - percentage = 42, - categoryName = "산책로가 어쩌구 저꾸", - categoryOptionId = 1, - categoryId = 2 - ), - CategoryTop3Entity( - rank = 2, - optionText = "산책로가 어쩌구 저꾸", - percentage = 37, - categoryName = "산책로가 어쩌구 저꾸", - categoryOptionId = 1, - categoryId = 2 - ), - CategoryTop3Entity( - rank = 3, - optionText = "산책로가 어쩌구 저꾸", - percentage = 35, - categoryName = "산책로가 어쩌구 저꾸", - categoryOptionId = 1, - categoryId = 2 - ) + CategoryTop3Entity(rank = 1, optionText = "산책로가 어쩌구 저꾸", percentage = 42, categoryName = "", categoryOptionId = 1, categoryId = 2), + CategoryTop3Entity(rank = 2, optionText = "풍경이 예뻐요", percentage = 37, categoryName = "", categoryOptionId = 1, categoryId = 2), + CategoryTop3Entity(rank = 3, optionText = "깨끗해요", percentage = 35, categoryName = "", categoryOptionId = 1, categoryId = 2) ), totalReviewCount = 42, walkingImageUrls = listOf( @@ -360,7 +341,8 @@ fun CourseDetailPreview() { "https://pawkey-server.com/image/walk2.jpg" ), categorySummary = listOf("안전", "편리성"), - onImageClick = {}, + content = "산책로가 깨끗하고 벚꽃이 예뻐요!", + onImageClick = {} ) } } 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 312d4514..b8beeb11 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 @@ -2,6 +2,7 @@ package com.paw.key.data.di import com.paw.key.data.repositoryimpl.ArchivedListRepositoryImpl import com.paw.key.data.repositoryimpl.DummyRepositoryImpl +import com.paw.key.data.repositoryimpl.LikeRepositoryImpl import com.paw.key.data.repositoryimpl.PetProfileRepositoryImpl import com.paw.key.data.repositoryimpl.onboarding.OnboardingInfoRepositoryImpl import com.paw.key.data.repositoryimpl.onboarding.OnboardingRegionRepositoryImpl @@ -19,6 +20,7 @@ import com.paw.key.data.repositoryimpl.walklist.WalkListDetailRepositoryImpl import com.paw.key.data.repositoryimpl.walkreview.WalkReviewRepositoryImpl import com.paw.key.domain.repository.ArchivedListRepository import com.paw.key.domain.repository.DummyRepository +import com.paw.key.domain.repository.LikeRepository import com.paw.key.domain.repository.onboarding.OnboardingInfoRepository import com.paw.key.domain.repository.onboarding.OnboardingRegionRepository import com.paw.key.domain.repository.onboarding.OnboardingRepository @@ -124,6 +126,12 @@ interface RepositoryModule { impl: ArchivedListRepositoryImpl ): ArchivedListRepository + @Binds + @Singleton + fun bindLikeRepository( + impl: LikeRepositoryImpl + ): LikeRepository + @Binds @Singleton fun bindWalkReviewRepository( 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 30ba6ea3..731acc24 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 @@ -2,6 +2,7 @@ package com.paw.key.data.di import com.paw.key.data.service.ArchivedListService import com.paw.key.data.service.DummyService +import com.paw.key.data.service.LikeService import com.paw.key.data.service.PetProfileService import com.paw.key.data.service.onboarding.OnboardingInfoService import com.paw.key.data.service.onboarding.OnboardingPetsService @@ -89,6 +90,11 @@ object ServiceModule { fun provideArchivedListService(retrofit: Retrofit): ArchivedListService = retrofit.create() + @Provides + @Singleton + fun provideLikeService(retrofit: Retrofit): LikeService = + retrofit.create() + @Provides @Singleton fun provideWalkReviewService(retrofit: Retrofit): WalkReviewService = diff --git a/app/src/main/java/com/paw/key/data/dto/response/ArchivedListResponseDto.kt b/app/src/main/java/com/paw/key/data/dto/response/ArchivedListResponseDto.kt index 516c1a24..9e3f4bf9 100644 --- a/app/src/main/java/com/paw/key/data/dto/response/ArchivedListResponseDto.kt +++ b/app/src/main/java/com/paw/key/data/dto/response/ArchivedListResponseDto.kt @@ -36,7 +36,7 @@ data class ArchivedDto( val descriptionTags: List ) { fun toEntity() = ArchivedListEntity( - postId = postId.toLong(), + postId = postId, createdAt = createdAt, isLiked = isLike, title = title, diff --git a/app/src/main/java/com/paw/key/data/repositoryimpl/LikeRepositoryImpl.kt b/app/src/main/java/com/paw/key/data/repositoryimpl/LikeRepositoryImpl.kt new file mode 100644 index 00000000..b124c658 --- /dev/null +++ b/app/src/main/java/com/paw/key/data/repositoryimpl/LikeRepositoryImpl.kt @@ -0,0 +1,28 @@ +package com.paw.key.data.repositoryimpl + +import com.paw.key.data.remote.datasource.LikeDataSource +import com.paw.key.domain.repository.LikeRepository +import javax.inject.Inject + +class LikeRepositoryImpl @Inject constructor( + private val dataSource: LikeDataSource +) : LikeRepository { + + override suspend fun likeCourse(userId: Int, courseId: Int): Result { + return try { + dataSource.likeCourse(userId, courseId) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun unlikeCourse(userId: Int, courseId: Int): Result { + return try { + dataSource.unlikeCourse(userId, courseId) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/data/service/LikeService.kt b/app/src/main/java/com/paw/key/data/service/LikeService.kt index d134e5a8..a853cd0b 100644 --- a/app/src/main/java/com/paw/key/data/service/LikeService.kt +++ b/app/src/main/java/com/paw/key/data/service/LikeService.kt @@ -7,6 +7,7 @@ import retrofit2.http.POST import retrofit2.http.Path interface LikeService { + @POST("/api/v1/likes/{courseId}") suspend fun likeCourse( @Header("X-USER-ID") userId: Int, diff --git a/app/src/main/java/com/paw/key/domain/model/entity/archivedlist/ArchivedListEntity.kt b/app/src/main/java/com/paw/key/domain/model/entity/archivedlist/ArchivedListEntity.kt index c8c587e2..7b7db616 100644 --- a/app/src/main/java/com/paw/key/domain/model/entity/archivedlist/ArchivedListEntity.kt +++ b/app/src/main/java/com/paw/key/domain/model/entity/archivedlist/ArchivedListEntity.kt @@ -5,7 +5,7 @@ data class ArchivedListPostsEntity( ) data class ArchivedListEntity( - val postId: Long, + val postId: Int, val createdAt: String, val isLiked: Boolean, val title: String, diff --git a/app/src/main/java/com/paw/key/presentation/ui/course/entire/tab/map/List/TabListScreen.kt b/app/src/main/java/com/paw/key/presentation/ui/course/entire/tab/map/List/TabListScreen.kt index c10bb98e..3cb0edb0 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/course/entire/tab/map/List/TabListScreen.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/course/entire/tab/map/List/TabListScreen.kt @@ -3,24 +3,14 @@ package com.paw.key.presentation.ui.course.entire.tab.map.List import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -41,7 +31,8 @@ import com.paw.key.presentation.ui.course.entire.tab.map.List.viewmodel.TapListV private fun PreviewTabListScreen() { PawKeyTheme { TabListScreen( - navigateToDetail = {} + navigateToDetail = {}, + onClickLike = { _, _ -> } ) } } @@ -55,7 +46,10 @@ fun TapListRoute( TabListScreen( modifier = modifier, navigateToDetail = navigateToDetail, - viewModel = viewModel + viewModel = viewModel, + onClickLike = { postId, isLiked -> + viewModel.toggleLike(postId = postId, isLiked = isLiked) + } ) } @@ -64,13 +58,13 @@ fun TabListScreen( navigateToDetail: () -> Unit, modifier: Modifier = Modifier, viewModel: TapListViewModel = hiltViewModel(), + onClickLike: (postId: Int, isLiked: Boolean) -> Unit ) { var showBottomSheet by remember { mutableStateOf(false) } val listState by viewModel.state.collectAsStateWithLifecycle() Column( - modifier = modifier - .fillMaxSize() + modifier = modifier.fillMaxSize() ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -84,10 +78,9 @@ fun TabListScreen( imageVector = ImageVector.vectorResource(R.drawable.ic_course_optin_filter), contentDescription = "filter", tint = Color.Unspecified, - modifier = Modifier - .noRippleClickable { - showBottomSheet = true - } + modifier = Modifier.noRippleClickable { + showBottomSheet = true + } ) OptionChip( text = if (viewModel.isFilterApplied()) "필터 적용됨" else "선택한 옵션이 없어요", @@ -101,10 +94,6 @@ fun TabListScreen( .background(PawKeyTheme.colors.white2) .padding(bottom = 36.dp) ) { - - // Todo : 나중에 서버용 리스트로 변경 - - // 로딩 상태 표시 if (listState.isLoading) { item { Box( @@ -134,6 +123,7 @@ fun TabListScreen( isLiked = post.isLike, onClickLike = { isLiked -> viewModel.toggleLike(post.postId, isLiked) + }, onClickItem = { navigateToDetail() } ) diff --git a/app/src/main/java/com/paw/key/presentation/ui/course/entire/tab/map/List/viewmodel/TapListViewModel.kt b/app/src/main/java/com/paw/key/presentation/ui/course/entire/tab/map/List/viewmodel/TapListViewModel.kt index d0ee6b8d..c80baa63 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/course/entire/tab/map/List/viewmodel/TapListViewModel.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/course/entire/tab/map/List/viewmodel/TapListViewModel.kt @@ -314,4 +314,20 @@ class TapListViewModel @Inject constructor( fun isFilterApplied(): Boolean { return isAllOptionsSelected() } + + fun toggleLike(postId: Int, isLiked: Boolean) { + viewModelScope.launch { + _state.update { state -> + val updatedPosts = state.postsResult?.posts?.map { + if (it.postId == postId) it.copy(isLike = isLiked) else it + } ?: emptyList() + + val updatedPostsResult = state.postsResult?.copy(posts = updatedPosts) + + state.copy( + postsResult = updatedPostsResult + ) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/ArchivedCourseListScreen.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/ArchivedCourseListScreen.kt index 0929baea..0c531d52 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/mypage/ArchivedCourseListScreen.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/ArchivedCourseListScreen.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -14,38 +13,40 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.paw.key.core.designsystem.component.CourseCard import com.paw.key.core.designsystem.component.TopBar import com.paw.key.core.designsystem.theme.PawKeyTheme -import com.paw.key.presentation.ui.mypage.state.SavedListState -import com.paw.key.presentation.ui.mypage.viewmodel.SavedListViewModel +import com.paw.key.presentation.ui.mypage.state.ArchivedListState +import com.paw.key.presentation.ui.mypage.viewmodel.ArchivedListViewModel @Composable fun ArchivedCourseRoute( navigateUp: () -> Unit, navigateNext: () -> Unit, modifier: Modifier = Modifier, - viewModel: SavedListViewModel = hiltViewModel() + viewModel: ArchivedListViewModel = hiltViewModel() ) { val state = viewModel.state.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - viewModel.getSavedList(2) - } + ArchivedCourseListScreen( state = state.value, navigateUp = navigateUp, navigateNext = navigateNext, + onClickLike = { postId, isLiked -> + viewModel.toggleLike(postId = postId, isLiked = isLiked) + }, modifier = modifier ) } @Composable fun ArchivedCourseListScreen( - state: SavedListState, + state: ArchivedListState, navigateUp: () -> Unit, navigateNext: () -> Unit, + onClickLike: (postId: Int, isLiked: Boolean) -> Unit, modifier: Modifier = Modifier ) { Column { TopBar( - title = "저장한 산책 루트", + title = "내가 기록한 산책 루트", onBackClick = navigateUp ) @@ -55,9 +56,7 @@ fun ArchivedCourseListScreen( .padding(16.dp) .background(PawKeyTheme.colors.white1) ) { - itemsIndexed( - items = state.courseList - ) { _, item -> + itemsIndexed(state.courseList) { _, item -> CourseCard( postId = item.postId.toInt(), title = item.title, @@ -69,6 +68,7 @@ fun ArchivedCourseListScreen( isLiked = item.isLiked, onClickItem = navigateNext, onClickLike = {} + ) } } @@ -79,10 +79,12 @@ fun ArchivedCourseListScreen( @Composable fun ArchivedCourseListScreenPreview() { PawKeyTheme { - SavedCourseListScreen(state = SavedListState(), + ArchivedCourseListScreen( + state = ArchivedListState(), navigateUp = {}, navigateNext = {}, onClickLike = {} + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/SavedCourseDetailScreen.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/SavedCourseDetailScreen.kt index a24a5b81..12c2127b 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/mypage/SavedCourseDetailScreen.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/SavedCourseDetailScreen.kt @@ -101,7 +101,7 @@ fun SavedCourseDetailScreen( petName = petName, date = date, location = location, - isLike = isLike, + onClickLike = {}, content = content, petProfileImage = petProfileImage, routeMapImageUrl = routeMapImageUrl, diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/SavedCourseListScreen.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/SavedCourseListScreen.kt index b5d1e8aa..c5e209af 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/mypage/SavedCourseListScreen.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/SavedCourseListScreen.kt @@ -56,9 +56,7 @@ fun SavedCourseListScreen( .padding(16.dp) .background(PawKeyTheme.colors.white1) ) { - itemsIndexed( - items = state.courseList - ) { _, item -> + itemsIndexed(state.courseList) { _, item -> CourseCard( postId = item.postId.toInt(), title = item.title, @@ -82,7 +80,8 @@ fun SavedCourseListScreen( @Composable fun SavedCourseListScreenPreview() { PawKeyTheme { - SavedCourseListScreen(state = SavedListState(), + SavedCourseListScreen( + state = SavedListState(), navigateUp = {}, navigateNext = {}, onClickLike = {} diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/viewmodel/ArchivedListViewModel.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/viewmodel/ArchivedListViewModel.kt index 84182c04..f19e3f1d 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/mypage/viewmodel/ArchivedListViewModel.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/viewmodel/ArchivedListViewModel.kt @@ -1,18 +1,62 @@ package com.paw.key.presentation.ui.mypage.viewmodel +import android.util.Log import androidx.lifecycle.ViewModel -import com.paw.key.presentation.ui.mypage.state.ArchivedDetailState +import androidx.lifecycle.viewModelScope +import com.paw.key.core.designsystem.component.CourseCard +import com.paw.key.domain.repository.ArchivedListRepository +import com.paw.key.domain.repository.SavedListRepository +import com.paw.key.presentation.ui.mypage.state.ArchivedListSideEffect import com.paw.key.presentation.ui.mypage.state.ArchivedListState -import com.paw.key.presentation.ui.mypage.state.SavedDetailState +import com.paw.key.presentation.ui.mypage.state.MyPageSideEffect +import com.paw.key.presentation.ui.mypage.state.PetProfileSideEffect.NavigateNext +import com.paw.key.presentation.ui.mypage.state.SavedListSideEffect +import com.paw.key.presentation.ui.mypage.state.SavedListState 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.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class ArchivedListViewModel @Inject constructor() : ViewModel() { +class ArchivedListViewModel @Inject constructor( + private val archivedListRepository: ArchivedListRepository +) : ViewModel() { + private val _state = MutableStateFlow(ArchivedListState()) - val state: StateFlow - get() = _state.asStateFlow() + val state: StateFlow = _state.asStateFlow() + + private val _sideEffect = MutableSharedFlow() + val sideEffect: MutableSharedFlow = _sideEffect + + fun getArchivedList(userId: Int) { + viewModelScope.launch { + archivedListRepository.getArchivedList(userId) + .onSuccess { result -> + _state.update { + it.copy( + courseList = result.posts + ) + } + } + .onFailure { e -> + Log.e("ArchivedListViewModel", "저장한 게시물 불러오기 실패", e) + _sideEffect.emit(ArchivedListSideEffect.ShowSnackBar(e.message ?: "알 수 없는 오류")) + } + } + } + fun toggleLike(postId: Int, isLiked: Boolean) { + viewModelScope.launch { + _state.update { state -> + state.copy( + courseList = state.courseList.map { + if (it.postId == postId) it.copy(isLiked = isLiked) else it + } + ) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/paw/key/presentation/ui/mypage/viewmodel/SavedListViewModel.kt b/app/src/main/java/com/paw/key/presentation/ui/mypage/viewmodel/SavedListViewModel.kt index 5fa12054..256257b9 100644 --- a/app/src/main/java/com/paw/key/presentation/ui/mypage/viewmodel/SavedListViewModel.kt +++ b/app/src/main/java/com/paw/key/presentation/ui/mypage/viewmodel/SavedListViewModel.kt @@ -46,4 +46,15 @@ class SavedListViewModel @Inject constructor( } } } + fun toggleLike(postId: Int, isLiked: Boolean) { + viewModelScope.launch { + _state.update { state -> + state.copy( + courseList = state.courseList.map { + if (it.postId == postId) it.copy(isLiked = isLiked) else it + } + ) + } + } + } } \ No newline at end of file