diff --git a/app/google-services.json b/app/google-services.json index 829ecfa9..9369dd14 100644 --- a/app/google-services.json +++ b/app/google-services.json @@ -13,6 +13,14 @@ } }, "oauth_client": [ + { + "client_id": "353417813537-gqd3v6turdn9l26rfeorapgj55pe4nd4.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.texthip.thip", + "certificate_hash": "1f2681ba8d7a53b61b3a10214109763221ed6150" + } + }, { "client_id": "353417813537-lovs0p2tb9kjnjlp493a7098ov0cb2bu.apps.googleusercontent.com", "client_type": 1, diff --git a/app/src/main/java/com/texthip/thip/MainActivity.kt b/app/src/main/java/com/texthip/thip/MainActivity.kt index 4d100d59..cc0d9d64 100644 --- a/app/src/main/java/com/texthip/thip/MainActivity.kt +++ b/app/src/main/java/com/texthip/thip/MainActivity.kt @@ -44,7 +44,16 @@ fun RootNavHost() { // --- 메인 관련 화면들 --- composable { // MainScreen으로 가는 경로 추가 - MainScreen() + MainScreen( + onNavigateToLogin = { + navController.navigate(CommonRoutes.Login) { + // 메인 화면으로 돌아올 수 없도록 모든 화면 기록 삭제 + popUpTo(navController.graph.id) { + inclusive = true + } + } + } + ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/MainScreen.kt b/app/src/main/java/com/texthip/thip/MainScreen.kt index d606b1f9..b4e336cc 100644 --- a/app/src/main/java/com/texthip/thip/MainScreen.kt +++ b/app/src/main/java/com/texthip/thip/MainScreen.kt @@ -14,7 +14,9 @@ import com.texthip.thip.ui.navigator.MainNavHost import com.texthip.thip.ui.navigator.extensions.isMainTabRoute @Composable -fun MainScreen() { +fun MainScreen( + onNavigateToLogin: () -> Unit +) { val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination @@ -28,7 +30,10 @@ fun MainScreen() { containerColor = Color.Transparent ) { innerPadding -> Box(modifier = Modifier.padding(innerPadding)) { - MainNavHost(navController) + MainNavHost( + navController = navController, + onNavigateToLogin = onNavigateToLogin + ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt index 70af1774..ddc98a50 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt @@ -17,8 +17,12 @@ import com.texthip.thip.data.model.feed.response.FeedWriteInfoResponse import com.texthip.thip.data.model.feed.response.MyFeedResponse import com.texthip.thip.data.model.feed.response.RelatedBooksResponse import com.texthip.thip.data.service.FeedService +import com.texthip.thip.ui.feed.mock.FeedStateUpdateResult import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType @@ -36,6 +40,8 @@ class FeedRepository @Inject constructor( @param:ApplicationContext private val context: Context, private val json: Json ) { + private val _feedStateUpdateResult = MutableSharedFlow() + val feedStateUpdateResult: Flow = _feedStateUpdateResult.asSharedFlow() /** 피드 작성에 필요한 카테고리 및 태그 목록 조회 */ suspend fun getFeedWriteInfo(): Result = runCatching { @@ -198,6 +204,7 @@ class FeedRepository @Inject constructor( .handleBaseResponse() .getOrThrow() } + /** 임시 파일들을 정리하는 함수 */ private fun cleanupTempFiles(tempFiles: List) { tempFiles.forEach { file -> @@ -230,19 +237,60 @@ class FeedRepository @Inject constructor( .getOrThrow() } - suspend fun changeFeedLike(feedId: Long, newLikeStatus: Boolean): Result = runCatching { + /*suspend fun changeFeedLike(feedId: Long, newLikeStatus: Boolean): Result = runCatching { val request = FeedLikeRequest(type = newLikeStatus) feedService.changeFeedLike(feedId, request) .handleBaseResponse() .getOrThrow() + }*/ + suspend fun changeFeedLike( + feedId: Long, newLikeStatus: Boolean, + currentLikeCount: Int, + currentIsSaved: Boolean + ): Result { + // 👈 3. 기존 로직을 수정하여 성공 시 방송(emit)하도록 변경 + return runCatching { + val request = FeedLikeRequest(type = newLikeStatus) + feedService.changeFeedLike(feedId, request) + .handleBaseResponse() + .getOrThrow() + }.onSuccess { response -> + // API 호출 성공 및 응답 데이터가 있을 경우 + response?.let { + // 변경된 상태를 객체로 만들어 방송(emit) + val newLikeCount = if (it.isLiked) currentLikeCount + 1 else currentLikeCount - 1 + val update = FeedStateUpdateResult( + feedId = feedId, + isLiked = it.isLiked, + likeCount = newLikeCount, + isSaved = currentIsSaved // isSaved 상태는 그대로 유지 + ) + _feedStateUpdateResult.emit(update) + } + } } /** 피드 저장 */ - suspend fun changeFeedSave(feedId: Long, newSaveStatus: Boolean): Result = runCatching { - val request = FeedSaveRequest(type = newSaveStatus) - feedService.changeFeedSave(feedId, request) - .handleBaseResponse() - .getOrThrow() - } + suspend fun changeFeedSave( + feedId: Long, newSaveStatus: Boolean, currentIsLiked: Boolean, + currentLikeCount: Int + ): Result = + runCatching { + val request = FeedSaveRequest(type = newSaveStatus) + feedService.changeFeedSave(feedId, request) + .handleBaseResponse() + .getOrThrow() + }.onSuccess { response -> + response?.let { + // API 응답(isSaved)과 파라미터로 받은 값들을 조합 + val update = FeedStateUpdateResult( + feedId = feedId, + isLiked = currentIsLiked, // isLiked 상태는 그대로 유지 + likeCount = currentLikeCount, + isSaved = it.isSaved + ) + _feedStateUpdateResult.emit(update) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/repository/UserRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/UserRepository.kt index 163badc5..58e4dc0e 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/UserRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/UserRepository.kt @@ -1,7 +1,10 @@ package com.texthip.thip.data.repository +import retrofit2.HttpException import android.util.Log import com.texthip.thip.data.manager.TokenManager +import com.texthip.thip.data.model.base.BaseResponse +import com.texthip.thip.data.model.base.ThipApiFailureException import com.texthip.thip.data.model.base.handleBaseResponse import com.texthip.thip.data.model.users.request.FollowRequest import com.texthip.thip.data.model.users.request.NicknameRequest @@ -16,13 +19,15 @@ import com.texthip.thip.data.model.users.response.OthersFollowersResponse import com.texthip.thip.data.model.users.response.SignupResponse import com.texthip.thip.data.model.users.response.UserSearchResponse import com.texthip.thip.data.service.UserService +import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton @Singleton class UserRepository @Inject constructor( private val userService: UserService, - private val tokenManager: TokenManager + private val tokenManager: TokenManager, + private val json: Json ) { //내 팔로잉 목록 조회 suspend fun getMyFollowings( @@ -79,13 +84,35 @@ class UserRepository @Inject constructor( .handleBaseResponse() .getOrThrow() } - +/* suspend fun updateProfile(request: ProfileUpdateRequest): Result = runCatching { userService.updateProfile(request) .handleBaseResponse() .getOrThrow() + }*/ + suspend fun updateProfile(request: ProfileUpdateRequest): Result { + return try { + val response = userService.updateProfile(request) + response.handleBaseResponse().getOrThrow() + Result.success(Unit) + } catch (e: HttpException) { + val errorBody = e.response()?.errorBody()?.string() + if (errorBody != null) { + try { + val baseResponse = json.decodeFromString>(errorBody) + Result.failure(ThipApiFailureException(baseResponse.code, baseResponse.message)) + } catch (jsonException: Exception) { + Result.failure(e) // JSON 파싱 실패 시 원래 HttpException 반환 + } + } else { + Result.failure(e) // 에러 본문이 없으면 원래 HttpException 반환 + } + } catch (e: Exception) { + Result.failure(e) + } } + suspend fun signup(request: SignupRequest): Result { Log.d("SignupDebug", "UserRepository.signup() 호출됨. 요청 닉네임: ${request.nickname}") diff --git a/app/src/main/java/com/texthip/thip/ui/feed/component/ImageViewerModal.kt b/app/src/main/java/com/texthip/thip/ui/feed/component/ImageViewerModal.kt index 166af14b..7ea84058 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/component/ImageViewerModal.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/component/ImageViewerModal.kt @@ -45,13 +45,13 @@ fun ImageViewerModal( .fillMaxSize() .clickable { onDismiss() } ) { - // 닫기 버튼 + // 이전 버튼 Icon( - painter = painterResource(R.drawable.ic_x), + painter = painterResource(R.drawable.ic_arrow_back), contentDescription = "닫기", tint = colors.White, modifier = Modifier - .align(Alignment.TopEnd) + .align(Alignment.TopStart) .padding(20.dp) .size(24.dp) .clickable { onDismiss() } diff --git a/app/src/main/java/com/texthip/thip/ui/feed/component/LiveSearchPeopleResult.kt b/app/src/main/java/com/texthip/thip/ui/feed/component/LiveSearchPeopleResult.kt index 07978b37..42673458 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/component/LiveSearchPeopleResult.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/component/LiveSearchPeopleResult.kt @@ -60,14 +60,17 @@ fun SearchPeopleResultPreview() { SearchPeopleResult( peopleList = listOf( MySubscriptionData( + userId = 1L, profileImageUrl = null, nickname = "Thiper_Official", role = "공식 인플루언서", roleColor = colors.NeonGreen, subscriberCount = 50, - isSubscribed = false + isSubscribed = false, + ), MySubscriptionData( + userId = 1L, profileImageUrl = null, nickname = "Thiper_Writer", role = "작가", @@ -76,6 +79,7 @@ fun SearchPeopleResultPreview() { isSubscribed = true ), MySubscriptionData( + userId = 1L, profileImageUrl = null, nickname = "Thiper_Newbie", role = "칭호칭호", diff --git a/app/src/main/java/com/texthip/thip/ui/feed/component/MyFeedCard.kt b/app/src/main/java/com/texthip/thip/ui/feed/component/MyFeedCard.kt index 1f1b46e9..9685f3f6 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/component/MyFeedCard.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/component/MyFeedCard.kt @@ -1,16 +1,24 @@ package com.texthip.thip.ui.feed.component +import androidx.compose.foundation.Image 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.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -23,6 +31,7 @@ import coil.compose.AsyncImage import com.texthip.thip.R import com.texthip.thip.ui.common.buttons.ActionBookButton import com.texthip.thip.ui.mypage.mock.FeedItem +import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @@ -36,6 +45,7 @@ fun MyFeedCard( ) { val hasImages = feedItem.imageUrls.isNotEmpty() val maxLines = if (hasImages) 3 else 8 + var isTextTruncated by remember { mutableStateOf(false) } Column( modifier = modifier @@ -48,7 +58,7 @@ fun MyFeedCard( onClick = onBookClick ) - Column( + /*Column( modifier = Modifier .clickable { onContentClick() }, verticalArrangement = Arrangement.Center, @@ -83,10 +93,61 @@ fun MyFeedCard( } } } + }*/ + Box( + modifier = Modifier + .clickable { onContentClick() } + ) { + Text( + text = feedItem.content, + style = typography.feedcopy_r400_s14_h20, + color = colors.White, + maxLines = maxLines, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + // 3. onTextLayout 콜백을 사용하여 텍스트가 잘렸는지 확인 + onTextLayout = { textLayoutResult -> + isTextTruncated = textLayoutResult.hasVisualOverflow + } + ) + + // 4. 텍스트가 잘렸을 경우에만 "...더보기" 이미지를 우측 하단에 표시 + if (isTextTruncated) { + Image( + painter = painterResource(id = R.drawable.ic_text_more), + contentDescription = null, + modifier = Modifier + .align(Alignment.BottomEnd) + .width(80.dp) + .height(24.dp) + ) + } + } + if (hasImages) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + feedItem.imageUrls.take(3).forEach { imageUrl -> + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier + .padding(end = 10.dp) + .size(100.dp), + contentScale = ContentScale.Crop + ) + } + } } + Row( - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.padding(top = 16.dp), + verticalAlignment = Alignment.CenterVertically, ) { Icon( modifier = Modifier.clickable { onLikeClick() }, @@ -122,6 +183,7 @@ fun MyFeedCard( } } } + } @Preview @@ -157,15 +219,19 @@ private fun MyFeedCardPrev() { isLiked = false, isSaved = true, isLocked = false, - imageUrls = listOf("https://example.com/image1.jpg", "https://example.com/image2.jpg") - ) - - Column { - MyFeedCard( - feedItem = feed1 - ) - MyFeedCard( - feedItem = feed2 + imageUrls = listOf( + "https://example.com/image1.jpg", + "https://example.com/image2.jpg" ) + ) + ThipTheme { + Column { + MyFeedCard( + feedItem = feed1 + ) + MyFeedCard( + feedItem = feed2 + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/component/OthersFeedCard.kt b/app/src/main/java/com/texthip/thip/ui/feed/component/OthersFeedCard.kt index 41333397..ed939bec 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/component/OthersFeedCard.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/component/OthersFeedCard.kt @@ -1,22 +1,30 @@ package com.texthip.thip.ui.feed.component +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +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.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import com.texthip.thip.R import com.texthip.thip.data.model.feed.response.FeedList import com.texthip.thip.ui.common.buttons.ActionBarButton import com.texthip.thip.ui.common.buttons.ActionBookButton +import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @@ -25,12 +33,15 @@ fun OthersFeedCard( modifier: Modifier = Modifier, feedItem: FeedList, onLikeClick: () -> Unit = {}, - onContentClick: () -> Unit = {} + onContentClick: () -> Unit = {}, + onBookmarkClick: () -> Unit = {} ) { val images = feedItem.contentUrls val hasImages = images.isNotEmpty() val maxLines = if (hasImages) 3 else 8 + var isTextTruncated by remember { mutableStateOf(false) } + Column( modifier = modifier .fillMaxWidth() @@ -42,17 +53,38 @@ fun OthersFeedCard( bookAuthor = feedItem.bookAuthor, onClick = {} ) - - Text( - text = feedItem.contentBody, - style = typography.feedcopy_r400_s14_h20, - color = colors.White, - maxLines = maxLines, + Spacer(modifier = Modifier.height(16.dp)) + Box( modifier = Modifier - .fillMaxWidth() + .fillMaxWidth() .padding(vertical = 16.dp) - ) + .clickable { onContentClick() } + ) { + Text( + text = feedItem.contentBody, + style = typography.feedcopy_r400_s14_h20, + color = colors.White, + maxLines = maxLines, + modifier = Modifier + .fillMaxWidth(), + //.clickable { onContentClick() } + onTextLayout = { textLayoutResult -> + isTextTruncated = textLayoutResult.hasVisualOverflow + } + ) + if (isTextTruncated) { + Image( + painter = painterResource(id = R.drawable.ic_text_more), + contentDescription = null, // decorative image + modifier = Modifier + .align(Alignment.BottomEnd) + .width(80.dp) + .height(24.dp) + ) + } + } + if (hasImages) { Row( modifier = Modifier @@ -78,16 +110,15 @@ fun OthersFeedCard( likeCount = feedItem.likeCount, commentCount = feedItem.commentCount, isSaveVisible = true, - onLikeClick = { -// onLikeClick(feedItem.feedId) - }, - onBookmarkClick = { - - } + isSaved = feedItem.isSaved, + onLikeClick = onLikeClick, + onCommentClick = onContentClick, + onBookmarkClick = onBookmarkClick ) } } + @Preview @Composable private fun OthersFeedCardPreview() { @@ -95,19 +126,20 @@ private fun OthersFeedCardPreview() { feedId = 1, postDate = "3시간 전", isbn = "12345", bookTitle = "미드나이트 라이브러리", bookAuthor = "매트 헤이그", - contentBody = "피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.", + contentBody = "피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.", contentUrls = listOf("https://picsum.photos/100"), likeCount = 10, commentCount = 5, isPublic = true, isSaved = true, isLiked = false, isWriter = false ) - - Column { - OthersFeedCard( - feedItem = feed - ) - OthersFeedCard( - feedItem = feed - ) + ThipTheme { + Column { + OthersFeedCard( + feedItem = feed + ) + OthersFeedCard( + feedItem = feed + ) + } } diff --git a/app/src/main/java/com/texthip/thip/ui/feed/component/PeopleRecentSearch.kt b/app/src/main/java/com/texthip/thip/ui/feed/component/PeopleRecentSearch.kt index 51f54663..0296bf88 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/component/PeopleRecentSearch.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/component/PeopleRecentSearch.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.texthip.thip.R import com.texthip.thip.ui.common.buttons.GenreChipButton +import com.texthip.thip.ui.feed.viewmodel.RecentSearchUiItem import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @@ -27,9 +28,9 @@ import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun PeopleRecentSearch( modifier: Modifier = Modifier, - recentSearches: List, + recentSearches: List, onSearchClick: (String) -> Unit, - onRemove: (String) -> Unit + onRemove: (Long) -> Unit ) { Column (modifier = modifier){ Text( @@ -50,11 +51,11 @@ fun PeopleRecentSearch( horizontalArrangement = Arrangement.spacedBy(16.dp), maxLines = 2, ) { - recentSearches.take(9).forEach { keyword -> + recentSearches.take(5).forEach { item -> GenreChipButton( - text = keyword, - onClick = { onSearchClick(keyword) }, - onCloseClick = { onRemove(keyword) } + text = item.term, + onClick = { onSearchClick(item.term) }, + onCloseClick = { onRemove(item.id) } ) } } @@ -71,15 +72,23 @@ fun PeopleRecentSearchPrev() { .padding(16.dp) ) { var searches by remember { - mutableStateOf(listOf("thip", "걔누구더라", "으아아아", "ㅇㅇ", "user.02")) + mutableStateOf( + listOf( + RecentSearchUiItem(id = 1, term = "thip"), + RecentSearchUiItem(id = 2, term = "걔누구더라"), + RecentSearchUiItem(id = 3, term = "으아아아"), + RecentSearchUiItem(id = 4, term = "ㅇㅇ"), + RecentSearchUiItem(id = 5, term = "user.02") + ) + ) } PeopleRecentSearch( recentSearches = searches, - onSearchClick = { keyword -> + onSearchClick = { searchTerm -> }, - onRemove = { keyword -> - searches = searches.filter { it != keyword } + onRemove = { searchId -> + searches = searches.filterNot { it.id == searchId } } ) } diff --git a/app/src/main/java/com/texthip/thip/ui/feed/mock/FeedStateUpdateResult.kt b/app/src/main/java/com/texthip/thip/ui/feed/mock/FeedStateUpdateResult.kt new file mode 100644 index 00000000..db2d095e --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/feed/mock/FeedStateUpdateResult.kt @@ -0,0 +1,11 @@ +package com.texthip.thip.ui.feed.mock + +import kotlinx.serialization.Serializable + +@Serializable +data class FeedStateUpdateResult( + val feedId: Long, + val isLiked: Boolean, + val likeCount: Int, + val isSaved: Boolean +) diff --git a/app/src/main/java/com/texthip/thip/ui/feed/mock/MySubscriptionData.kt b/app/src/main/java/com/texthip/thip/ui/feed/mock/MySubscriptionData.kt index 51c565b9..f9d786ad 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/mock/MySubscriptionData.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/mock/MySubscriptionData.kt @@ -6,6 +6,7 @@ import com.texthip.thip.data.model.users.response.UserItem import com.texthip.thip.utils.color.hexToColor data class MySubscriptionData( + val userId: Long? =null, val profileImageUrl: String? = null, val nickname: String, val role: String, @@ -16,6 +17,7 @@ data class MySubscriptionData( fun UserItem.toMySubscriptionData(): MySubscriptionData { return MySubscriptionData( + userId = this.userId.toLong(), profileImageUrl = this.profileImageUrl, nickname = this.nickname, role = this.aliasName, diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedCommentScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedCommentScreen.kt index 5618a7f5..4bc9eb1b 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedCommentScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedCommentScreen.kt @@ -64,6 +64,7 @@ import com.texthip.thip.ui.common.modal.DialogPopup import com.texthip.thip.ui.common.modal.ToastWithDate import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar import com.texthip.thip.ui.feed.component.ImageViewerModal +import com.texthip.thip.ui.feed.mock.FeedStateUpdateResult import com.texthip.thip.ui.feed.viewmodel.FeedDetailViewModel import com.texthip.thip.ui.group.note.component.CommentSection import com.texthip.thip.ui.group.note.viewmodel.CommentsEvent @@ -90,6 +91,18 @@ fun FeedCommentScreen( val feedDetailUiState by feedDetailViewModel.uiState.collectAsState() val commentsUiState by commentsViewModel.uiState.collectAsState() + LaunchedEffect(feedDetailUiState.feedDetail) { + feedDetailUiState.feedDetail?.let { detail -> + //커스텀객체 타입 인식오류 -> 직렬화가 아닌 잘게 쪼개어 전달 + navController.previousBackStackEntry?.savedStateHandle?.let { handle -> + handle.set("updated_feed_id", detail.feedId.toLong()) + handle.set("updated_feed_isLiked", detail.isLiked) + handle.set("updated_feed_likeCount", detail.likeCount) + handle.set("updated_feed_isSaved", detail.isSaved) + } + } + } + LaunchedEffect(feedDetailUiState.deleteSuccess) { if (feedDetailUiState.deleteSuccess) { navController.previousBackStackEntry diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedOthersScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedOthersScreen.kt index f52d41a7..365c301c 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedOthersScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedOthersScreen.kt @@ -14,18 +14,22 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import com.texthip.thip.R import com.texthip.thip.data.model.feed.response.FeedList import com.texthip.thip.data.model.feed.response.FeedUsersInfoResponse import com.texthip.thip.ui.common.header.AuthorHeader +import com.texthip.thip.ui.common.modal.ToastWithDate import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar import com.texthip.thip.ui.feed.component.FeedSubscribeBarlist import com.texthip.thip.ui.feed.component.OthersFeedCard @@ -35,6 +39,7 @@ import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography import com.texthip.thip.utils.color.hexToColor +import kotlinx.coroutines.delay @Composable fun FeedOthersScreen( @@ -44,11 +49,19 @@ fun FeedOthersScreen( viewModel: FeedOthersViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() - + val context = LocalContext.current + FeedOthersContent( uiState = uiState, onNavigateBack = onNavigateBack, onLikeClick = { feedId -> viewModel.changeFeedLike(feedId) }, + onBookmarkClick = { feedId -> viewModel.changeFeedSave(feedId) }, + onToggleFollow = { + val followedMessage = context.getString(R.string.toast_thip, uiState.userInfo?.nickname ?: "") + val unfollowedMessage = context.getString(R.string.toast_thip_cancel, uiState.userInfo?.nickname ?: "") + viewModel.toggleFollow(followedMessage, unfollowedMessage) + }, + onHideToast = viewModel::hideToast, onNavigateToSubscriptionList = onNavigateToSubscriptionList, onNavigateToFeedComment = onNavigateToFeedComment ) @@ -59,10 +72,19 @@ fun FeedOthersContent( uiState: FeedOthersUiState, onNavigateBack: () -> Unit, onLikeClick: (Long) -> Unit, + onBookmarkClick: (Long) -> Unit, + onToggleFollow: () -> Unit, + onHideToast: () -> Unit, onNavigateToSubscriptionList: (userId: Long) -> Unit, onNavigateToFeedComment: (feedId: Long) -> Unit = {}, ) { val userInfo = uiState.userInfo + LaunchedEffect(uiState.showToast) { + if (uiState.showToast) { + delay(2000) + onHideToast() + } + } Box(modifier = Modifier.fillMaxSize()) { Column( @@ -95,7 +117,7 @@ fun FeedOthersContent( R.string.thip ), // TODO: 띱하기/취소하기 로직 연결 - onButtonClick = {}, + onButtonClick = onToggleFollow, modifier = Modifier.padding(bottom = 20.dp) ) Spacer(modifier = Modifier.height(16.dp)) @@ -143,6 +165,7 @@ fun FeedOthersContent( OthersFeedCard( feedItem = feed, onLikeClick = { onLikeClick(feed.feedId) }, + onBookmarkClick = { onBookmarkClick(feed.feedId) }, onContentClick = { onNavigateToFeedComment(feed.feedId) } ) Spacer(modifier = Modifier.height(40.dp)) @@ -157,6 +180,20 @@ fun FeedOthersContent( } } } + if (uiState.showToast) { + Box( + modifier = Modifier + .fillMaxWidth() + .zIndex(1f) + .align(Alignment.TopCenter) + .padding(horizontal = 15.dp, vertical = 15.dp), + ) { + ToastWithDate( + message = uiState.toastMessage, + modifier = Modifier.fillMaxWidth() + ) + } + } } } @@ -194,6 +231,9 @@ private fun FeedOthersScreenPrev() { ), onNavigateBack = {}, onLikeClick = {}, + onToggleFollow = {}, + onHideToast = {}, + onBookmarkClick = {}, onNavigateToSubscriptionList = {} ) } diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt index 2bbc10f6..60d0e3c2 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt @@ -52,6 +52,7 @@ import com.texthip.thip.ui.common.topappbar.LogoTopAppBar import com.texthip.thip.ui.feed.component.FeedSubscribeBarlist import com.texthip.thip.ui.feed.component.MyFeedCard import com.texthip.thip.ui.feed.component.MySubscribeBarlist +import com.texthip.thip.ui.feed.mock.FeedStateUpdateResult import com.texthip.thip.ui.feed.viewmodel.FeedViewModel import com.texthip.thip.ui.mypage.component.SavedFeedCard import com.texthip.thip.ui.mypage.mock.FeedItem @@ -72,6 +73,7 @@ fun FeedScreen( onNavigateToBookDetail: (String) -> Unit = {}, resultFeedId: Long? = null, onNavigateToUserProfile: (userId: Long) -> Unit = {}, + onNavigateToSearchPeople: () -> Unit = {}, onNavigateToNotification: () -> Unit = {}, refreshFeed: Boolean? = null, onNavigateToOthersSubscription: (userId: Long) -> Unit = {}, @@ -164,7 +166,31 @@ fun FeedScreen( } } } + LaunchedEffect(Unit) { //커스텀객체 타입 인식오류 -> 직렬화가 아닌 잘게 쪼개어 전달 + navController.currentBackStackEntry?.savedStateHandle?.let { handle -> + handle.getLiveData("updated_feed_id").observeForever { feedId -> + if (feedId != null) { + val isLiked = handle.get("updated_feed_isLiked") ?: false + val likeCount = handle.get("updated_feed_likeCount") ?: 0 + val isSaved = handle.get("updated_feed_isSaved") ?: false + + val result = FeedStateUpdateResult( + feedId = feedId, + isLiked = isLiked, + likeCount = likeCount, + isSaved = isSaved + ) + feedViewModel.updateFeedStateFromResult(result) + + handle.remove("updated_feed_id") + handle.remove("updated_feed_isLiked") + handle.remove("updated_feed_likeCount") + handle.remove("updated_feed_isSaved") + } + } + } + } // 초기 로딩 상태 처리 if (feedUiState.isLoading && feedUiState.currentTabFeeds.isEmpty()) { Box( @@ -190,7 +216,7 @@ fun FeedScreen( LogoTopAppBar( leftIcon = painterResource(R.drawable.ic_plusfriend), hasNotification = false, - onLeftClick = {}, + onLeftClick = onNavigateToSearchPeople, onRightClick = onNavigateToNotification, ) Spacer(modifier = Modifier.height(32.dp)) diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/SearchPeopleScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/SearchPeopleScreen.kt index 8a6d09dd..2f66251c 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/SearchPeopleScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/SearchPeopleScreen.kt @@ -27,6 +27,7 @@ import com.texthip.thip.ui.feed.component.PeopleRecentSearch import com.texthip.thip.ui.feed.component.SearchPeopleEmptyResult import com.texthip.thip.ui.feed.component.SearchPeopleResult import com.texthip.thip.ui.feed.mock.MySubscriptionData +import com.texthip.thip.ui.feed.viewmodel.RecentSearchUiItem import com.texthip.thip.ui.feed.viewmodel.SearchPeopleUiState import com.texthip.thip.ui.feed.viewmodel.SearchPeopleViewModel import com.texthip.thip.ui.theme.ThipTheme @@ -35,11 +36,17 @@ import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun SearchPeopleScreen( - viewModel: SearchPeopleViewModel = hiltViewModel() + viewModel: SearchPeopleViewModel = hiltViewModel(), + onNavigateBack: () -> Unit, + onUserClick: (Long) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val focusManager = LocalFocusManager.current + LaunchedEffect(Unit) { + viewModel.fetchRecentSearches() + } + LaunchedEffect(uiState.isSearched) { if (uiState.isSearched) { focusManager.clearFocus() @@ -48,21 +55,24 @@ fun SearchPeopleScreen( SearchPeopleContent( uiState = uiState, + onNavigateBack = onNavigateBack, onSearchTextChanged = viewModel::onSearchTextChanged, onFinalSearch = viewModel::onFinalSearch, onRecentSearchClick = { keyword -> viewModel.onFinalSearch(keyword) }, - onRecentSearchRemove = viewModel::removeRecentSearch + onRecentSearchRemove = viewModel::removeRecentSearch, + onUserClick = { user -> onUserClick(user.userId!!.toLong())} ) } @Composable fun SearchPeopleContent( uiState: SearchPeopleUiState, + onNavigateBack: () -> Unit, onSearchTextChanged: (String) -> Unit, onFinalSearch: (String) -> Unit, onRecentSearchClick: (String) -> Unit, - onRecentSearchRemove: (String) -> Unit - + onRecentSearchRemove: (Long) -> Unit, + onUserClick: (MySubscriptionData) -> Unit ) { Column( @@ -70,7 +80,7 @@ fun SearchPeopleContent( ) { DefaultTopAppBar( title = stringResource(R.string.search_user), - onLeftClick = {}, + onLeftClick = onNavigateBack, ) Spacer(modifier = Modifier.height(16.dp)) @@ -110,7 +120,7 @@ fun SearchPeopleContent( .height(1.dp) .background(colors.DarkGrey02) ) - SearchPeopleResult(peopleList = uiState.searchResults) + SearchPeopleResult(peopleList = uiState.searchResults,onThipNumClick = onUserClick) } uiState.isSearched && uiState.searchResults.isEmpty() -> { //검색했는데 결과 없음 @@ -121,7 +131,7 @@ fun SearchPeopleContent( } uiState.searchText.isNotBlank() && !uiState.isSearched -> { //검색중 - SearchPeopleResult(peopleList = uiState.searchResults) + SearchPeopleResult(peopleList = uiState.searchResults,onThipNumClick = onUserClick) } else -> { //최근검색어 보여주기 @@ -145,12 +155,18 @@ private fun SearchPeopleContentPreview_Recent() { ThipTheme { SearchPeopleContent( uiState = SearchPeopleUiState( - recentSearches = listOf("메롱", "메메롱", "메메메롱") + recentSearches = listOf( + RecentSearchUiItem(id = 1, term = "메롱"), + RecentSearchUiItem(id = 2, term = "메메롱"), + RecentSearchUiItem(id = 3, term = "메메메롱") + ) ), onSearchTextChanged = {}, onFinalSearch = {}, onRecentSearchClick = {}, - onRecentSearchRemove = {} + onRecentSearchRemove = {}, + onUserClick = {}, + onNavigateBack = {} ) } } @@ -159,8 +175,8 @@ private fun SearchPeopleContentPreview_Recent() { @Composable private fun SearchPeopleContentPreview_Typing() { val dummyResults = listOf( - MySubscriptionData(null, "메롱이", "인플루언서", colors.NeonGreen, 12, false), - MySubscriptionData(null, "메메롱이", "칭호", colors.NeonGreen, 1, false), + MySubscriptionData(1L,null, "메롱이", "인플루언서", colors.NeonGreen, 12, false), + MySubscriptionData(1L,null, "메메롱이", "칭호", colors.NeonGreen, 1, false), ) ThipTheme { SearchPeopleContent( @@ -171,7 +187,9 @@ private fun SearchPeopleContentPreview_Typing() { onSearchTextChanged = {}, onFinalSearch = {}, onRecentSearchClick = {}, - onRecentSearchRemove = {} + onRecentSearchRemove = {}, + onUserClick = {}, + onNavigateBack = {} ) } } @@ -180,8 +198,8 @@ private fun SearchPeopleContentPreview_Typing() { @Composable private fun SearchPeopleContentPreview_Result() { val dummyResults = listOf( - MySubscriptionData(null, "Thip_Official", "인플루언서", colors.NeonGreen, 111, false), - MySubscriptionData(null, "thip01", "작가", colors.NeonGreen, 0, false) + MySubscriptionData(1L, null, "Thip_Official", "인플루언서", colors.NeonGreen, 111, false), + MySubscriptionData(1L, null, "thip01", "작가", colors.NeonGreen, 0, false) ) ThipTheme { SearchPeopleContent( @@ -193,7 +211,9 @@ private fun SearchPeopleContentPreview_Result() { onSearchTextChanged = {}, onFinalSearch = {}, onRecentSearchClick = {}, - onRecentSearchRemove = {} + onRecentSearchRemove = {}, + onUserClick = {}, + onNavigateBack = {} ) } } @@ -211,7 +231,9 @@ private fun SearchPeopleContentPreview_Empty() { onSearchTextChanged = {}, onFinalSearch = {}, onRecentSearchClick = {}, - onRecentSearchRemove = {} + onRecentSearchRemove = {}, + onUserClick = {}, + onNavigateBack = {} ) } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/usecase/ChangeFeedLikeUseCase.kt b/app/src/main/java/com/texthip/thip/ui/feed/usecase/ChangeFeedLikeUseCase.kt index 4782c16a..019bce3e 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/usecase/ChangeFeedLikeUseCase.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/usecase/ChangeFeedLikeUseCase.kt @@ -6,6 +6,13 @@ import javax.inject.Inject class ChangeFeedLikeUseCase @Inject constructor( private val feedRepository: FeedRepository ) { - suspend operator fun invoke(feedId: Long, newLikeStatus: Boolean) = - feedRepository.changeFeedLike(feedId, newLikeStatus) + suspend operator fun invoke( + feedId: Long, newLikeStatus: Boolean, currentLikeCount: Int, + currentIsSaved: Boolean + ) = + feedRepository.changeFeedLike( + feedId, newLikeStatus, + currentLikeCount, + currentIsSaved + ) } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/usecase/ChangeFeedSaveUseCase.kt b/app/src/main/java/com/texthip/thip/ui/feed/usecase/ChangeFeedSaveUseCase.kt index d59c1106..e763f643 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/usecase/ChangeFeedSaveUseCase.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/usecase/ChangeFeedSaveUseCase.kt @@ -6,6 +6,14 @@ import javax.inject.Inject class ChangeFeedSaveUseCase @Inject constructor( private val feedRepository: FeedRepository ) { - suspend operator fun invoke(feedId: Long, newSaveStatus: Boolean) = - feedRepository.changeFeedSave(feedId, newSaveStatus) + suspend operator fun invoke( + feedId: Long, newSaveStatus: Boolean, currentIsLiked: Boolean, + currentLikeCount: Int + ) = + feedRepository.changeFeedSave( + feedId, + newSaveStatus, + currentIsLiked, + currentLikeCount + ) } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedDetailViewModel.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedDetailViewModel.kt index e2543135..651177ee 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedDetailViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedDetailViewModel.kt @@ -33,9 +33,31 @@ class FeedDetailViewModel @Inject constructor( private val _uiState = MutableStateFlow(FeedDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() + init { + observeFeedUpdates() + } + private fun updateState(update: (FeedDetailUiState) -> FeedDetailUiState) { _uiState.value = update(_uiState.value) } + private fun observeFeedUpdates() { + viewModelScope.launch { + feedRepository.feedStateUpdateResult.collect { update -> + val currentFeed = _uiState.value.feedDetail + if (currentFeed != null && currentFeed.feedId.toLong() == update.feedId) { + updateState { + it.copy( + feedDetail = currentFeed.copy( + isLiked = update.isLiked, + likeCount = update.likeCount, + isSaved = update.isSaved + ) + ) + } + } + } + } + } fun loadFeedDetail(feedId: Long) { viewModelScope.launch { @@ -91,10 +113,14 @@ class FeedDetailViewModel @Inject constructor( updateState { it.copy(feedDetail = updatedFeed) } val newLikeStatus = !originalFeed.isLiked - changeFeedLikeUseCase(originalFeed.feedId.toLong(), newLikeStatus) - .onFailure { - updateState { it.copy(feedDetail = originalFeed) } - } + changeFeedLikeUseCase( + feedId = originalFeed.feedId.toLong(), + newLikeStatus = newLikeStatus, + currentLikeCount = originalFeed.likeCount, + currentIsSaved = originalFeed.isSaved + ).onFailure { + updateState { it.copy(feedDetail = originalFeed) } + } } } @@ -108,10 +134,14 @@ class FeedDetailViewModel @Inject constructor( updateState { it.copy(feedDetail = updatedFeed) } val newSaveStatus = !originalFeed.isSaved - changeFeedSaveUseCase(originalFeed.feedId.toLong(), newSaveStatus) - .onFailure { - updateState { it.copy(feedDetail = originalFeed) } - } + changeFeedSaveUseCase( + feedId = originalFeed.feedId.toLong(), + newSaveStatus = newSaveStatus, + currentIsLiked = originalFeed.isLiked, + currentLikeCount = originalFeed.likeCount + ).onFailure { + updateState { it.copy(feedDetail = originalFeed) } + } } } diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedOthersViewModel.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedOthersViewModel.kt index 26fbcb41..3c71985a 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedOthersViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedOthersViewModel.kt @@ -1,13 +1,16 @@ package com.texthip.thip.ui.feed.viewmodel import android.util.Log +import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.texthip.thip.data.model.feed.response.FeedList import com.texthip.thip.data.model.feed.response.FeedUsersInfoResponse import com.texthip.thip.data.repository.FeedRepository +import com.texthip.thip.data.repository.UserRepository import com.texthip.thip.ui.feed.usecase.ChangeFeedLikeUseCase +import com.texthip.thip.ui.feed.usecase.ChangeFeedSaveUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -20,13 +23,17 @@ data class FeedOthersUiState( val isLoading: Boolean = true, val userInfo: FeedUsersInfoResponse? = null, val feeds: List = emptyList(), - val errorMessage: String? = null + val errorMessage: String? = null, + val showToast: Boolean = false, + val toastMessage: String = "" ) @HiltViewModel class FeedOthersViewModel @Inject constructor( private val feedRepository: FeedRepository, private val changeFeedLikeUseCase: ChangeFeedLikeUseCase, + private val changeFeedSaveUseCase: ChangeFeedSaveUseCase, + private val userRepository: UserRepository, savedStateHandle: SavedStateHandle ) : ViewModel() { private val userId: Long = requireNotNull(savedStateHandle["userId"]) @@ -36,7 +43,27 @@ class FeedOthersViewModel @Inject constructor( init { fetchData() + observeFeedUpdates() } + private fun observeFeedUpdates() { + viewModelScope.launch { + feedRepository.feedStateUpdateResult.collect { update -> + val updatedFeeds = _uiState.value.feeds.map { feed -> + if (feed.feedId == update.feedId) { + feed.copy( + isLiked = update.isLiked, + likeCount = update.likeCount, + isSaved = update.isSaved + ) + } else { + feed + } + } + _uiState.update { it.copy(feeds = updatedFeeds) } + } + } + } + private fun fetchData() { viewModelScope.launch { @@ -86,10 +113,67 @@ class FeedOthersViewModel @Inject constructor( //api 호출 val newLikeStatus = !feedToUpdate.isLiked - changeFeedLikeUseCase(feedId, newLikeStatus) + changeFeedLikeUseCase( + feedId = feedId, + newLikeStatus = newLikeStatus, + currentLikeCount = feedToUpdate.likeCount, + currentIsSaved = feedToUpdate.isSaved + ).onFailure { + _uiState.update { it.copy(feeds = currentFeeds) } + } + } + } + fun changeFeedSave(feedId: Long) { + viewModelScope.launch { + val currentFeeds = _uiState.value.feeds + val feedToUpdate = currentFeeds.find { it.feedId == feedId } ?: return@launch + + //ui 먼저 변경 ( 낙관적 업데이트 ) + val newFeeds = currentFeeds.map { + if (it.feedId == feedId) { + it.copy(isSaved = !it.isSaved) // isSaved 상태 반전 + } else { + it + } + } + _uiState.update { it.copy(feeds = newFeeds) } + + //api 호출 + val newSaveStatus = !feedToUpdate.isSaved + changeFeedSaveUseCase( + feedId = feedId, + newSaveStatus = newSaveStatus, + currentIsLiked = feedToUpdate.isLiked, + currentLikeCount = feedToUpdate.likeCount + ).onFailure { + _uiState.update { it.copy(feeds = currentFeeds) } + } + } + } + fun toggleFollow(followedMessage: String, unfollowedMessage: String) { + val currentUserInfo = _uiState.value.userInfo ?: return + val currentIsFollowing = currentUserInfo.isFollowing + + //UI 즉시 변경 (낙관적 업데이트) + val optimisticUserInfo = currentUserInfo.copy(isFollowing = !currentIsFollowing) + _uiState.update { + it.copy( + userInfo = optimisticUserInfo, + showToast = true, + toastMessage = if (!currentIsFollowing) followedMessage else unfollowedMessage + ) + } + + //API 요청 + viewModelScope.launch { + userRepository.toggleFollow(followingUserId = userId, isFollowing = !currentIsFollowing) .onFailure { - _uiState.update { it.copy(feeds = currentFeeds) } + _uiState.update { it.copy(userInfo = currentUserInfo) } } } } + + fun hideToast() { + _uiState.update { it.copy(showToast = false) } + } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt index e3d0ae5a..711e35cc 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt @@ -8,6 +8,7 @@ import com.texthip.thip.data.model.feed.response.MyFeedItem import com.texthip.thip.data.model.users.response.RecentWriterList import com.texthip.thip.data.repository.FeedRepository import com.texthip.thip.data.repository.UserRepository +import com.texthip.thip.ui.feed.mock.FeedStateUpdateResult import com.texthip.thip.ui.feed.usecase.ChangeFeedLikeUseCase import com.texthip.thip.ui.feed.usecase.ChangeFeedSaveUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -360,7 +361,10 @@ class FeedViewModel @Inject constructor( //api 호출 val newLikeStatus = !feedToUpdate.isLiked - changeFeedLikeUseCase(feedId, newLikeStatus) + changeFeedLikeUseCase( + feedId, newLikeStatus, feedToUpdate.likeCount, + feedToUpdate.isSaved + ) .onFailure { _uiState.update { it.copy(allFeeds = currentFeeds) } } @@ -384,12 +388,32 @@ class FeedViewModel @Inject constructor( // API 호출 val newSaveStatus = !feedToUpdate.isSaved - changeFeedSaveUseCase(feedId, newSaveStatus) - .onFailure { - updateState { it.copy(allFeeds = currentFeeds) } - } + changeFeedSaveUseCase( + feedId = feedId, + newSaveStatus = newSaveStatus, + currentIsLiked = feedToUpdate.isLiked, + currentLikeCount = feedToUpdate.likeCount + ).onFailure { + _uiState.update { it.copy(allFeeds = currentFeeds) } + } } } + + fun updateFeedStateFromResult(result: FeedStateUpdateResult) { + val updatedFeeds = _uiState.value.allFeeds.map { feed -> + if (feed.feedId.toLong() == result.feedId) { + feed.copy( + isLiked = result.isLiked, + likeCount = result.likeCount, + isSaved = result.isSaved + ) + } else { + feed + } + } + _uiState.update { it.copy(allFeeds = updatedFeeds) } + } + fun removeDeletedFeed(feedId: Long) { val currentAllFeeds = _uiState.value.allFeeds.filterNot { it.feedId.toLong() == feedId } val currentMyFeeds = _uiState.value.myFeeds.filterNot { it.feedId.toLong() == feedId } diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/SearchPeopleViewModel.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/SearchPeopleViewModel.kt index eac8a8ac..1430aeb0 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/SearchPeopleViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/SearchPeopleViewModel.kt @@ -2,6 +2,7 @@ package com.texthip.thip.ui.feed.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.texthip.thip.data.repository.RecentSearchRepository import com.texthip.thip.data.repository.UserRepository import com.texthip.thip.ui.feed.mock.MySubscriptionData import com.texthip.thip.ui.feed.mock.toMySubscriptionData @@ -15,18 +16,23 @@ import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.onSuccess +data class RecentSearchUiItem( + val id: Long, + val term: String +) data class SearchPeopleUiState( val searchText: String = "", val isSearched: Boolean = false, // 최종 검색 완료 여부 val searchResults: List = emptyList(), - val recentSearches: List = listOf("메롱", "메메롱"), // TODO: 실제 최근 검색어 로딩 + val recentSearches: List = emptyList(), val isLoading: Boolean = false, val errorMessage: String? = null ) @HiltViewModel class SearchPeopleViewModel @Inject constructor( - private val userRepository: UserRepository + private val userRepository: UserRepository, + private val recentSearchRepository: RecentSearchRepository ) : ViewModel() { private val _uiState = MutableStateFlow(SearchPeopleUiState()) @@ -34,6 +40,38 @@ class SearchPeopleViewModel @Inject constructor( private var searchJob: Job? = null + //최근 검색어 + fun fetchRecentSearches() { + viewModelScope.launch { + recentSearchRepository.getRecentSearches("USER") + .onSuccess { response -> + val searchItems = response?.recentSearchList?.map { + RecentSearchUiItem(id = it.recentSearchId.toLong(), term = it.searchTerm) + } ?: emptyList() + _uiState.update { it.copy(recentSearches = searchItems) } + } + .onFailure { exception -> + _uiState.update { it.copy(errorMessage = exception.message) } + } + } + } + //최근 검색어 삭제 + fun removeRecentSearch(searchId: Long) { + viewModelScope.launch { + // UI에서 먼저 즉시 삭제 + val currentSearches = _uiState.value.recentSearches + val updatedSearches = currentSearches.filterNot { it.id == searchId } + _uiState.update { it.copy(recentSearches = updatedSearches) } + + // 서버에 삭제 요청 + recentSearchRepository.deleteRecentSearch(searchId.toInt()) + .onFailure { + // 실패 시 UI를 원래대로 복구 + _uiState.update { it.copy(recentSearches = currentSearches) } + } + } + } + // 사용자가 텍스트를 입력할 때 호출 fun onSearchTextChanged(text: String) { _uiState.update { it.copy(searchText = text, isSearched = false) } @@ -84,15 +122,10 @@ class SearchPeopleViewModel @Inject constructor( // 최근 검색어 관련 로직 fun addRecentSearch(keyword: String) { _uiState.update { currentState -> - val updatedSearches = (listOf(keyword) + currentState.recentSearches) - .distinct().take(10) - currentState.copy(recentSearches = updatedSearches) - } - } - - fun removeRecentSearch(keyword: String) { - _uiState.update { currentState -> - val updatedSearches = currentState.recentSearches.filterNot { it == keyword } + val newItem = RecentSearchUiItem(id = -1L, term = keyword) + val updatedSearches = (listOf(newItem) + currentState.recentSearches) + .distinctBy { it.term } + .take(5) currentState.copy(recentSearches = updatedSearches) } } diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/component/SavedFeedCard.kt b/app/src/main/java/com/texthip/thip/ui/mypage/component/SavedFeedCard.kt index 232eb249..6f6a3e94 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/component/SavedFeedCard.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/component/SavedFeedCard.kt @@ -1,7 +1,9 @@ package com.texthip.thip.ui.mypage.component +import androidx.compose.foundation.Image 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.fillMaxWidth @@ -11,10 +13,15 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -42,6 +49,7 @@ fun SavedFeedCard( ) { val hasImages = feedItem.imageUrls.isNotEmpty() val maxLines = if (hasImages) 3 else 8 + var isTextTruncated by remember { mutableStateOf(false) } Column( modifier = modifier @@ -60,7 +68,7 @@ fun SavedFeedCard( Column( modifier = Modifier .fillMaxWidth() - .padding(top = 16.dp) + .padding(vertical = 16.dp) ) { ActionBookButton( bookTitle = feedItem.bookTitle, @@ -71,24 +79,36 @@ fun SavedFeedCard( Column( modifier = Modifier - .clickable { onContentClick() }, + .clickable { onContentClick() }, // 전체 영역 클릭 유지 verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = feedItem.content, - style = typography.feedcopy_r400_s14_h20, - color = colors.White, - maxLines = maxLines, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp) - ) + Box { + Text( + text = feedItem.content, + style = typography.feedcopy_r400_s14_h20, + color = colors.White, + maxLines = maxLines, + modifier = Modifier + .fillMaxWidth(), + onTextLayout = { textLayoutResult -> + isTextTruncated = textLayoutResult.hasVisualOverflow + } + ) + if (isTextTruncated) { + Image( + painter = painterResource(id = R.drawable.ic_text_more), + contentDescription = null, + modifier = Modifier.align(Alignment.BottomEnd) + ) + } + } + if (hasImages) { Row( modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp), + .padding(vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { feedItem.imageUrls.take(3).forEach { imageUrl -> diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageEditScreen.kt b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageEditScreen.kt index 140c7ba2..12599855 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageEditScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageEditScreen.kt @@ -115,8 +115,7 @@ fun EditProfileContent( containerColor = colors.DarkGrey02, value = uiState.nickname, onValueChange = onNicknameChange, - //hint = stringResource(R.string.nickname_condition), - hint = uiState.initialNickname.takeIf { it.isNotBlank() } ?: stringResource(R.string.nickname_condition), + hint = stringResource(R.string.nickname_condition), showWarning = uiState.nicknameWarningMessageResId != null, showIcon = false, showLimit = true, diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageScreen.kt b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageScreen.kt index 4bcc60be..8ce331c8 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageScreen.kt @@ -55,12 +55,18 @@ fun MyPageScreen( onNavigateToSavedFeeds: () -> Unit, onCustomerService: () -> Unit, onNavigateToNotificationSettings: () -> Unit, - onDeleteAccount: () -> Unit + onDeleteAccount: () -> Unit, + onNavigateToLogin: () -> Unit ) { val uiState by viewModel.uiState.collectAsState() LaunchedEffect(Unit) { viewModel.fetchMyPageInfo() } + LaunchedEffect(uiState.isLogoutCompleted) { + if (uiState.isLogoutCompleted) { + onNavigateToLogin() + } + } MyPageContent( uiState = uiState, onEditProfileClick = onNavigateToEditProfile, @@ -183,7 +189,6 @@ fun MyPageContent( hasRightIcon = true, modifier = Modifier.fillMaxWidth(), onClick = { - onCustomerServiceClick val intent = Intent(Intent.ACTION_VIEW, URL_CUSTOMER_SERVICE.toUri()) context.startActivity(intent) diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/MyPageViewModel.kt b/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/MyPageViewModel.kt index 40e91a8d..2cb54f5a 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/MyPageViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/MyPageViewModel.kt @@ -2,6 +2,7 @@ package com.texthip.thip.ui.mypage.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.texthip.thip.data.manager.TokenManager import com.texthip.thip.data.repository.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -17,11 +18,13 @@ data class MyPageUiState( val aliasName: String = "", val aliasColor: String = "#0XFFFFFF", val errorMessage: String? = null, - val showLogoutDialog: Boolean = false + val showLogoutDialog: Boolean = false, + val isLogoutCompleted: Boolean = false ) @HiltViewModel class MyPageViewModel @Inject constructor( - private val userRepository: UserRepository + private val userRepository: UserRepository, + private val tokenManager: TokenManager ) : ViewModel() { private val _uiState = MutableStateFlow(MyPageUiState()) @@ -60,7 +63,9 @@ class MyPageViewModel @Inject constructor( } fun confirmLogout() { - _uiState.update { it.copy(showLogoutDialog = false) } - // TODO: 실제 로그아웃 로직 구현 + viewModelScope.launch { + tokenManager.clearTokens() + _uiState.update { it.copy(showLogoutDialog = false, isLogoutCompleted = true) } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/MainNavHost.kt b/app/src/main/java/com/texthip/thip/ui/navigator/MainNavHost.kt index 50111ae7..a52e2df5 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/MainNavHost.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/MainNavHost.kt @@ -12,7 +12,10 @@ import com.texthip.thip.ui.navigator.routes.MainTabRoutes // 메인 네비게이션 @Composable -fun MainNavHost(navController: NavHostController) { +fun MainNavHost( + navController: NavHostController, + onNavigateToLogin: () -> Unit +) { NavHost( navController = navController, startDestination = MainTabRoutes.Feed @@ -26,7 +29,10 @@ fun MainNavHost(navController: NavHostController) { navigateBack = navController::popBackStack ) searchNavigation(navController) - myPageNavigation(navController) + myPageNavigation( + navController = navController, + onNavigateToLogin = onNavigateToLogin + ) commonNavigation( navController = navController, navigateBack = navController::popBackStack diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/AuthNavigationExtensions.kt b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/AuthNavigationExtensions.kt index fb2e5a03..93c51d51 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/AuthNavigationExtensions.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/AuthNavigationExtensions.kt @@ -23,7 +23,7 @@ fun NavHostController.navigateToSignup() { * 장르 선택(회원가입) 화면으로 이동 */ fun NavHostController.navigateToSignupGenre() { - navigate(CommonRoutes.Genre) + navigate(CommonRoutes.SignupScreenRoutes.Genre) } diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt index 8b4cad32..55cd6683 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt @@ -45,6 +45,11 @@ fun NavHostController.navigateToUserProfile(userId: Long) { navigate(FeedRoutes.Others(userId)) } +//사용자 찾기 화면으로 +fun NavHostController.navigateToSearchPeople() { + navigate(FeedRoutes.SearchPeople) +} + // 띱 목록으로 이동 fun NavHostController.navigateToOthersSubscription(userId: Long) { navigate(FeedRoutes.OthersSubscription(userId)) diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/AuthNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/AuthNavigation.kt index 887c694a..d4c1a89b 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/AuthNavigation.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/AuthNavigation.kt @@ -1,7 +1,9 @@ package com.texthip.thip.ui.navigator.navigations +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable @@ -9,16 +11,17 @@ import androidx.navigation.navigation import com.texthip.thip.ui.navigator.extensions.navigateToLogin import com.texthip.thip.ui.navigator.extensions.navigateToMainAfterSignup import com.texthip.thip.ui.navigator.extensions.navigateToSignup -import com.texthip.thip.ui.navigator.extensions.navigateToSignupGenre import com.texthip.thip.ui.navigator.routes.CommonRoutes +import com.texthip.thip.ui.signin.mock.SignupUserInfo import com.texthip.thip.ui.signin.screen.LoginScreen +import com.texthip.thip.ui.signin.screen.SignupDoneScreen import com.texthip.thip.ui.signin.screen.SignupGenreScreen import com.texthip.thip.ui.signin.screen.SignupNicknameScreen import com.texthip.thip.ui.signin.screen.SplashScreen +import com.texthip.thip.ui.signin.screen.TutorialScreen import com.texthip.thip.ui.signin.viewmodel.SignupViewModel fun NavGraphBuilder.authNavigation(navController: NavHostController) { - // --- 인증 관련 화면들 --- composable { SplashScreen( onNavigateToLogin = { navController.navigateToLogin() } @@ -31,9 +34,9 @@ fun NavGraphBuilder.authNavigation(navController: NavHostController) { ) } navigation( - startDestination = CommonRoutes.Signup + startDestination = CommonRoutes.SignupScreenRoutes.Nickname ) { - composable { backStackEntry -> + composable { backStackEntry -> val parentEntry = remember(backStackEntry) { navController.getBackStackEntry() } @@ -42,11 +45,11 @@ fun NavGraphBuilder.authNavigation(navController: NavHostController) { SignupNicknameScreen( viewModel = signupViewModel, onNavigateToGenre = { - navController.navigateToSignupGenre() + navController.navigate(CommonRoutes.SignupScreenRoutes.Genre) } ) } - composable { backStackEntry -> + composable { backStackEntry -> val parentEntry = remember(backStackEntry) { navController.getBackStackEntry() } @@ -55,11 +58,37 @@ fun NavGraphBuilder.authNavigation(navController: NavHostController) { SignupGenreScreen( viewModel = signupViewModel, onSignupSuccess = { - navController.navigate(CommonRoutes.Main) { - popUpTo(CommonRoutes.Login) { - inclusive = true - } - } + navController.navigate(CommonRoutes.SignupScreenRoutes.Tutorial) + } + ) + } + composable { + TutorialScreen( + onFinish = { + navController.navigate(CommonRoutes.SignupScreenRoutes.Done) { + popUpTo { inclusive = true } + } } + ) + } + composable { backStackEntry -> + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry() + } + val signupViewModel = hiltViewModel(parentEntry) + val uiState by signupViewModel.uiState.collectAsStateWithLifecycle() + + val selectedRole = uiState.roleCards.getOrNull(uiState.selectedIndex) + + val userInfo = SignupUserInfo( + nickname = uiState.nickname, + profileImage = selectedRole?.imageUrl, + role = selectedRole?.role ?: "" + ) + + SignupDoneScreen( + userInfo = userInfo, + onNavigateToMain = { + navController.navigateToMainAfterSignup() } ) } diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/FeedNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/FeedNavigation.kt index 7a721e31..ada77abc 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/FeedNavigation.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/FeedNavigation.kt @@ -11,6 +11,7 @@ import com.texthip.thip.ui.feed.screen.FeedOthersScreen import com.texthip.thip.ui.feed.screen.FeedScreen import com.texthip.thip.ui.feed.screen.FeedWriteScreen import com.texthip.thip.ui.feed.screen.MySubscriptionScreen +import com.texthip.thip.ui.feed.screen.SearchPeopleScreen import com.texthip.thip.ui.feed.screen.OthersSubscriptionListScreen import com.texthip.thip.ui.feed.viewmodel.FeedWriteViewModel import com.texthip.thip.ui.navigator.extensions.navigateToAlarm @@ -18,6 +19,7 @@ import com.texthip.thip.ui.navigator.extensions.navigateToBookDetail import com.texthip.thip.ui.navigator.extensions.navigateToFeedComment import com.texthip.thip.ui.navigator.extensions.navigateToFeedWrite import com.texthip.thip.ui.navigator.extensions.navigateToMySubscription +import com.texthip.thip.ui.navigator.extensions.navigateToSearchPeople import com.texthip.thip.ui.navigator.extensions.navigateToOthersSubscription import com.texthip.thip.ui.navigator.extensions.navigateToUserProfile import com.texthip.thip.ui.navigator.routes.FeedRoutes @@ -54,6 +56,9 @@ fun NavGraphBuilder.feedNavigation(navController: NavHostController, navigateBac onNavigateToUserProfile = { userId -> navController.navigateToUserProfile(userId) }, + onNavigateToSearchPeople = { + navController.navigateToSearchPeople() + }, onNavigateToNotification = { navController.navigateToAlarm() }, @@ -186,4 +191,12 @@ fun NavGraphBuilder.feedNavigation(navController: NavHostController, navigateBac } ) } + composable { + SearchPeopleScreen( + onNavigateBack = navigateBack, + onUserClick = { userId -> + navController.navigateToUserProfile(userId) + } + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/MyPageNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/MyPageNavigation.kt index a43b7a4d..af586998 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/MyPageNavigation.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/MyPageNavigation.kt @@ -19,7 +19,10 @@ import com.texthip.thip.ui.navigator.routes.MainTabRoutes import com.texthip.thip.ui.navigator.routes.MyPageRoutes // MyPage -fun NavGraphBuilder.myPageNavigation(navController: NavHostController) { +fun NavGraphBuilder.myPageNavigation( + navController: NavHostController, + onNavigateToLogin: () -> Unit +) { composable { MyPageScreen( navController = navController, @@ -27,7 +30,8 @@ fun NavGraphBuilder.myPageNavigation(navController: NavHostController) { onNavigateToSavedFeeds = { navController.navigateToSavedFeeds() }, onNavigateToNotificationSettings = { navController.navigateToNotificationSettings() }, onCustomerService = {navController.navigateToCustomerService()}, - onDeleteAccount = { navController.navigateToLeaveThipScreen() } + onDeleteAccount = { navController.navigateToLeaveThipScreen() }, + onNavigateToLogin = onNavigateToLogin ) } diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/signupNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/signupNavigation.kt deleted file mode 100644 index cdfaa516..00000000 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/signupNavigation.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.texthip.thip.ui.navigator.navigations - -import androidx.compose.runtime.remember -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.compose.composable -import androidx.navigation.navigation -import com.texthip.thip.ui.navigator.routes.MainTabRoutes -import com.texthip.thip.ui.signin.screen.SignupGenreScreen -import com.texthip.thip.ui.signin.screen.SignupNicknameScreen -import com.texthip.thip.ui.signin.viewmodel.SignupViewModel - -fun NavGraphBuilder.signupNavigation(navController: NavHostController) { - navigation( - startDestination = "signup_nickname", - route = "signup_flow" - ) { - composable("signup_nickname") { navBackStackEntry -> - val parentEntry = remember(navBackStackEntry) { - navController.getBackStackEntry("signup_flow") - } - val viewModel: SignupViewModel = hiltViewModel(parentEntry) - - SignupNicknameScreen( - viewModel = viewModel, - onNavigateToGenre = { - navController.navigate("signup_genre") - } - ) - } - - composable("signup_genre"){ navBackStackEntry -> - val parentEntry = remember(navBackStackEntry) { - navController.getBackStackEntry("signup_flow") - } - val viewModel: SignupViewModel = hiltViewModel(parentEntry) - - SignupGenreScreen( - viewModel = viewModel, - onSignupSuccess = { - navController.navigate(MainTabRoutes.Feed) { - popUpTo("signup_flow") { inclusive = true } - } - } - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/routes/CommonRoutes.kt b/app/src/main/java/com/texthip/thip/ui/navigator/routes/CommonRoutes.kt index b3c9a8a3..0b19505d 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/routes/CommonRoutes.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/routes/CommonRoutes.kt @@ -14,13 +14,22 @@ sealed class CommonRoutes : Routes() { data object Login : CommonRoutes() @Serializable - data object Signup : CommonRoutes() + data object SignupFlow : CommonRoutes() @Serializable - data object Genre : CommonRoutes() + sealed interface SignupScreenRoutes { + @Serializable + data object Nickname : SignupScreenRoutes - @Serializable - data object SignupFlow : CommonRoutes() + @Serializable + data object Genre : SignupScreenRoutes + + @Serializable + data object Tutorial : SignupScreenRoutes + + @Serializable + data object Done : SignupScreenRoutes + } @Serializable data object Main : CommonRoutes() diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/routes/FeedRoutes.kt b/app/src/main/java/com/texthip/thip/ui/navigator/routes/FeedRoutes.kt index 5832ca95..e10fd236 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/routes/FeedRoutes.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/routes/FeedRoutes.kt @@ -26,6 +26,8 @@ sealed class FeedRoutes : Routes() { @Serializable data class Others(val userId: Long) : FeedRoutes() + @Serializable data object SearchPeople : FeedRoutes() + @Serializable data class OthersSubscription(val userId: Long) : FeedRoutes() } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/signin/mock/SignupUserInfo.kt b/app/src/main/java/com/texthip/thip/ui/signin/mock/SignupUserInfo.kt index 9bf87efc..5cf10465 100644 --- a/app/src/main/java/com/texthip/thip/ui/signin/mock/SignupUserInfo.kt +++ b/app/src/main/java/com/texthip/thip/ui/signin/mock/SignupUserInfo.kt @@ -2,6 +2,6 @@ package com.texthip.thip.ui.signin.mock data class SignupUserInfo( val nickname: String, - val profileImageResId: Int?, + val profileImage: String?, val role: String ) diff --git a/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupDoneScreen.kt b/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupDoneScreen.kt index 30890b21..06202822 100644 --- a/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupDoneScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupDoneScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import com.texthip.thip.R import com.texthip.thip.ui.common.buttons.ActionMediumButton import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar @@ -33,11 +34,13 @@ import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography - @Composable -fun SignupDoneScreen(userInfo: SignupUserInfo) { +fun SignupDoneScreen( + userInfo: SignupUserInfo, + onNavigateToMain: () -> Unit +) { val nickname = userInfo.nickname - val profileImageResId = userInfo.profileImageResId + val profileImage = userInfo.profileImage val role = userInfo.role Column( Modifier @@ -77,7 +80,7 @@ fun SignupDoneScreen(userInfo: SignupUserInfo) { .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - if (profileImageResId != null) { + if (profileImage != null) { Box( modifier = Modifier .size(54.dp) @@ -86,8 +89,8 @@ fun SignupDoneScreen(userInfo: SignupUserInfo) { .background(colors.Black), contentAlignment = Alignment.BottomCenter ) { - Image( - painter = painterResource(id = profileImageResId), + AsyncImage( + model = userInfo.profileImage, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier @@ -125,7 +128,7 @@ fun SignupDoneScreen(userInfo: SignupUserInfo) { contentColor = colors.White, backgroundColor = colors.Purple, modifier = Modifier.width(180.dp), - onClick = {}, + onClick = onNavigateToMain, ) } @@ -137,9 +140,12 @@ fun SignupDoneScreen(userInfo: SignupUserInfo) { @Preview @Composable private fun SignupDoneScreenPrev() { - SignupDoneScreen(userInfo = SignupUserInfo( - nickname = "JJUYAA", - profileImageResId = R.drawable.character_sociology, - role = "칭호칭호" - )) + SignupDoneScreen( + userInfo = SignupUserInfo( + nickname = "JJUYAA", + profileImage = "", + role = "칭호칭호" + ), + onNavigateToMain = {} + ) } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/signin/screen/TutorialScreen.kt b/app/src/main/java/com/texthip/thip/ui/signin/screen/TutorialScreen.kt new file mode 100644 index 00000000..7ae5d777 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/signin/screen/TutorialScreen.kt @@ -0,0 +1,173 @@ +package com.texthip.thip.ui.signin.screen + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.texthip.thip.R +import com.texthip.thip.ui.common.topappbar.InputTopAppBar +import com.texthip.thip.ui.theme.ThipTheme +import com.texthip.thip.ui.theme.ThipTheme.colors +import com.texthip.thip.ui.theme.ThipTheme.typography +import kotlinx.coroutines.launch + +// 튜토리얼 각 페이지에 표시될 데이터 클래스 +private data class TutorialPage( + val title: Int, + val subtitle: Int, + val imageRes: Int +) + +// 튜토리얼 6개 화면에 대한 데이터 +private val tutorialPages = listOf( + TutorialPage(R.string.feed, R.string.tutorial_1, R.drawable.img_tutorial_1), + TutorialPage(R.string.feed, R.string.tutorial_2, R.drawable.img_tutorial_2), + TutorialPage(R.string.nav_group, R.string.tutorial_3, R.drawable.img_tutorial_3), + TutorialPage(R.string.nav_group, R.string.tutorial_4, R.drawable.img_tutorial_4), + TutorialPage(R.string.thip_plus, R.string.tutorial_5, R.drawable.img_tutorial_5), + TutorialPage(R.string.thip_plus, R.string.tutorial_6, R.drawable.img_tutorial_6) +) + + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TutorialScreen( + onFinish: () -> Unit +) { + val pagerState = rememberPagerState(pageCount = { tutorialPages.size }) + val scope = rememberCoroutineScope() + + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + ) { + InputTopAppBar( + title = "", + rightButtonName = stringResource(R.string.next), + isLeftIconVisible = false, + isRightButtonEnabled = true, + onRightClick = { + scope.launch { + if (pagerState.currentPage < tutorialPages.lastIndex) { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } else { + onFinish() + } + } + } + ) + + // 화면 콘텐츠 영역 + Column( + modifier = Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.padding(top = 10.dp).weight(1f)// 남은 공간을 모두 차지하도록 weight 설정 + ) { pageIndex -> + TutorialPageContent(page = tutorialPages[pageIndex]) + } + Spacer(modifier = Modifier.height(24.dp)) + PageIndicator( + pageCount = tutorialPages.size, + currentPage = pagerState.currentPage + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = stringResource(R.string.skip), + style = typography.info_r400_s12, + color = colors.Grey01, + modifier = Modifier + .padding(bottom = 62.dp) + .clickable { onFinish() } + ) + } + } +} + +@Composable +private fun TutorialPageContent(page: TutorialPage) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(id = page.title), + style = typography.title_b700_s20_h24, + color = colors.White + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(id = page.subtitle), + style = typography.smalltitle_sb600_s16_h24, + color = colors.Grey, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(40.dp)) + Image( + painter = painterResource(id = page.imageRes), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun PageIndicator(pageCount: Int, currentPage: Int) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + repeat(pageCount) { index -> + val color = if (currentPage == index) colors.White else colors.Grey02 + Box( + modifier = Modifier + .size(4.dp) + .clip(CircleShape) + .background(color) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun TutorialScreenPreview() { + ThipTheme { + TutorialScreen(onFinish = {}) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/signin/viewmodel/SignupViewModel.kt b/app/src/main/java/com/texthip/thip/ui/signin/viewmodel/SignupViewModel.kt index abac5a16..a89243fc 100644 --- a/app/src/main/java/com/texthip/thip/ui/signin/viewmodel/SignupViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/signin/viewmodel/SignupViewModel.kt @@ -151,14 +151,4 @@ class SignupViewModel @Inject constructor( } } } - - // 소셜로그인과 연동 후 삭제 예정 - fun setInitialDataForTest(nickname: String) { - _uiState.update { - it.copy( - nickname = nickname, - isNicknameVerified = true // 닉네임이 검증되었다고 가정 - ) - } - } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_text_more.xml b/app/src/main/res/drawable/ic_text_more.xml new file mode 100644 index 00000000..f5bb2b9e --- /dev/null +++ b/app/src/main/res/drawable/ic_text_more.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/img_tutorial_1.png b/app/src/main/res/drawable/img_tutorial_1.png new file mode 100644 index 00000000..e93b9376 Binary files /dev/null and b/app/src/main/res/drawable/img_tutorial_1.png differ diff --git a/app/src/main/res/drawable/img_tutorial_2.png b/app/src/main/res/drawable/img_tutorial_2.png new file mode 100644 index 00000000..e6de05da Binary files /dev/null and b/app/src/main/res/drawable/img_tutorial_2.png differ diff --git a/app/src/main/res/drawable/img_tutorial_3.png b/app/src/main/res/drawable/img_tutorial_3.png new file mode 100644 index 00000000..69ca3db7 Binary files /dev/null and b/app/src/main/res/drawable/img_tutorial_3.png differ diff --git a/app/src/main/res/drawable/img_tutorial_4.png b/app/src/main/res/drawable/img_tutorial_4.png new file mode 100644 index 00000000..9a726d95 Binary files /dev/null and b/app/src/main/res/drawable/img_tutorial_4.png differ diff --git a/app/src/main/res/drawable/img_tutorial_5.png b/app/src/main/res/drawable/img_tutorial_5.png new file mode 100644 index 00000000..4cefa4b2 Binary files /dev/null and b/app/src/main/res/drawable/img_tutorial_5.png differ diff --git a/app/src/main/res/drawable/img_tutorial_6.png b/app/src/main/res/drawable/img_tutorial_6.png new file mode 100644 index 00000000..1d7dd54b Binary files /dev/null and b/app/src/main/res/drawable/img_tutorial_6.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3ca5c34e..922a7b94 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -299,6 +299,16 @@ 띱! 하고 떠오른 그 문장을 나눠요 + + 피드에서 책과 독서에 대한 생각을\n자유롭게 나누어 보세요! + 칭호를 통해 내 독서 취향을 드러내고,\n마음에 드는 유저를 \'띱\'하고 감상을 공유해 보세요! + 모임방에서는 글은 물론 투표 기능을 통해\n감상과 의견을 나눌 수 있어요. + 읽고 싶은 책으로 나만의 독서 모임을 만들고,\n독서메이트와 함께 기록을 나눌 수 있어요. + 기록은 자유롭게, 감상은 방해 없이.\n읽지 않은 페이지에 대한 기록은 블라인드 되어\n스포일러 걱정 없이 몰입할 수 있어요 + 모임방의 인상깊은 기록을\n\'핀하기\'로 피드에 다시 공유해 보세요. + Thip+ + + 카카오계정 로그인 구글계정 로그인 @@ -312,6 +322,7 @@ 지금 바로 Thip 시작하기 관심있는 장르를 하나 선택해주세요. 이후 내 정보에서 변경이 가능해요. + 건너뛰기 내 띱 목록