From d2d109a02475fd34e3a4da8e0fc60430819f505c Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 27 Aug 2025 19:34:45 +0900 Subject: [PATCH 01/27] =?UTF-8?q?[refactor]:=20=ED=83=AD=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=84=A0=ED=83=9D=ED=96=88=EC=9D=84=20=EB=95=8C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=B5=9C=EC=83=81=EB=8B=A8=20(#1?= =?UTF-8?q?02)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/texthip/thip/MainScreen.kt | 24 ++++++++++++-- .../texthip/thip/ui/feed/screen/FeedScreen.kt | 29 +++++++++++++++- .../thip/ui/feed/viewmodel/FeedViewModel.kt | 33 +++++++++++++++---- .../thip/ui/navigator/BottomNavigationBar.kt | 10 ++++-- .../texthip/thip/ui/navigator/MainNavHost.kt | 6 ++-- .../navigator/navigations/FeedNavigation.kt | 7 +++- 6 files changed, 95 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/MainScreen.kt b/app/src/main/java/com/texthip/thip/MainScreen.kt index b4e336cc..1b4ef1dd 100644 --- a/app/src/main/java/com/texthip/thip/MainScreen.kt +++ b/app/src/main/java/com/texthip/thip/MainScreen.kt @@ -5,6 +5,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold 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.Modifier import androidx.compose.ui.graphics.Color import androidx.navigation.compose.currentBackStackEntryAsState @@ -12,6 +15,7 @@ import androidx.navigation.compose.rememberNavController import com.texthip.thip.ui.navigator.BottomNavigationBar import com.texthip.thip.ui.navigator.MainNavHost import com.texthip.thip.ui.navigator.extensions.isMainTabRoute +import com.texthip.thip.ui.navigator.routes.MainTabRoutes @Composable fun MainScreen( @@ -20,19 +24,35 @@ fun MainScreen( val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination + var feedReselectionTrigger by remember { mutableStateOf(0) } val showBottomBar = currentDestination?.isMainTabRoute() ?: true Scaffold( bottomBar = { - if (showBottomBar) BottomNavigationBar(navController) + if (showBottomBar) { + BottomNavigationBar( + navController = navController, + onTabReselected = { route -> + when (route) { + MainTabRoutes.Feed -> { + feedReselectionTrigger += 1 + } + else -> { + // 다른 탭들은 향후 확장 가능 + } + } + } + ) + } }, containerColor = Color.Transparent ) { innerPadding -> Box(modifier = Modifier.padding(innerPadding)) { MainNavHost( navController = navController, - onNavigateToLogin = onNavigateToLogin + onNavigateToLogin = onNavigateToLogin, + onFeedTabReselected = feedReselectionTrigger ) } } 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 6e07877d..0a8522e4 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 @@ -76,6 +76,7 @@ fun FeedScreen( onNavigateToSearchPeople: () -> Unit = {}, onNavigateToNotification: () -> Unit = {}, refreshFeed: Boolean? = null, + onFeedTabReselected: Int = 0, // 바텀 네비게이션 재선택 트리거 onNavigateToOthersSubscription: (userId: Long) -> Unit = {}, onResultConsumed: () -> Unit = {}, onRefreshConsumed: () -> Unit = {}, @@ -139,6 +140,7 @@ fun FeedScreen( } var isUserTabChange by remember { mutableStateOf(false) } + var shouldScrollToTop by remember { mutableStateOf(false) } LaunchedEffect(Unit) { // 최초 진입시에만 데이터 로딩 @@ -172,6 +174,16 @@ fun FeedScreen( isUserTabChange = false } } + + // 같은 탭 재클릭 시 스크롤 상단 이동 처리 + LaunchedEffect(shouldScrollToTop) { + if (shouldScrollToTop) { + currentListState.scrollToItem(0) + shouldScrollToTop = false + } + } + + // 중복된 로직 제거 - 기존 bottomNavReselected 방식만 사용 LaunchedEffect(resultFeedId) { if (resultFeedId != null) { @@ -202,6 +214,14 @@ fun FeedScreen( } } } + + // 바텀 네비게이션 탭 재선택 처리 (직접 상태 전달 방식) + LaunchedEffect(onFeedTabReselected) { + if (onFeedTabReselected > 0) { + feedViewModel.refreshOnBottomNavReselect() + currentListState.scrollToItem(0) + } + } LaunchedEffect(Unit) { //커스텀객체 타입 인식오류 -> 직렬화가 아닌 잘게 쪼개어 전달 navController.currentBackStackEntry?.savedStateHandle?.let { handle -> handle.getLiveData("updated_feed_id").observeForever { feedId -> @@ -263,7 +283,14 @@ fun FeedScreen( titles = feedTabTitles, selectedTabIndex = feedUiState.selectedTabIndex, onTabSelected = { index -> - isUserTabChange = true + val isCurrentTab = feedUiState.selectedTabIndex == index + if (isCurrentTab) { + // 같은 탭 다시 클릭 시 스크롤 상단 이동 트리거 + shouldScrollToTop = true + } else { + // 다른 탭으로 전환 시 + isUserTabChange = true + } feedViewModel.onTabSelected(index) } ) 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 4b408e8a..4290d5e3 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 @@ -75,23 +75,44 @@ class FeedViewModel @Inject constructor( } fun onTabSelected(index: Int) { + val isCurrentTab = _uiState.value.selectedTabIndex == index updateState { it.copy(selectedTabIndex = index) } when (index) { 0 -> { - // 항상 새로고침 (인디케이터 표시) - refreshCurrentTab() + if (isCurrentTab) { + // 같은 탭 다시 클릭 시: 전체 새로고침 + 스크롤 상단 + refreshDataAndScrollToTop() + } else { + // 다른 탭에서 이동: 기존처럼 현재 탭만 새로고침 + refreshCurrentTab() + } } 1 -> { - // 항상 새로고침 (인디케이터 표시) - refreshCurrentTab() - if (_uiState.value.myFeedInfo == null) { - fetchMyFeedInfo() + if (isCurrentTab) { + // 같은 탭 다시 클릭 시: 전체 새로고침 + 스크롤 상단 + refreshDataAndScrollToTop() + } else { + // 다른 탭에서 이동: 기존처럼 현재 탭만 새로고침 + refreshCurrentTab() + if (_uiState.value.myFeedInfo == null) { + fetchMyFeedInfo() + } } } } } + + fun refreshDataAndScrollToTop() { + refreshData() + // 스크롤 상단 이동은 Screen에서 처리 + } + + fun refreshOnBottomNavReselect() { + // 바텀 네비게이션에서 같은 탭 다시 클릭 시 (스크롤은 Screen에서 처리) + refreshData() + } private fun loadAllFeeds(isInitial: Boolean = true) { if (isLoadingAllFeeds && !isInitial) return diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/BottomNavigationBar.kt b/app/src/main/java/com/texthip/thip/ui/navigator/BottomNavigationBar.kt index 46131d72..4a6ea0c0 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/BottomNavigationBar.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/BottomNavigationBar.kt @@ -32,11 +32,15 @@ import androidx.navigation.compose.rememberNavController import com.texthip.thip.ui.navigator.data.NavBarItems import com.texthip.thip.ui.navigator.extensions.isRoute import com.texthip.thip.ui.navigator.extensions.navigateToTab +import com.texthip.thip.ui.navigator.routes.MainTabRoutes import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @Composable -fun BottomNavigationBar(navController: NavHostController) { +fun BottomNavigationBar( + navController: NavHostController, + onTabReselected: ((MainTabRoutes) -> Unit)? = null +) { val currentDestination = navController.currentBackStackEntryAsState().value?.destination val greyColor = colors.Grey02 @@ -119,7 +123,9 @@ fun BottomNavigationBar(navController: NavHostController) { }, selected = isSelected, onClick = { - if (!isSelected) { + if (isSelected) { + onTabReselected?.invoke(item.route) + } else { navController.navigateToTab(item.route) } }, 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 a52e2df5..b06889ac 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 @@ -14,7 +14,8 @@ import com.texthip.thip.ui.navigator.routes.MainTabRoutes @Composable fun MainNavHost( navController: NavHostController, - onNavigateToLogin: () -> Unit + onNavigateToLogin: () -> Unit, + onFeedTabReselected: Int = 0 ) { NavHost( navController = navController, @@ -22,7 +23,8 @@ fun MainNavHost( ) { feedNavigation( navController = navController, - navigateBack = navController::popBackStack + navigateBack = navController::popBackStack, + onFeedTabReselected = onFeedTabReselected ) groupNavigation( navController = navController, 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 96dcbe95..20daf602 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 @@ -30,7 +30,11 @@ import com.texthip.thip.ui.navigator.routes.FeedRoutes import com.texthip.thip.ui.navigator.routes.MainTabRoutes // Feed -fun NavGraphBuilder.feedNavigation(navController: NavHostController, navigateBack: () -> Unit) { +fun NavGraphBuilder.feedNavigation( + navController: NavHostController, + navigateBack: () -> Unit, + onFeedTabReselected: Int = 0 +) { composable { backStackEntry -> val feedViewModel: FeedViewModel = hiltViewModel(backStackEntry) val uiState by feedViewModel.uiState.collectAsState() @@ -54,6 +58,7 @@ fun NavGraphBuilder.feedNavigation(navController: NavHostController, navigateBac navController = navController, resultFeedId = resultFeedId, refreshFeed = refreshFeed, + onFeedTabReselected = onFeedTabReselected, onResultConsumed = { backStackEntry.savedStateHandle.remove("feedId") }, From 09882e407ab3a74ba8c10ca973bbbd4f9bfbd995 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 29 Aug 2025 18:40:33 +0900 Subject: [PATCH 02/27] =?UTF-8?q?[refactor]:=20=EB=8B=89=EB=84=A4=EC=9E=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=8C=80=EB=AC=B8=EC=9E=90=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EC=9D=84=20=ED=95=98=EC=A7=80=20=EB=AA=BB=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/texthip/thip/ui/mypage/screen/MypageEditScreen.kt | 3 ++- .../com/texthip/thip/ui/signin/screen/SignupNicknameScreen.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 12599855..a50ff1ce 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 @@ -121,7 +121,8 @@ fun EditProfileContent( showLimit = true, maxLength = 10, warningMessage = uiState.nicknameWarningMessageResId?.let { stringResource(it) } - ?: "" + ?: "", + preventUppercase = true ) Spacer(modifier = Modifier.height(40.dp)) Text( diff --git a/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupNicknameScreen.kt b/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupNicknameScreen.kt index 3033a780..1ddbea3a 100644 --- a/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupNicknameScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupNicknameScreen.kt @@ -106,7 +106,8 @@ fun SignupNicknameContent( showIcon = false, showLimit = true, maxLength = 10, - warningMessage = warningMessageResId?.let { stringResource(it) } ?: "" + warningMessage = warningMessageResId?.let { stringResource(it) } ?: "", + preventUppercase = true ) } } From 0a3aa0f927309a7828f096747e821ce8ee143a04 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 29 Aug 2025 18:41:05 +0900 Subject: [PATCH 03/27] =?UTF-8?q?[refactor]:=20=ED=95=B4=EB=8B=B9=20textFi?= =?UTF-8?q?eld=EB=A5=BC=20Basic=EC=9C=BC=EB=A1=9C=20=EA=B5=AC=ED=98=84(?= =?UTF-8?q?=ED=8C=A8=EB=94=A9=20=EB=AC=B8=EC=A0=9C)=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/common/forms/FormTextFieldDefault.kt | 80 +++++++------ .../thip/ui/common/forms/WarningTextField.kt | 106 ++++++++---------- 2 files changed, 93 insertions(+), 93 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/common/forms/FormTextFieldDefault.kt b/app/src/main/java/com/texthip/thip/ui/common/forms/FormTextFieldDefault.kt index 55f2972e..ef3df040 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/forms/FormTextFieldDefault.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/forms/FormTextFieldDefault.kt @@ -1,19 +1,18 @@ package com.texthip.thip.ui.common.forms +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.Icon -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -22,6 +21,7 @@ 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.graphics.SolidColor import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -47,48 +47,56 @@ fun FormTextFieldDefault( Box(modifier = modifier .height(48.dp)) { - OutlinedTextField( + BasicTextField( value = displayText, onValueChange = { // 글자수 제한 적용 text = if (showLimit && it.length > limit) it.substring(0, limit) else it }, - placeholder = { - Text( - text = hint, - color = colors.Grey02, - style = myStyle + textStyle = myStyle.copy(color = colors.White), + modifier = Modifier + .fillMaxSize() + .background( + color = containerColor, + shape = RoundedCornerShape(12.dp) ) - }, - textStyle = myStyle, - modifier = Modifier.fillMaxSize(), - shape = RoundedCornerShape(12.dp), - colors = TextFieldDefaults.colors( - focusedTextColor = colors.White, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - focusedContainerColor = containerColor, - unfocusedContainerColor = containerColor, - cursorColor = colors.NeonGreen - ), - trailingIcon = { - if (showIcon) { - if (text.isNotEmpty()) { - Icon( - painter = painterResource(id = R.drawable.ic_x_circle_white), - contentDescription = "Clear text", - modifier = Modifier.clickable { text = "" }, - tint = Color.Unspecified - ) - } else { - Icon( - painter = painterResource(id = R.drawable.ic_x_circle), - contentDescription = "Clear text" + .padding(horizontal = 14.dp, vertical = 12.dp), + singleLine = true, + cursorBrush = SolidColor(colors.NeonGreen), + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterStart + ) { + if (displayText.isEmpty()) { + Text( + text = hint, + color = colors.Grey02, + style = myStyle ) } + innerTextField() + + if (showIcon) { + if (text.isNotEmpty()) { + Icon( + painter = painterResource(id = R.drawable.ic_x_circle_white), + contentDescription = "Clear text", + modifier = Modifier + .align(Alignment.CenterEnd) + .clickable { text = "" }, + tint = Color.Unspecified + ) + } else { + Icon( + painter = painterResource(id = R.drawable.ic_x_circle), + contentDescription = "Clear text", + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } } - }, - singleLine = true + } ) // 글자수 제한 표시 (오른쪽 상단) diff --git a/app/src/main/java/com/texthip/thip/ui/common/forms/WarningTextField.kt b/app/src/main/java/com/texthip/thip/ui/common/forms/WarningTextField.kt index bec351f1..6e535e4e 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/forms/WarningTextField.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/forms/WarningTextField.kt @@ -9,11 +9,13 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.graphics.SolidColor import androidx.compose.material3.Icon -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -45,55 +47,67 @@ fun WarningTextField( showIcon: Boolean = false, containerColor: Color = colors.Black, isNumberOnly: Boolean = false, - keyboardType: KeyboardType = KeyboardType.Text + keyboardType: KeyboardType = KeyboardType.Text, + preventUppercase: Boolean = false ) { - val myStyle = typography.menu_r400_s14_h24.copy(lineHeight = 14.sp) + val myStyle = typography.menu_r400_s14_h24.copy(lineHeight = 16.sp) Column { Box( modifier = modifier .height(48.dp) ) { - OutlinedTextField( + BasicTextField( value = value, onValueChange = { input -> var filtered = input if (isNumberOnly) filtered = filtered.filter { it.isDigit() } + if (preventUppercase) filtered = filtered.filter { !it.isUpperCase() } if (filtered.length > maxLength) filtered = filtered.take(maxLength) onValueChange(filtered) }, - placeholder = { - Text( - text = hint, - color = colors.Grey02, - style = myStyle + textStyle = myStyle.copy(color = colors.White), + modifier = Modifier + .fillMaxSize() + .background( + color = containerColor, + shape = RoundedCornerShape(12.dp) ) - }, - textStyle = myStyle, - modifier = Modifier.fillMaxSize(), - shape = RoundedCornerShape(12.dp), - colors = TextFieldDefaults.colors( - unfocusedTextColor = colors.White, - focusedTextColor = colors.White, - focusedIndicatorColor = if (showWarning) colors.Red else Color.Transparent, - unfocusedIndicatorColor = if (showWarning) colors.Red else Color.Transparent, - focusedContainerColor = containerColor, - unfocusedContainerColor = containerColor, - cursorColor = colors.NeonGreen - ), - trailingIcon = { - if (showIcon) { - Icon( - painter = painterResource(id = R.drawable.ic_x_circle_grey), - contentDescription = "Clear text", - modifier = Modifier.clickable { onValueChange("") }, - tint = Color.Unspecified - ) - } - }, + .border( + width = if (showWarning) 1.dp else 0.dp, + color = if (showWarning) colors.Red else Color.Transparent, + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 14.dp, vertical = 12.dp), singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = keyboardType) - + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + cursorBrush = SolidColor(colors.NeonGreen), + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterStart + ) { + if (value.isEmpty()) { + Text( + text = hint, + color = colors.Grey02, + style = myStyle + ) + } + innerTextField() + + if (showIcon) { + Icon( + painter = painterResource(id = R.drawable.ic_x_circle_grey), + contentDescription = "Clear text", + modifier = Modifier + .align(Alignment.CenterEnd) + .clickable { onValueChange("") }, + tint = Color.Unspecified + ) + } + } + } ) if (showLimit && maxLength != Int.MAX_VALUE) { @@ -162,25 +176,3 @@ fun WarningTextFieldPreviewNormal() { ) } } - -@Composable -@Preview(showBackground = true, backgroundColor = 0xFF000000, widthDp = 360, heightDp = 200) -fun WarningTextFieldPreviewNormal_numberonly() { - var password by remember { mutableStateOf("") } - - Box( - modifier = Modifier.size(width = 360.dp, height = 200.dp), - contentAlignment = Alignment.Center - ) { - WarningTextField( - value = password, - onValueChange = { password = it }, - hint = "4자리 숫자로 입장 비밀번호를 설정", - showWarning = false, - warningMessage = "4자리 숫자를 입력해주세요.", - maxLength = 4, - isNumberOnly = true, - keyboardType = KeyboardType.NumberPassword - ) - } -} From 68e7b13e17cfa8e74d158648fba4c1caac6ba8c2 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Sat, 30 Aug 2025 13:55:18 +0900 Subject: [PATCH 04/27] =?UTF-8?q?[refactor]:=20Screen=EC=97=90=EC=84=9C=20?= =?UTF-8?q?Content=EB=A1=9C=20=EB=AF=B8=EA=B5=AC=ED=98=84=EB=90=9C=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=EC=9D=84=20Content=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/feed/screen/FeedCommentScreen.kt | 67 +++++++++-- .../texthip/thip/ui/feed/screen/FeedScreen.kt | 113 +++++++++++++----- .../room/screen/GroupRoomUnlockScreen.kt | 75 ++++++++---- .../group/search/screen/GroupSearchScreen.kt | 65 ++++++---- .../navigator/navigations/AuthNavigation.kt | 2 +- .../thip/ui/signin/screen/SplashScreen.kt | 14 ++- 6 files changed, 245 insertions(+), 91 deletions(-) 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 a3c552dc..d9da9ddc 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 @@ -45,6 +45,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager 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 @@ -66,6 +67,7 @@ import com.texthip.thip.ui.group.note.component.CommentSection import com.texthip.thip.ui.group.note.viewmodel.CommentsEvent import com.texthip.thip.ui.group.note.viewmodel.CommentsViewModel import com.texthip.thip.ui.group.room.mock.MenuBottomSheetItem +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.rooms.advancedImePadding @@ -112,7 +114,7 @@ fun FeedCommentScreen( } LaunchedEffect(feedId) { feedDetailViewModel.loadFeedDetail(feedId) - commentsViewModel.initialize(postId = feedId.toLong(), postType = "FEED") + commentsViewModel.initialize(postId = feedId, postType = "FEED") } // 댓글이 생성되면 피드 상세 정보를 다시 로드 @@ -123,6 +125,35 @@ fun FeedCommentScreen( } } + FeedCommentContent( + modifier = modifier, + feedDetailUiState = feedDetailUiState, + commentsUiState = commentsUiState, + onNavigateBack = onNavigateBack, + onNavigateToFeedEdit = onNavigateToFeedEdit, + onNavigateToUserProfile = onNavigateToUserProfile, + onNavigateToBookDetail = onNavigateToBookDetail, + onLikeClick = { feedDetailViewModel.changeFeedLike() }, + onBookmarkClick = { feedDetailViewModel.changeFeedSave() }, + onDeleteFeed = { feedDetailViewModel.deleteFeed(feedId) }, + onCommentEvent = commentsViewModel::onEvent + ) +} + +@Composable +private fun FeedCommentContent( + modifier: Modifier = Modifier, + feedDetailUiState: com.texthip.thip.ui.feed.viewmodel.FeedDetailUiState, + commentsUiState: com.texthip.thip.ui.group.note.viewmodel.CommentsUiState, + onNavigateBack: () -> Unit, + onNavigateToFeedEdit: (Int) -> Unit, + onNavigateToUserProfile: (userId: Long) -> Unit, + onNavigateToBookDetail: (String) -> Unit, + onLikeClick: () -> Unit, + onBookmarkClick: () -> Unit, + onDeleteFeed: () -> Unit, + onCommentEvent: (CommentsEvent) -> Unit +) { // 로딩 상태 처리 if (feedDetailUiState.isLoading) { Box( @@ -151,7 +182,7 @@ fun FeedCommentScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = feedDetailUiState.error!!, + text = feedDetailUiState.error, style = typography.copy_r400_s14, color = colors.Grey ) @@ -324,8 +355,8 @@ fun FeedCommentScreen( isSaved = feedDetail.isSaved, isPinVisible = false, isLockIcon = feedDetail.isPublic == false, - onLikeClick = { feedDetailViewModel.changeFeedLike() }, - onBookmarkClick = { feedDetailViewModel.changeFeedSave() }, + onLikeClick = onLikeClick, + onBookmarkClick = onBookmarkClick, ) HorizontalDivider( @@ -380,7 +411,7 @@ fun FeedCommentScreen( ) { commentItem -> CommentSection( commentItem = commentItem, - onEvent = commentsViewModel::onEvent, + onEvent = onCommentEvent, onReplyClick = { commentId, nickname -> replyingToCommentId = commentId replyingToNickname = nickname @@ -409,7 +440,7 @@ fun FeedCommentScreen( onInputChange = { commentInput = it }, onSendClick = { if (commentInput.isNotBlank()) { - commentsViewModel.onEvent( + onCommentEvent( CommentsEvent.CreateComment( content = commentInput, parentId = replyingToCommentId @@ -498,11 +529,10 @@ fun FeedCommentScreen( text = stringResource(R.string.delete), color = colors.Red, onClick = { - commentsViewModel.onEvent(CommentsEvent.DeleteComment(comment.commentId)) + onCommentEvent(CommentsEvent.DeleteComment(comment.commentId)) toastMessage = "댓글 삭제를 완료했습니다." showToast = true isCommentMenuVisible = false - isCommentMenuVisible = false } ) ) @@ -538,7 +568,7 @@ fun FeedCommentScreen( description = stringResource(R.string.delete_feed_dialog_description), onConfirm = { showDeleteDialog = false - feedDetailViewModel.deleteFeed(feedId) + onDeleteFeed() }, onCancel = { showDeleteDialog = false @@ -557,3 +587,22 @@ fun FeedCommentScreen( } } } + +@Preview +@Composable +private fun FeedCommentContentPreview() { + ThipTheme { + FeedCommentContent( + feedDetailUiState = com.texthip.thip.ui.feed.viewmodel.FeedDetailUiState(), + commentsUiState = com.texthip.thip.ui.group.note.viewmodel.CommentsUiState(), + onNavigateBack = {}, + onNavigateToFeedEdit = {}, + onNavigateToUserProfile = {}, + onNavigateToBookDetail = {}, + onLikeClick = {}, + onBookmarkClick = {}, + onDeleteFeed = {}, + onCommentEvent = {} + ) + } +} 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 0a8522e4..4439af2b 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 @@ -250,6 +250,60 @@ fun FeedScreen( } } } + + FeedContent( + feedUiState = feedUiState, + showProgressBar = showProgressBar, + progress = progress.value, + currentListState = currentListState, + feedTabTitles = feedTabTitles, + onNavigateToSearchPeople = onNavigateToSearchPeople, + onNavigateToNotification = onNavigateToNotification, + onNavigateToMySubscription = onNavigateToMySubscription, + onNavigateToOthersSubscription = onNavigateToOthersSubscription, + onNavigateToFeedComment = onNavigateToFeedComment, + onNavigateToBookDetail = onNavigateToBookDetail, + onNavigateToUserProfile = { userId -> + navController.currentBackStackEntry?.savedStateHandle?.set("from_profile", true) + onNavigateToUserProfile(userId) + }, + onNavigateToFeedWrite = onNavigateToFeedWrite, + onTabSelected = { index -> + val isCurrentTab = feedUiState.selectedTabIndex == index + if (isCurrentTab) { + shouldScrollToTop = true + } else { + isUserTabChange = true + } + feedViewModel.onTabSelected(index) + }, + onChangeFeedLike = feedViewModel::changeFeedLike, + onChangeFeedSave = feedViewModel::changeFeedSave, + onPullToRefresh = feedViewModel::pullToRefresh + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FeedContent( + feedUiState: com.texthip.thip.ui.feed.viewmodel.FeedUiState, + showProgressBar: Boolean, + progress: Float, + currentListState: LazyListState, + feedTabTitles: List, + onNavigateToSearchPeople: () -> Unit, + onNavigateToNotification: () -> Unit, + onNavigateToMySubscription: () -> Unit, + onNavigateToOthersSubscription: (userId: Long) -> Unit, + onNavigateToFeedComment: (Long) -> Unit, + onNavigateToBookDetail: (String) -> Unit, + onNavigateToUserProfile: (userId: Long) -> Unit, + onNavigateToFeedWrite: () -> Unit, + onTabSelected: (Int) -> Unit, + onChangeFeedLike: (Long) -> Unit, + onChangeFeedSave: (Long) -> Unit, + onPullToRefresh: () -> Unit +) { // 초기 로딩 상태 처리 if (feedUiState.isLoading && feedUiState.currentTabFeeds.isEmpty()) { Box( @@ -267,7 +321,7 @@ fun FeedScreen( Box(modifier = Modifier.fillMaxSize()) { PullToRefreshBox( isRefreshing = feedUiState.isPullToRefreshing, - onRefresh = { feedViewModel.pullToRefresh() } + onRefresh = onPullToRefresh ) { Column( modifier = Modifier.fillMaxSize() @@ -284,14 +338,7 @@ fun FeedScreen( selectedTabIndex = feedUiState.selectedTabIndex, onTabSelected = { index -> val isCurrentTab = feedUiState.selectedTabIndex == index - if (isCurrentTab) { - // 같은 탭 다시 클릭 시 스크롤 상단 이동 트리거 - shouldScrollToTop = true - } else { - // 다른 탭으로 전환 시 - isUserTabChange = true - } - feedViewModel.onTabSelected(index) + onTabSelected(index) } ) @@ -310,7 +357,7 @@ fun FeedScreen( ) { Text( modifier = Modifier.padding(bottom = 12.dp), - text = if (progress.value < 1.0f) { + text = if (progress < 1.0f) { stringResource(R.string.posting_in_progress_feed) } else { stringResource(R.string.posting_complete_feed) @@ -328,7 +375,7 @@ fun FeedScreen( ) { Box( modifier = Modifier - .fillMaxWidth(fraction = progress.value) + .fillMaxWidth(fraction = progress) .fillMaxHeight() .background( color = colors.NeonGreen, @@ -428,7 +475,7 @@ fun FeedScreen( MyFeedCard( feedItem = feedItem, - onLikeClick = { feedViewModel.changeFeedLike(feedItem.id) }, + onLikeClick = { onChangeFeedLike(feedItem.id) }, onContentClick = { onNavigateToFeedComment(feedItem.id) }, @@ -483,10 +530,10 @@ fun FeedScreen( feedItem = feedItem, bottomTextColor = hexToColor(allFeed.aliasColor), onBookmarkClick = { - feedViewModel.changeFeedSave(feedItem.id) + onChangeFeedSave(feedItem.id) }, onLikeClick = { - feedViewModel.changeFeedLike(feedItem.id) + onChangeFeedLike(feedItem.id) }, onContentClick = { onNavigateToFeedComment(feedItem.id) @@ -498,8 +545,6 @@ fun FeedScreen( onNavigateToBookDetail(allFeed.isbn) }, onProfileClick = { - // 프로필에서 돌아올 때를 위한 플래그 설정 - navController.currentBackStackEntry?.savedStateHandle?.set("from_profile", true) onNavigateToUserProfile(allFeed.creatorId) } ) @@ -553,24 +598,26 @@ fun FeedScreen( @Preview(showBackground = true) @Composable -private fun FeedScreenPreview() { - ThipTheme { - FeedScreen( - onNavigateToFeedWrite = { }, - onNavigateToBookDetail = { }, - navController = rememberNavController() - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun FeedScreenWithoutDataPreview() { +private fun FeedContentPreview() { ThipTheme { - FeedScreen( - onNavigateToFeedWrite = { }, - onNavigateToBookDetail = { }, - navController = rememberNavController() + FeedContent( + feedUiState = com.texthip.thip.ui.feed.viewmodel.FeedUiState(), + showProgressBar = false, + progress = 0f, + currentListState = LazyListState(), + feedTabTitles = listOf("피드", "내 피드"), + onNavigateToSearchPeople = {}, + onNavigateToNotification = {}, + onNavigateToMySubscription = {}, + onNavigateToOthersSubscription = {}, + onNavigateToFeedComment = {}, + onNavigateToBookDetail = {}, + onNavigateToUserProfile = {}, + onNavigateToFeedWrite = {}, + onTabSelected = {}, + onChangeFeedLike = {}, + onChangeFeedSave = {}, + onPullToRefresh = {} ) } } diff --git a/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomUnlockScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomUnlockScreen.kt index cfbcd686..19557ce5 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomUnlockScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomUnlockScreen.kt @@ -97,6 +97,46 @@ fun GroupRoomUnlockScreen( } } + GroupRoomUnlockContent( + password = password, + showError = showError, + focusRequesters = focusRequesters, + onBackClick = onBackClick, + onPasswordChange = { index, input -> + if (input.length <= 1 && input.all { it.isDigit() }) { + val newPassword = password.copyOf() + val wasEmpty = password[index].isEmpty() + newPassword[index] = input + password = newPassword + + // 숫자가 입력되면 다음 칸으로 이동 + if (input.isNotEmpty() && index < 3) { + focusRequesters[index + 1].requestFocus() + } else if (input.isEmpty() && !wasEmpty && index > 0) { + focusRequesters[index - 1].requestFocus() + } + } + }, + onBackspace = { index -> + // 빈 박스에서 백스페이스 → 이전 박스로 이동 + if (index > 0) { + val prevIndex = index - 1 + focusRequesters[prevIndex].requestFocus() + } + } + ) +} + +@Composable +private fun GroupRoomUnlockContent( + password: Array, + showError: Boolean, + focusRequesters: List, + onBackClick: () -> Unit, + onPasswordChange: (Int, String) -> Unit, + onBackspace: (Int) -> Unit +) { + Box(modifier = Modifier.fillMaxSize().advancedImePadding()) { Column( modifier = Modifier.fillMaxSize() @@ -130,29 +170,8 @@ fun GroupRoomUnlockScreen( repeat(4) { index -> SingleDigitBox( value = password[index], - onValueChange = { input -> - if (input.length <= 1 && input.all { it.isDigit() }) { - val newPassword = password.copyOf() - val wasEmpty = password[index].isEmpty() - newPassword[index] = input - password = newPassword - - // 숫자가 입력되면 다음 칸으로 이동 - if (input.isNotEmpty() && index < 3) { - focusRequesters[index + 1].requestFocus() - } else if (input.isEmpty() && !wasEmpty && index > 0) { - focusRequesters[index - 1].requestFocus() - } - - } - }, - onBackspace = { - // 빈 박스에서 백스페이스 → 이전 박스로 이동 - if (index > 0) { - val prevIndex = index - 1 - focusRequesters[prevIndex].requestFocus() - } - }, + onValueChange = { input -> onPasswordChange(index, input) }, + onBackspace = { onBackspace(index) }, borderColor = if (showError) colors.Red else Color.Transparent, modifier = Modifier .size(44.dp) @@ -183,11 +202,15 @@ fun GroupRoomUnlockScreen( @Preview(showBackground = true) @Composable -fun GroupRoomUnlockScreenPreview() { +private fun GroupRoomUnlockContentPreview() { ThipTheme { - GroupRoomUnlockScreen( + GroupRoomUnlockContent( + password = arrayOf("", "", "", ""), + showError = false, + focusRequesters = List(4) { FocusRequester() }, onBackClick = {}, - onSuccessNavigation = {} + onPasswordChange = { _, _ -> }, + onBackspace = {} ) } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/group/search/screen/GroupSearchScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/search/screen/GroupSearchScreen.kt index b9d3bc81..d704dd8c 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/search/screen/GroupSearchScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/search/screen/GroupSearchScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.texthip.thip.R +import com.texthip.thip.data.manager.Genre import com.texthip.thip.ui.common.buttons.FilterButton import com.texthip.thip.ui.common.forms.SearchBookTextField import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar @@ -29,6 +30,7 @@ import com.texthip.thip.ui.group.search.component.GroupRecentSearch import com.texthip.thip.ui.group.search.component.GroupEmptyResult import com.texthip.thip.ui.group.search.component.GroupFilteredSearchResult import com.texthip.thip.ui.group.search.component.GroupLiveSearchResult +import com.texthip.thip.ui.group.search.viewmodel.GroupSearchUiState import com.texthip.thip.ui.group.search.viewmodel.GroupSearchViewModel import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.utils.rooms.toDisplayStrings @@ -41,6 +43,34 @@ fun GroupSearchScreen( viewModel: GroupSearchViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() + + GroupSearchContent( + modifier = modifier, + uiState = uiState, + onNavigateBack = onNavigateBack, + onRoomClick = onRoomClick, + onUpdateSearchQuery = viewModel::updateSearchQuery, + onSearchButtonClick = viewModel::onSearchButtonClick, + onDeleteRecentSearch = viewModel::deleteRecentSearchByKeyword, + onLoadMoreRooms = viewModel::loadMoreRooms, + onUpdateSelectedGenre = viewModel::updateSelectedGenre, + onUpdateSortType = viewModel::updateSortType + ) +} + +@Composable +private fun GroupSearchContent( + modifier: Modifier = Modifier, + uiState: GroupSearchUiState, + onNavigateBack: () -> Unit = {}, + onRoomClick: (Int) -> Unit = {}, + onUpdateSearchQuery: (String) -> Unit = {}, + onSearchButtonClick: () -> Unit = {}, + onDeleteRecentSearch: (String) -> Unit = {}, + onLoadMoreRooms: () -> Unit = {}, + onUpdateSelectedGenre: (Genre?) -> Unit = {}, + onUpdateSortType: (String) -> Unit = {} +) { val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current @@ -61,7 +91,6 @@ fun GroupSearchScreen( else -> 0 } - LaunchedEffect(uiState.isCompleteSearching) { if (uiState.isCompleteSearching) { focusManager.clearFocus() @@ -91,12 +120,8 @@ fun GroupSearchScreen( .focusRequester(focusRequester), hint = stringResource(R.string.group_room_search_hint), text = uiState.searchQuery, - onValueChange = { query -> - viewModel.updateSearchQuery(query) - }, - onSearch = { - viewModel.onSearchButtonClick() - } + onValueChange = onUpdateSearchQuery, + onSearch = { _ -> onSearchButtonClick() } ) Spacer(modifier = Modifier.height(16.dp)) @@ -105,19 +130,17 @@ fun GroupSearchScreen( if (uiState.recentSearches.isEmpty()) { GroupRecentSearch( recentSearches = emptyList(), - onSearchClick = {}, - onRemove = {} + onSearchClick = { _ -> }, + onRemove = { _ -> } ) } else { GroupRecentSearch( recentSearches = uiState.recentSearches.map { it.searchTerm }, onSearchClick = { keyword -> - viewModel.updateSearchQuery(keyword) - viewModel.onSearchButtonClick() + onUpdateSearchQuery(keyword) + onSearchButtonClick() }, - onRemove = { keyword -> - viewModel.deleteRecentSearchByKeyword(keyword) - } + onRemove = onDeleteRecentSearch ) } } @@ -134,7 +157,7 @@ fun GroupSearchScreen( onRoomClick = { room -> onRoomClick(room.roomId) }, canLoadMore = uiState.canLoadMore, isLoadingMore = uiState.isLoadingMore, - onLoadMore = { viewModel.loadMoreRooms() } + onLoadMore = onLoadMoreRooms ) } } @@ -157,14 +180,14 @@ fun GroupSearchScreen( } else { null } - viewModel.updateSelectedGenre(selectedGenre) + onUpdateSelectedGenre(selectedGenre) }, resultCount = uiState.searchResults.size, roomList = uiState.searchResults, onRoomClick = { room -> onRoomClick(room.roomId) }, canLoadMore = uiState.canLoadMore, isLoadingMore = uiState.isLoadingMore, - onLoadMore = { viewModel.loadMoreRooms() } + onLoadMore = onLoadMoreRooms ) } } @@ -184,7 +207,7 @@ fun GroupSearchScreen( 1 -> "memberCount" else -> "deadline" } - viewModel.updateSortType(sortType) + onUpdateSortType(sortType) } ) } @@ -194,8 +217,10 @@ fun GroupSearchScreen( @Preview @Composable -fun PreviewGroupSearchScreen() { +private fun GroupSearchContentPreview() { ThipTheme { - GroupSearchScreen() + GroupSearchContent( + uiState = com.texthip.thip.ui.group.search.viewmodel.GroupSearchUiState() + ) } } 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 84662a7f..4cd4f12d 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,6 +1,6 @@ package com.texthip.thip.ui.navigator.navigations -import SplashScreen +import com.texthip.thip.ui.signin.screen.SplashScreen import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.hilt.navigation.compose.hiltViewModel diff --git a/app/src/main/java/com/texthip/thip/ui/signin/screen/SplashScreen.kt b/app/src/main/java/com/texthip/thip/ui/signin/screen/SplashScreen.kt index ca14c54b..8ab7f7b6 100644 --- a/app/src/main/java/com/texthip/thip/ui/signin/screen/SplashScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/signin/screen/SplashScreen.kt @@ -1,3 +1,5 @@ +package com.texthip.thip.ui.signin.screen + import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -25,6 +27,7 @@ import com.texthip.thip.R import com.texthip.thip.ui.signin.viewmodel.SplashDestination import com.texthip.thip.ui.signin.viewmodel.SplashViewModel import com.texthip.thip.ui.theme.Purple +import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @@ -44,6 +47,11 @@ fun SplashScreen( } } + SplashContent() +} + +@Composable +private fun SplashContent() { Column( Modifier .background(colors.Black) @@ -72,6 +80,8 @@ fun SplashScreen( @Preview @Composable -private fun SplashScreenPrev() { - SplashScreen() +private fun SplashContentPreview() { + ThipTheme { + SplashContent() + } } \ No newline at end of file From 21a322726708c26694ec3de6f896dcb501f78af6 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Sat, 30 Aug 2025 14:44:18 +0900 Subject: [PATCH 05/27] =?UTF-8?q?[refactor]:=20Screen=EC=97=90=EC=84=9C=20?= =?UTF-8?q?Content=EB=A1=9C=20=EB=AF=B8=EA=B5=AC=ED=98=84=EB=90=9C=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=EC=9D=84=20Content=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/feed/screen/FeedCommentScreen.kt | 48 +++++- .../texthip/thip/ui/feed/screen/FeedScreen.kt | 59 +++++++- .../group/search/screen/GroupSearchScreen.kt | 36 ++++- .../thip/ui/mypage/screen/MypageSaveScreen.kt | 143 ++++++++++++++---- 4 files changed, 256 insertions(+), 30 deletions(-) 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 d9da9ddc..01169e11 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 @@ -52,6 +52,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import coil.compose.AsyncImage import com.texthip.thip.R +import com.texthip.thip.data.model.comments.response.CommentList +import com.texthip.thip.data.model.feed.response.FeedDetailResponse import com.texthip.thip.ui.common.bottomsheet.MenuBottomSheet import com.texthip.thip.ui.common.buttons.ActionBarButton import com.texthip.thip.ui.common.buttons.ActionBookButton @@ -62,9 +64,11 @@ 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.viewmodel.FeedDetailUiState 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 +import com.texthip.thip.ui.group.note.viewmodel.CommentsUiState import com.texthip.thip.ui.group.note.viewmodel.CommentsViewModel import com.texthip.thip.ui.group.room.mock.MenuBottomSheetItem import com.texthip.thip.ui.theme.ThipTheme @@ -593,8 +597,48 @@ private fun FeedCommentContent( private fun FeedCommentContentPreview() { ThipTheme { FeedCommentContent( - feedDetailUiState = com.texthip.thip.ui.feed.viewmodel.FeedDetailUiState(), - commentsUiState = com.texthip.thip.ui.group.note.viewmodel.CommentsUiState(), + feedDetailUiState = FeedDetailUiState( + feedDetail = FeedDetailResponse( + feedId = 1, + creatorId = 123L, + creatorNickname = "책읽는사람", + creatorProfileImageUrl = "", + aliasName = "문학 애호가", + aliasColor = "#FF6B9D", + postDate = "2시간 전", + bookTitle = "코스모스", + isbn = "9788983711892", + bookAuthor = "칼 세이건", + contentBody = "이 책을 읽으면서 우주에 대한 새로운 시각을 갖게 되었습니다. 과학적 사실들이 아름다운 문장으로 표현되어 있어서 읽는 내내 감동받았어요.", + contentUrls = listOf("https://example.com/image1.jpg"), + tagList = listOf("과학", "우주", "감동"), + isLiked = true, + likeCount = 42, + commentCount = 8, + isSaved = false, + isWriter = false, + isPublic = true + ) + ), + commentsUiState = CommentsUiState( + comments = listOf( + CommentList( + commentId = 1, + creatorId = 456L, + creatorProfileImageUrl = "", + creatorNickname = "독서왕", + aliasName = "과학 전문가", + aliasColor = "#00FF7F", + postDate = "1시간 전", + content = "정말 좋은 책이네요! 저도 읽어보고 싶습니다.", + likeCount = 5, + isDeleted = false, + isWriter = false, + isLike = false, + replyList = emptyList() + ) + ) + ), onNavigateBack = {}, onNavigateToFeedEdit = {}, onNavigateToUserProfile = {}, 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 4439af2b..d55debb8 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 @@ -45,6 +45,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.texthip.thip.R +import com.texthip.thip.data.model.feed.response.AllFeedItem +import com.texthip.thip.data.model.users.response.RecentWriterList import com.texthip.thip.ui.common.buttons.FloatingButton import com.texthip.thip.ui.common.header.AuthorHeader import com.texthip.thip.ui.common.header.HeaderMenuBarTab @@ -53,6 +55,7 @@ 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.FeedUiState import com.texthip.thip.ui.feed.viewmodel.FeedViewModel import com.texthip.thip.ui.mypage.component.SavedFeedCard import com.texthip.thip.ui.mypage.mock.FeedItem @@ -601,7 +604,61 @@ private fun FeedContent( private fun FeedContentPreview() { ThipTheme { FeedContent( - feedUiState = com.texthip.thip.ui.feed.viewmodel.FeedUiState(), + feedUiState = FeedUiState( + selectedTabIndex = 0, + allFeeds = listOf( + AllFeedItem( + feedId = 1, + creatorId = 123L, + creatorNickname = "책읽는사람", + creatorProfileImageUrl = "", + aliasName = "문학 애호가", + aliasColor = "#FF6B9D", + postDate = "2시간 전", + isbn = "9788983711892", + bookTitle = "코스모스", + bookAuthor = "칼 세이건", + contentBody = "이 책을 읽으면서 우주에 대한 새로운 시각을 갖게 되었습니다. 과학적 사실들이 아름다운 문장으로 표현되어 있어서 읽는 내내 감동받았어요.", + contentUrls = listOf("https://example.com/image1.jpg"), + likeCount = 42, + commentCount = 8, + isSaved = false, + isLiked = true, + isWriter = false + ), + AllFeedItem( + feedId = 2, + creatorId = 456L, + creatorNickname = "소설러버", + creatorProfileImageUrl = "", + aliasName = "추리소설 전문가", + aliasColor = "#4ECDC4", + postDate = "4시간 전", + isbn = "9788932473234", + bookTitle = "셜록 홈즈의 모험", + bookAuthor = "아서 코난 도일", + contentBody = "홈즈의 추리 과정이 정말 흥미진진합니다. 논리적 사고의 힘을 보여주는 명작이에요.", + contentUrls = emptyList(), + likeCount = 28, + commentCount = 15, + isSaved = true, + isLiked = false, + isWriter = false + ) + ), + recentWriters = listOf( + RecentWriterList( + userId = 789L, + nickname = "철학자", + profileImageUrl = "" + ), + RecentWriterList( + userId = 101L, + nickname = "역사학도", + profileImageUrl = "" + ) + ) + ), showProgressBar = false, progress = 0f, currentListState = LazyListState(), diff --git a/app/src/main/java/com/texthip/thip/ui/group/search/screen/GroupSearchScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/search/screen/GroupSearchScreen.kt index d704dd8c..1822d574 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/search/screen/GroupSearchScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/search/screen/GroupSearchScreen.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.texthip.thip.R import com.texthip.thip.data.manager.Genre +import com.texthip.thip.data.model.book.response.RecentSearchItem +import com.texthip.thip.data.model.rooms.response.SearchRoomItem import com.texthip.thip.ui.common.buttons.FilterButton import com.texthip.thip.ui.common.forms.SearchBookTextField import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar @@ -220,7 +222,39 @@ private fun GroupSearchContent( private fun GroupSearchContentPreview() { ThipTheme { GroupSearchContent( - uiState = com.texthip.thip.ui.group.search.viewmodel.GroupSearchUiState() + uiState = GroupSearchUiState( + searchQuery = "코스모스", + isCompleteSearching = true, + searchResults = listOf( + SearchRoomItem( + roomId = 1, + bookImageUrl = "", + roomName = "코스모스 독서 모임", + memberCount = 8, + recruitCount = 12, + deadlineDate = "2024-12-31", + isPublic = true + ) + ), + recentSearches = listOf( + RecentSearchItem( + recentSearchId = 1, + searchTerm = "해리포터" + ), + RecentSearchItem( + recentSearchId = 2, + searchTerm = "1984" + ) + ), + genres = listOf( + Genre.LITERATURE, + Genre.SCIENCE_IT, + Genre.SOCIAL_SCIENCE, + Genre.HUMANITIES, + Genre.ART + ), + selectedGenre = Genre.SCIENCE_IT + ) ) } } diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt index 76ea537b..f3c2e758 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt @@ -1,6 +1,5 @@ package com.texthip.thip.ui.mypage.screen -import android.annotation.SuppressLint import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -32,8 +31,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.texthip.thip.R import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar -import com.texthip.thip.ui.mypage.component.BookContent -import com.texthip.thip.ui.mypage.component.FeedContent +import com.texthip.thip.ui.mypage.mock.BookItem +import com.texthip.thip.ui.mypage.mock.FeedItem import com.texthip.thip.ui.mypage.viewmodel.SavedBookViewModel import com.texthip.thip.ui.mypage.viewmodel.SavedFeedViewModel import com.texthip.thip.ui.theme.ThipTheme @@ -61,6 +60,29 @@ fun MypageSaveScreen( } } + MypageSaveContent( + selectedTabIndex = selectedTabIndex, + onTabSelected = { selectedTabIndex = it }, + feedList = feedList, + bookList = bookList, + onNavigateBack = onNavigateBack, + onBookClick = onBookClick, + onFeedClick = onFeedClick + ) +} + +@Composable +private fun MypageSaveContent( + selectedTabIndex: Int, + onTabSelected: (Int) -> Unit, + feedList: List, + bookList: List, + onNavigateBack: () -> Unit, + onBookClick: (isbn: String) -> Unit, + onFeedClick: (feedId: Long) -> Unit +) { + val tabs = listOf(stringResource(R.string.feed), stringResource(R.string.book)) + Column( Modifier .background(colors.Black) @@ -103,7 +125,7 @@ fun MypageSaveScreen( Tab( modifier = Modifier.width(60.dp), selected = selected, - onClick = { selectedTabIndex = index }, + onClick = { onTabSelected(index) }, selectedContentColor = colors.White, unselectedContentColor = colors.Grey02, text = { @@ -123,17 +145,27 @@ fun MypageSaveScreen( .fillMaxWidth() ) { when (selectedTabIndex) { - 0 -> FeedContent( - feedList = feedList, - onFeedClick = onFeedClick, - viewModel = feedViewModel, - ) + 0 -> { + Text( + text = "저장된 피드 ${feedList.size}개", + style = typography.smalltitle_sb600_s18_h24, + color = colors.White, + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp) + ) + } - 1 -> BookContent( - bookList = bookList, - onBookClick = onBookClick, - viewModel = bookViewModel, - ) + 1 -> { + Text( + text = "저장된 책 ${bookList.size}개", + style = typography.smalltitle_sb600_s18_h24, + color = colors.White, + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp) + ) + } } } } @@ -141,22 +173,81 @@ fun MypageSaveScreen( } -@SuppressLint("ViewModelConstructorInComposable") -@Preview -@Composable -private fun SavedScreenPrev() { - MypageSaveScreen( - onNavigateBack = {}, - ) -} - -@SuppressLint("ViewModelConstructorInComposable") @Preview @Composable -private fun SavedScreenWithoutFeedPrev() { +private fun MypageSaveContentPreview() { ThipTheme { - MypageSaveScreen( + MypageSaveContent( + selectedTabIndex = 0, + onTabSelected = {}, + feedList = listOf( + FeedItem( + id = 1L, + userProfileImage = "", + userName = "책벌레", + userRole = "소설 마니아", + bookTitle = "노르웨이의 숲", + authName = "무라카미 하루키", + timeAgo = "3시간 전", + content = "무라카미 하루키의 대표작 중 하나입니다. 청춘의 아픔과 사랑을 섬세하게 그려낸 작품이에요. 특히 와타나베의 내면 묘사가 인상깊었습니다.", + likeCount = 35, + commentCount = 12, + isLiked = true, + isSaved = true, + isLocked = false, + tags = listOf("일본문학", "청춘", "사랑"), + imageUrls = listOf("https://example.com/book1.jpg") + ), + FeedItem( + id = 2L, + userProfileImage = "", + userName = "역사애호가", + userRole = "한국사 전문가", + bookTitle = "총, 균, 쇠", + authName = "재레드 다이아몬드", + timeAgo = "1일 전", + content = "인류 문명의 발전을 지리학적 관점에서 분석한 놀라운 책입니다. 왜 어떤 대륙이 다른 대륙을 정복했는지에 대한 답을 찾을 수 있어요.", + likeCount = 67, + commentCount = 24, + isLiked = false, + isSaved = true, + isLocked = false, + tags = listOf("역사", "문명", "지리학"), + imageUrls = emptyList() + ) + ), + bookList = listOf( + BookItem( + id = 1, + title = "1984", + author = "조지 오웰", + publisher = "민음사", + imageUrl = "", + isbn = "9788937460777", + isSaved = true + ), + BookItem( + id = 2, + title = "사피엔스", + author = "유발 하라리", + publisher = "김영사", + imageUrl = "", + isbn = "9788934972464", + isSaved = true + ), + BookItem( + id = 3, + title = "코스모스", + author = "칼 세이건", + publisher = "사이언스북스", + imageUrl = "", + isbn = "9788983711892", + isSaved = true + ) + ), onNavigateBack = {}, + onBookClick = {}, + onFeedClick = {} ) } } \ No newline at end of file From a26da573ade46ab18d023225d34c94edf70bb501 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Sat, 30 Aug 2025 14:49:58 +0900 Subject: [PATCH 06/27] =?UTF-8?q?[refactor]:=20MyPageSave=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=A0=80=EC=9E=A5=EB=90=9C=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EC=99=80=20=EC=B1=85=EC=9D=84=20=EB=88=8C=EB=A0=80=EC=9D=84=20?= =?UTF-8?q?=EB=95=8C=20=EC=9E=90=EC=84=B8=ED=9E=88=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/mypage/screen/MypageSaveScreen.kt | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt index f3c2e758..d53088f8 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt @@ -31,6 +31,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.texthip.thip.R import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar +import com.texthip.thip.ui.mypage.component.BookContent +import com.texthip.thip.ui.mypage.component.FeedContent import com.texthip.thip.ui.mypage.mock.BookItem import com.texthip.thip.ui.mypage.mock.FeedItem import com.texthip.thip.ui.mypage.viewmodel.SavedBookViewModel @@ -67,7 +69,9 @@ fun MypageSaveScreen( bookList = bookList, onNavigateBack = onNavigateBack, onBookClick = onBookClick, - onFeedClick = onFeedClick + onFeedClick = onFeedClick, + feedViewModel = feedViewModel, + bookViewModel = bookViewModel ) } @@ -79,7 +83,9 @@ private fun MypageSaveContent( bookList: List, onNavigateBack: () -> Unit, onBookClick: (isbn: String) -> Unit, - onFeedClick: (feedId: Long) -> Unit + onFeedClick: (feedId: Long) -> Unit, + feedViewModel: SavedFeedViewModel?, + bookViewModel: SavedBookViewModel? ) { val tabs = listOf(stringResource(R.string.feed), stringResource(R.string.book)) @@ -145,25 +151,19 @@ private fun MypageSaveContent( .fillMaxWidth() ) { when (selectedTabIndex) { - 0 -> { - Text( - text = "저장된 피드 ${feedList.size}개", - style = typography.smalltitle_sb600_s18_h24, - color = colors.White, - modifier = Modifier - .align(Alignment.Center) - .padding(16.dp) + 0 -> feedViewModel?.let { + FeedContent( + feedList = feedList, + onFeedClick = onFeedClick, + viewModel = it ) } - 1 -> { - Text( - text = "저장된 책 ${bookList.size}개", - style = typography.smalltitle_sb600_s18_h24, - color = colors.White, - modifier = Modifier - .align(Alignment.Center) - .padding(16.dp) + 1 -> bookViewModel?.let { + BookContent( + bookList = bookList, + onBookClick = onBookClick, + viewModel = it ) } } @@ -247,7 +247,9 @@ private fun MypageSaveContentPreview() { ), onNavigateBack = {}, onBookClick = {}, - onFeedClick = {} + onFeedClick = {}, + feedViewModel = null, + bookViewModel = null ) } } \ No newline at end of file From fce906c24fbb74271a5849b8b76703dbd20eabd4 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Sat, 30 Aug 2025 15:34:48 +0900 Subject: [PATCH 07/27] =?UTF-8?q?[refactor]:=20=EC=9E=90=EB=8F=99=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=86=8C=EB=AC=B8=EC=9E=90=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/texthip/thip/ui/common/forms/WarningTextField.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/texthip/thip/ui/common/forms/WarningTextField.kt b/app/src/main/java/com/texthip/thip/ui/common/forms/WarningTextField.kt index 6e535e4e..27dbc3d6 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/forms/WarningTextField.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/forms/WarningTextField.kt @@ -62,7 +62,7 @@ fun WarningTextField( onValueChange = { input -> var filtered = input if (isNumberOnly) filtered = filtered.filter { it.isDigit() } - if (preventUppercase) filtered = filtered.filter { !it.isUpperCase() } + if (preventUppercase) filtered = filtered.lowercase() if (filtered.length > maxLength) filtered = filtered.take(maxLength) onValueChange(filtered) }, From 8d8243892bf9b6ad88b5b85e418e017d105b922a Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Sun, 31 Aug 2025 21:57:12 +0900 Subject: [PATCH 08/27] =?UTF-8?q?[refactor]:=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EB=8D=94=EB=B3=B4=EA=B8=B0=20=ED=99=94=EB=A9=B4=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EC=9C=84=EC=B9=98=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/feed/component/ImageViewerModal.kt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) 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 a7e72064..65d455ca 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 @@ -47,17 +47,21 @@ fun ImageViewerModal( .clickable { onDismiss() } ) { // 이전 버튼 - Icon( - painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = "닫기", - tint = colors.White, + Box( modifier = Modifier .align(Alignment.TopStart) - .padding(20.dp) - .size(24.dp) - .clickable { onDismiss() } + .padding(horizontal = 20.dp, vertical = 16.dp) .zIndex(1f) - ) + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = "닫기", + tint = colors.White, + modifier = Modifier + .size(24.dp) + .clickable { onDismiss() } + ) + } // 이미지 페이저 HorizontalPager( From b38efa392a081c7f5d98f899c9c37c8fe704b054 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Sun, 31 Aug 2025 22:01:43 +0900 Subject: [PATCH 09/27] =?UTF-8?q?[refactor]:=20=ED=94=BC=EB=93=9C=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=B9=AD=ED=98=B8=EB=B3=84=20?= =?UTF-8?q?=EC=83=89=20=EB=B0=98=EC=98=81=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/texthip/thip/ui/feed/screen/FeedCommentScreen.kt | 2 ++ 1 file changed, 2 insertions(+) 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 01169e11..c14322f8 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 @@ -74,6 +74,7 @@ import com.texthip.thip.ui.group.room.mock.MenuBottomSheetItem 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 com.texthip.thip.utils.rooms.advancedImePadding import kotlinx.coroutines.delay @@ -279,6 +280,7 @@ private fun FeedCommentContent( profileImage = feedDetail.creatorProfileImageUrl ?: "", topText = feedDetail.creatorNickname, bottomText = feedDetail.aliasName, + bottomTextColor = hexToColor(feedDetail.aliasColor), showSubscriberInfo = false, hoursAgo = feedDetail.postDate, onClick = { onNavigateToUserProfile(feedDetail.creatorId) } From b0fe3dab27a253cfcf04d394daf4ac95f1aaf748 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Sun, 31 Aug 2025 22:23:05 +0900 Subject: [PATCH 10/27] =?UTF-8?q?[refactor]:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=B9=AD=ED=98=B8=EB=B3=84=20=EC=83=89=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/texthip/thip/ui/feed/component/LiveSearchPeopleResult.kt | 1 + 1 file changed, 1 insertion(+) 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 42673458..23e0941c 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 @@ -30,6 +30,7 @@ fun SearchPeopleResult( profileImage = user.profileImageUrl, nickname = user.nickname, badgeText = user.role, + badgeTextColor = user.roleColor, profileImageSize = 36.dp, showButton = false, showThipNum = true, From 75008af357e5d9055a23be9ce13719294f12823d Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Mon, 1 Sep 2025 00:20:43 +0900 Subject: [PATCH 11/27] =?UTF-8?q?[refactor]:=20=ED=94=BC=EB=93=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20QA=20=EC=88=98=EC=A0=95(=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=20=EC=88=98=EC=A0=95,=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94)=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/feed/screen/FeedMyScreen.kt | 1 + .../thip/ui/feed/screen/FeedOthersScreen.kt | 4 ++ .../ui/feed/viewmodel/FeedOthersViewModel.kt | 2 +- .../thip/ui/feed/viewmodel/FeedViewModel.kt | 42 ++++++++++++++++++- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedMyScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedMyScreen.kt index eda45f2f..e044684c 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedMyScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedMyScreen.kt @@ -48,6 +48,7 @@ fun FeedMyScreen( LaunchedEffect(Unit) { viewModel.onTabSelected(1) + viewModel.fetchMyFeedInfo() } FeedMyContent( 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 b55d967d..3de4b5e0 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 @@ -54,6 +54,10 @@ fun FeedOthersScreen( ) { val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.fetchData() + } FeedOthersContent( uiState = uiState, 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 3c71985a..b25c54e7 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 @@ -65,7 +65,7 @@ class FeedOthersViewModel @Inject constructor( } - private fun fetchData() { + fun fetchData() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } 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 4290d5e3..901569f3 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 @@ -68,12 +68,52 @@ class FeedViewModel @Inject constructor( loadAllFeeds() fetchRecentWriters() fetchMyFeedInfo() + observeFeedUpdates() } private fun updateState(update: (FeedUiState) -> FeedUiState) { _uiState.value = update(_uiState.value) } + private fun observeFeedUpdates() { + viewModelScope.launch { + feedRepository.feedStateUpdateResult.collect { update -> + val updatedAllFeeds = _uiState.value.allFeeds.map { feed -> + if (feed.feedId.toLong() == update.feedId) { + feed.copy( + isLiked = update.isLiked, + likeCount = update.likeCount, + isSaved = update.isSaved, + commentCount = update.commentCount + ) + } else { + feed + } + } + + val updatedMyFeeds = _uiState.value.myFeeds.map { feed -> + if (feed.feedId.toLong() == update.feedId) { + feed.copy( + isLiked = update.isLiked, + likeCount = update.likeCount, + isSaved = update.isSaved, + commentCount = update.commentCount + ) + } else { + feed + } + } + + _uiState.update { + it.copy( + allFeeds = updatedAllFeeds, + myFeeds = updatedMyFeeds + ) + } + } + } + } + fun onTabSelected(index: Int) { val isCurrentTab = _uiState.value.selectedTabIndex == index updateState { it.copy(selectedTabIndex = index) } @@ -333,7 +373,7 @@ class FeedViewModel @Inject constructor( } } - private fun fetchMyFeedInfo() { + fun fetchMyFeedInfo() { viewModelScope.launch { feedRepository.getMyFeedInfo() .onSuccess { data -> From 72d1ec5f8dceabcbd9c27e8e7b9dbaec8b30b468 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Mon, 1 Sep 2025 00:25:00 +0900 Subject: [PATCH 12/27] =?UTF-8?q?[refactor]:=20=ED=94=BC=EB=93=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20QA=20=EC=88=98=EC=A0=95(=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=20=EC=88=98=EC=A0=95,=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94)=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../texthip/thip/ui/feed/viewmodel/FeedOthersViewModel.kt | 6 ------ 1 file changed, 6 deletions(-) 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 b25c54e7..695712ac 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,7 +1,5 @@ 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 @@ -77,10 +75,6 @@ class FeedOthersViewModel @Inject constructor( val fetchedFeeds = feedsResult.getOrNull()?.feedList ?: emptyList() - // ✅ 로그를 추가하여 유저 정보와 피드 개수를 확인 - Log.d("FeedOthersViewModel", "User Info Result: ${userInfoResult.getOrNull()}") - Log.d("FeedOthersViewModel", "Fetched Feeds Count: ${fetchedFeeds.size}") - _uiState.update { it.copy( isLoading = false, From f92b0e49e7cefc4876540988a883d4cf18f67c23 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 2 Sep 2025 18:51:15 +0900 Subject: [PATCH 13/27] =?UTF-8?q?[refactor]:=20=EC=B1=85=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20api=20cursor=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/book/response/BookListResponse.kt | 4 +- .../book/response/BookUserSaveResponse.kt | 5 +- .../thip/data/repository/BookRepository.kt | 8 +- .../texthip/thip/data/service/BookService.kt | 7 +- .../thip/ui/feed/screen/FeedWriteScreen.kt | 19 +- .../ui/feed/viewmodel/FeedWriteUiState.kt | 8 + .../ui/feed/viewmodel/FeedWriteViewModel.kt | 243 +++++++++++++++--- .../makeroom/screen/GroupMakeRoomScreen.kt | 19 +- .../viewmodel/GroupMakeRoomUiState.kt | 8 + .../viewmodel/GroupMakeRoomViewModel.kt | 243 +++++++++++++++--- .../thip/ui/mypage/component/BookContent.kt | 22 ++ 11 files changed, 514 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/data/model/book/response/BookListResponse.kt b/app/src/main/java/com/texthip/thip/data/model/book/response/BookListResponse.kt index b64eea5c..3b821b27 100644 --- a/app/src/main/java/com/texthip/thip/data/model/book/response/BookListResponse.kt +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/BookListResponse.kt @@ -6,7 +6,9 @@ import kotlinx.serialization.Serializable @Serializable data class BookListResponse( - @SerialName("bookList") val bookList: List = emptyList() + @SerialName("bookList") val bookList: List = emptyList(), + @SerialName("nextCursor") val nextCursor: String? = null, + @SerialName("isLast") val isLast: Boolean = false ) @Serializable diff --git a/app/src/main/java/com/texthip/thip/data/model/book/response/BookUserSaveResponse.kt b/app/src/main/java/com/texthip/thip/data/model/book/response/BookUserSaveResponse.kt index 8189484c..061ee805 100644 --- a/app/src/main/java/com/texthip/thip/data/model/book/response/BookUserSaveResponse.kt +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/BookUserSaveResponse.kt @@ -1,10 +1,13 @@ package com.texthip.thip.data.model.book.response +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class BookUserSaveResponse( - val bookList: List + val bookList: List, + @SerialName("nextCursor") val nextCursor: String? = null, + @SerialName("isLast") val isLast: Boolean = false ) @Serializable diff --git a/app/src/main/java/com/texthip/thip/data/repository/BookRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/BookRepository.kt index 5815c821..b4fb7072 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/BookRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/BookRepository.kt @@ -19,8 +19,8 @@ class BookRepository @Inject constructor( ) { /** 저장된 책 또는 모임 책 목록 조회 */ - suspend fun getBooks(type: String): Result = runCatching { - bookService.getBooks(type) + suspend fun getBooks(type: String, cursor: String? = null): Result = runCatching { + bookService.getBooks(type, cursor) .handleBaseResponse() .getOrThrow() } @@ -67,8 +67,8 @@ class BookRepository @Inject constructor( .getOrThrow() } - suspend fun getSavedBooks(): Result = runCatching { - bookService.getSavedBooks() + suspend fun getSavedBooks(cursor: String? = null): Result = runCatching { + bookService.getSavedBooks(cursor) .handleBaseResponse() .getOrThrow() } diff --git a/app/src/main/java/com/texthip/thip/data/service/BookService.kt b/app/src/main/java/com/texthip/thip/data/service/BookService.kt index e5a2f6d3..3e47624a 100644 --- a/app/src/main/java/com/texthip/thip/data/service/BookService.kt +++ b/app/src/main/java/com/texthip/thip/data/service/BookService.kt @@ -20,7 +20,8 @@ interface BookService { /** 저장된 책 또는 모임 책 목록 조회 */ @GET("books/selectable-list") suspend fun getBooks( - @Query("type") type: String + @Query("type") type: String, + @Query("cursor") cursor: String? = null ): BaseResponse /** 책 검색 */ @@ -56,5 +57,7 @@ interface BookService { ): BaseResponse @GET("books/saved") - suspend fun getSavedBooks(): BaseResponse + suspend fun getSavedBooks( + @Query("cursor") cursor: String? = null + ): BaseResponse } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedWriteScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedWriteScreen.kt index 4c02a4bc..19bdfba9 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedWriteScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedWriteScreen.kt @@ -94,6 +94,9 @@ fun FeedWriteScreen( onToggleTag = viewModel::toggleTag, onRemoveTag = viewModel::removeTag, onSearchBooks = viewModel::searchBooks, + onLoadMoreSavedBooks = viewModel::loadMoreSavedBooks, + onLoadMoreGroupBooks = viewModel::loadMoreGroupBooks, + onLoadMoreSearchResults = viewModel::loadMoreSearchResults, modifier = modifier ) } @@ -114,7 +117,10 @@ fun FeedWriteContent( onSelectCategory: (Int) -> Unit = {}, onToggleTag: (String) -> Unit = {}, onRemoveTag: (String) -> Unit = {}, - onSearchBooks: (String) -> Unit = {} + onSearchBooks: (String) -> Unit = {}, + onLoadMoreSavedBooks: () -> Unit = {}, + onLoadMoreGroupBooks: () -> Unit = {}, + onLoadMoreSearchResults: () -> Unit = {} ) { val scrollState = rememberScrollState() val focusManager = LocalFocusManager.current @@ -405,7 +411,16 @@ fun FeedWriteContent( searchResults = uiState.searchResults, isLoading = uiState.isLoadingBooks, isSearching = uiState.isSearching, - onSearch = onSearchBooks + isLoadingMoreSaved = uiState.isLoadingMoreSavedBooks, + isLoadingMoreGroup = uiState.isLoadingMoreGroupBooks, + isLoadingMoreSearch = uiState.isLoadingMoreSearchResults, + hasMoreSaved = !uiState.isLastSavedBooks, + hasMoreGroup = !uiState.isLastGroupBooks, + hasMoreSearch = !uiState.isLastSearchPage, + onSearch = onSearchBooks, + onLoadMoreSaved = onLoadMoreSavedBooks, + onLoadMoreGroup = onLoadMoreGroupBooks, + onLoadMoreSearch = onLoadMoreSearchResults ) } } diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteUiState.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteUiState.kt index 3563678c..0564c924 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteUiState.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteUiState.kt @@ -18,8 +18,16 @@ data class FeedWriteUiState( val savedBooks: List = emptyList(), val groupBooks: List = emptyList(), val isLoadingBooks: Boolean = false, + val isLoadingMoreSavedBooks: Boolean = false, + val isLoadingMoreGroupBooks: Boolean = false, + val isLastSavedBooks: Boolean = false, + val isLastGroupBooks: Boolean = false, val searchResults: List = emptyList(), val isSearching: Boolean = false, + val isLoadingMoreSearchResults: Boolean = false, + val searchPage: Int = 1, + val isLastSearchPage: Boolean = false, + val currentSearchQuery: String = "", val categories: List = emptyList(), val isBookPreselected: Boolean = false, val isLoadingCategories: Boolean = false, diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteViewModel.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteViewModel.kt index f7dccf48..a13dc70b 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.texthip.thip.R import com.texthip.thip.data.model.book.response.BookSavedResponse import com.texthip.thip.data.model.book.response.BookSearchItem +import com.texthip.thip.data.model.book.response.BookUserSaveList import com.texthip.thip.data.provider.StringResourceProvider import com.texthip.thip.data.repository.BookRepository import com.texthip.thip.data.repository.FeedRepository @@ -31,6 +32,11 @@ class FeedWriteViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private var searchJob: Job? = null + private var loadMoreSearchJob: Job? = null + private var savedBooksCursor: String? = null + private var groupBooksCursor: String? = null + private var isLoadingSavedBooks = false + private var isLoadingGroupBooks = false private fun updateState(update: (FeedWriteUiState) -> FeedWriteUiState) { _uiState.value = update(_uiState.value) @@ -191,36 +197,119 @@ class FeedWriteViewModel @Inject constructor( } private fun loadBooks() { + updateState { it.copy(isLoadingBooks = true) } + loadSavedBooks(isInitial = true) + loadGroupBooks(isInitial = true) + } + + fun loadSavedBooks(isInitial: Boolean = false) { + if (isLoadingSavedBooks) return + if (!isInitial && _uiState.value.isLastSavedBooks) return + viewModelScope.launch { - updateState { it.copy(isLoadingBooks = true) } try { - val savedBooksResult = bookRepository.getBooks("SAVED") - savedBooksResult.onSuccess { response -> - updateState { - it.copy(savedBooks = response?.bookList?.map { dto -> dto.toBookData() } - ?: emptyList()) - } - }.onFailure { - updateState { it.copy(savedBooks = emptyList()) } + isLoadingSavedBooks = true + + if (isInitial) { + updateState { it.copy(savedBooks = emptyList(), isLastSavedBooks = false) } + savedBooksCursor = null + } else { + updateState { it.copy(isLoadingMoreSavedBooks = true) } } - val groupBooksResult = bookRepository.getBooks("JOINING") - groupBooksResult.onSuccess { response -> - updateState { - it.copy(groupBooks = response?.bookList?.map { dto -> dto.toBookData() } - ?: emptyList()) + val cursor = if (isInitial) null else savedBooksCursor + + bookRepository.getBooks("SAVED", cursor) + .onSuccess { response -> + if (response != null) { + val currentList = if (isInitial) emptyList() else _uiState.value.savedBooks + val newBooks = response.bookList.map { it.toBookData() } + updateState { + it.copy( + savedBooks = currentList + newBooks, + isLastSavedBooks = response.isLast + ) + } + savedBooksCursor = response.nextCursor + } else { + updateState { it.copy(isLastSavedBooks = true) } + } } - }.onFailure { - updateState { it.copy(groupBooks = emptyList()) } + .onFailure { exception -> + if (isInitial) { + updateState { it.copy(savedBooks = emptyList()) } + } + } + } finally { + isLoadingSavedBooks = false + updateState { + it.copy( + isLoadingBooks = if (isInitial && !isLoadingGroupBooks) false else it.isLoadingBooks, + isLoadingMoreSavedBooks = false + ) } - } catch (e: Exception) { - updateState { it.copy(savedBooks = emptyList(), groupBooks = emptyList()) } + } + } + } + + fun loadGroupBooks(isInitial: Boolean = false) { + if (isLoadingGroupBooks) return + if (!isInitial && _uiState.value.isLastGroupBooks) return + + viewModelScope.launch { + try { + isLoadingGroupBooks = true + + if (isInitial) { + updateState { it.copy(groupBooks = emptyList(), isLastGroupBooks = false) } + groupBooksCursor = null + } else { + updateState { it.copy(isLoadingMoreGroupBooks = true) } + } + + val cursor = if (isInitial) null else groupBooksCursor + + bookRepository.getBooks("JOINING", cursor) + .onSuccess { response -> + if (response != null) { + val currentList = if (isInitial) emptyList() else _uiState.value.groupBooks + val newBooks = response.bookList.map { it.toBookData() } + updateState { + it.copy( + groupBooks = currentList + newBooks, + isLastGroupBooks = response.isLast + ) + } + groupBooksCursor = response.nextCursor + } else { + updateState { it.copy(isLastGroupBooks = true) } + } + } + .onFailure { exception -> + if (isInitial) { + updateState { it.copy(groupBooks = emptyList()) } + } + } } finally { - updateState { it.copy(isLoadingBooks = false) } + isLoadingGroupBooks = false + updateState { + it.copy( + isLoadingBooks = if (isInitial && !isLoadingSavedBooks) false else it.isLoadingBooks, + isLoadingMoreGroupBooks = false + ) + } } } } + fun loadMoreSavedBooks() { + loadSavedBooks(isInitial = false) + } + + fun loadMoreGroupBooks() { + loadGroupBooks(isInitial = false) + } + private fun BookSavedResponse.toBookData(): BookData { return BookData( title = this.bookTitle, @@ -230,6 +319,15 @@ class FeedWriteViewModel @Inject constructor( ) } + private fun BookUserSaveList.toBookDataFromSaved(): BookData { + return BookData( + title = this.bookTitle, + imageUrl = this.bookImageUrl, + author = this.authorName, + isbn = this.isbn + ) + } + private fun BookSearchItem.toBookData(): BookData { return BookData( title = this.title, @@ -241,28 +339,54 @@ class FeedWriteViewModel @Inject constructor( fun searchBooks(query: String) { searchJob?.cancel() + loadMoreSearchJob?.cancel() if (query.isBlank()) { - updateState { it.copy(searchResults = emptyList(), isSearching = false) } + updateState { + it.copy( + searchResults = emptyList(), + isSearching = false, + searchPage = 1, + isLastSearchPage = false, + currentSearchQuery = "" + ) + } return } searchJob = viewModelScope.launch { delay(300) // 디바운싱 - updateState { it.copy(isSearching = true) } + updateState { + it.copy( + isSearching = true, + searchResults = emptyList(), + searchPage = 1, + isLastSearchPage = false, + currentSearchQuery = query + ) + } try { val result = bookRepository.searchBooks(query, page = 1, isFinalized = false) result.onSuccess { response -> - val searchResults = - response?.searchResult?.map { - it.toBookData() - } ?: emptyList() - updateState { - it.copy( - searchResults = searchResults, - isSearching = false - ) + if (response != null) { + val searchResults = response.searchResult.map { it.toBookData() } + updateState { + it.copy( + searchResults = searchResults, + searchPage = response.page, + isLastSearchPage = response.last, + isSearching = false + ) + } + } else { + updateState { + it.copy( + searchResults = emptyList(), + isSearching = false, + isLastSearchPage = true + ) + } } }.onFailure { updateState { @@ -283,6 +407,59 @@ class FeedWriteViewModel @Inject constructor( } } + fun loadMoreSearchResults() { + val currentState = _uiState.value + if (currentState.isLoadingMoreSearchResults || + currentState.isSearching || + currentState.isLastSearchPage || + currentState.searchResults.isEmpty() || + currentState.currentSearchQuery.isBlank()) { + return + } + + loadMoreSearchJob?.cancel() + loadMoreSearchJob = viewModelScope.launch { + updateState { it.copy(isLoadingMoreSearchResults = true) } + + try { + val nextPage = currentState.searchPage + 1 + val result = bookRepository.searchBooks( + currentState.currentSearchQuery, + page = nextPage, + isFinalized = false + ) + result.onSuccess { response -> + if (response != null) { + val newResults = response.searchResult.map { it.toBookData() } + updateState { + it.copy( + searchResults = currentState.searchResults + newResults, + searchPage = response.page, + isLastSearchPage = response.last, + isLoadingMoreSearchResults = false + ) + } + } else { + updateState { + it.copy( + isLoadingMoreSearchResults = false, + isLastSearchPage = true + ) + } + } + }.onFailure { + updateState { + it.copy(isLoadingMoreSearchResults = false) + } + } + } catch (e: Exception) { + updateState { + it.copy(isLoadingMoreSearchResults = false) + } + } + } + } + fun updateFeedContent(content: String) { if (content.length <= 2000) { updateState { it.copy(feedContent = content) } @@ -461,4 +638,10 @@ class FeedWriteViewModel @Inject constructor( fun clearError() { updateState { it.copy(errorMessage = null) } } + + override fun onCleared() { + super.onCleared() + searchJob?.cancel() + loadMoreSearchJob?.cancel() + } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupMakeRoomScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupMakeRoomScreen.kt index 3f00f471..60202284 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupMakeRoomScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupMakeRoomScreen.kt @@ -84,6 +84,9 @@ fun GroupMakeRoomScreen( onTogglePrivate = viewModel::togglePrivate, onUpdatePassword = viewModel::updatePassword, onSearchBooks = viewModel::searchBooks, + onLoadMoreSavedBooks = viewModel::loadMoreSavedBooks, + onLoadMoreGroupBooks = viewModel::loadMoreGroupBooks, + onLoadMoreSearchResults = viewModel::loadMoreSearchResults, modifier = modifier ) } @@ -103,7 +106,10 @@ fun GroupMakeRoomContent( onSetMemberLimit: (Int) -> Unit = {}, onTogglePrivate: (Boolean) -> Unit = {}, onUpdatePassword: (String) -> Unit = {}, - onSearchBooks: (String) -> Unit = {} + onSearchBooks: (String) -> Unit = {}, + onLoadMoreSavedBooks: () -> Unit = {}, + onLoadMoreGroupBooks: () -> Unit = {}, + onLoadMoreSearchResults: () -> Unit = {} ) { val scrollState = rememberScrollState() @@ -258,7 +264,16 @@ fun GroupMakeRoomContent( searchResults = uiState.searchResults, isLoading = uiState.isLoadingBooks, isSearching = uiState.isSearching, - onSearch = onSearchBooks + isLoadingMoreSaved = uiState.isLoadingMoreSavedBooks, + isLoadingMoreGroup = uiState.isLoadingMoreGroupBooks, + isLoadingMoreSearch = uiState.isLoadingMoreSearchResults, + hasMoreSaved = !uiState.isLastSavedBooks, + hasMoreGroup = !uiState.isLastGroupBooks, + hasMoreSearch = !uiState.isLastSearchPage, + onSearch = onSearchBooks, + onLoadMoreSaved = onLoadMoreSavedBooks, + onLoadMoreGroup = onLoadMoreGroupBooks, + onLoadMoreSearch = onLoadMoreSearchResults ) } } diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomUiState.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomUiState.kt index f95b5b31..f3fa81a9 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomUiState.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomUiState.kt @@ -22,8 +22,16 @@ data class GroupMakeRoomUiState( val savedBooks: List = emptyList(), val groupBooks: List = emptyList(), val isLoadingBooks: Boolean = false, + val isLoadingMoreSavedBooks: Boolean = false, + val isLoadingMoreGroupBooks: Boolean = false, + val isLastSavedBooks: Boolean = false, + val isLastGroupBooks: Boolean = false, val searchResults: List = emptyList(), val isSearching: Boolean = false, + val isLoadingMoreSearchResults: Boolean = false, + val searchPage: Int = 1, + val isLastSearchPage: Boolean = false, + val currentSearchQuery: String = "", val genres: List = emptyList(), val isBookPreselected: Boolean = false ) { diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomViewModel.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomViewModel.kt index 7e0cb34f..cfd030ce 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.texthip.thip.R import com.texthip.thip.data.model.book.response.BookSavedResponse import com.texthip.thip.data.model.book.response.BookSearchItem +import com.texthip.thip.data.model.book.response.BookUserSaveList import com.texthip.thip.data.model.rooms.request.CreateRoomRequest import com.texthip.thip.data.manager.Genre import com.texthip.thip.data.provider.StringResourceProvider @@ -33,6 +34,11 @@ class GroupMakeRoomViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private var searchJob: Job? = null + private var loadMoreSearchJob: Job? = null + private var savedBooksCursor: String? = null + private var groupBooksCursor: String? = null + private var isLoadingSavedBooks = false + private var isLoadingGroupBooks = false companion object { private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy.MM.dd") @@ -82,36 +88,119 @@ class GroupMakeRoomViewModel @Inject constructor( } private fun loadBooks() { + updateState { it.copy(isLoadingBooks = true) } + loadSavedBooks(isInitial = true) + loadGroupBooks(isInitial = true) + } + + fun loadSavedBooks(isInitial: Boolean = false) { + if (isLoadingSavedBooks) return + if (!isInitial && _uiState.value.isLastSavedBooks) return + viewModelScope.launch { - updateState { it.copy(isLoadingBooks = true) } try { - val savedBooksResult = bookRepository.getBooks("SAVED") - savedBooksResult.onSuccess { response -> - updateState { - it.copy(savedBooks = response?.bookList?.map { dto -> dto.toBookData() } - ?: emptyList()) - } - }.onFailure { - updateState { it.copy(savedBooks = emptyList()) } + isLoadingSavedBooks = true + + if (isInitial) { + updateState { it.copy(savedBooks = emptyList(), isLastSavedBooks = false) } + savedBooksCursor = null + } else { + updateState { it.copy(isLoadingMoreSavedBooks = true) } } - val groupBooksResult = bookRepository.getBooks("JOINING") - groupBooksResult.onSuccess { response -> - updateState { - it.copy(groupBooks = response?.bookList?.map { dto -> dto.toBookData() } - ?: emptyList()) + val cursor = if (isInitial) null else savedBooksCursor + + bookRepository.getBooks("SAVED", cursor) + .onSuccess { response -> + if (response != null) { + val currentList = if (isInitial) emptyList() else _uiState.value.savedBooks + val newBooks = response.bookList.map { it.toBookData() } + updateState { + it.copy( + savedBooks = currentList + newBooks, + isLastSavedBooks = response.isLast + ) + } + savedBooksCursor = response.nextCursor + } else { + updateState { it.copy(isLastSavedBooks = true) } + } } - }.onFailure { - updateState { it.copy(groupBooks = emptyList()) } + .onFailure { exception -> + if (isInitial) { + updateState { it.copy(savedBooks = emptyList()) } + } + } + } finally { + isLoadingSavedBooks = false + updateState { + it.copy( + isLoadingBooks = if (isInitial && !isLoadingGroupBooks) false else it.isLoadingBooks, + isLoadingMoreSavedBooks = false + ) } - } catch (e: Exception) { - updateState { it.copy(savedBooks = emptyList(), groupBooks = emptyList()) } + } + } + } + + fun loadGroupBooks(isInitial: Boolean = false) { + if (isLoadingGroupBooks) return + if (!isInitial && _uiState.value.isLastGroupBooks) return + + viewModelScope.launch { + try { + isLoadingGroupBooks = true + + if (isInitial) { + updateState { it.copy(groupBooks = emptyList(), isLastGroupBooks = false) } + groupBooksCursor = null + } else { + updateState { it.copy(isLoadingMoreGroupBooks = true) } + } + + val cursor = if (isInitial) null else groupBooksCursor + + bookRepository.getBooks("JOINING", cursor) + .onSuccess { response -> + if (response != null) { + val currentList = if (isInitial) emptyList() else _uiState.value.groupBooks + val newBooks = response.bookList.map { it.toBookData() } + updateState { + it.copy( + groupBooks = currentList + newBooks, + isLastGroupBooks = response.isLast + ) + } + groupBooksCursor = response.nextCursor + } else { + updateState { it.copy(isLastGroupBooks = true) } + } + } + .onFailure { exception -> + if (isInitial) { + updateState { it.copy(groupBooks = emptyList()) } + } + } } finally { - updateState { it.copy(isLoadingBooks = false) } + isLoadingGroupBooks = false + updateState { + it.copy( + isLoadingBooks = if (isInitial && !isLoadingSavedBooks) false else it.isLoadingBooks, + isLoadingMoreGroupBooks = false + ) + } } } } + fun loadMoreSavedBooks() { + loadSavedBooks(isInitial = false) + } + + fun loadMoreGroupBooks() { + loadGroupBooks(isInitial = false) + } + private fun BookSavedResponse.toBookData(): BookData { return BookData( title = this.bookTitle, @@ -121,6 +210,15 @@ class GroupMakeRoomViewModel @Inject constructor( ) } + private fun BookUserSaveList.toBookDataFromSaved(): BookData { + return BookData( + title = this.bookTitle, + imageUrl = this.bookImageUrl, + author = this.authorName, + isbn = this.isbn + ) + } + private fun BookSearchItem.toBookData(): BookData { return BookData( title = this.title, @@ -132,28 +230,54 @@ class GroupMakeRoomViewModel @Inject constructor( fun searchBooks(query: String) { searchJob?.cancel() + loadMoreSearchJob?.cancel() if (query.isBlank()) { - updateState { it.copy(searchResults = emptyList(), isSearching = false) } + updateState { + it.copy( + searchResults = emptyList(), + isSearching = false, + searchPage = 1, + isLastSearchPage = false, + currentSearchQuery = "" + ) + } return } searchJob = viewModelScope.launch { delay(300) // 디바운싱 - updateState { it.copy(isSearching = true) } + updateState { + it.copy( + isSearching = true, + searchResults = emptyList(), + searchPage = 1, + isLastSearchPage = false, + currentSearchQuery = query + ) + } try { val result = bookRepository.searchBooks(query, page = 1, isFinalized = false) result.onSuccess { response -> - val searchResults = - response?.searchResult?.map { - it.toBookData() - } ?: emptyList() - updateState { - it.copy( - searchResults = searchResults, - isSearching = false - ) + if (response != null) { + val searchResults = response.searchResult.map { it.toBookData() } + updateState { + it.copy( + searchResults = searchResults, + searchPage = response.page, + isLastSearchPage = response.last, + isSearching = false + ) + } + } else { + updateState { + it.copy( + searchResults = emptyList(), + isSearching = false, + isLastSearchPage = true + ) + } } }.onFailure { updateState { @@ -174,6 +298,59 @@ class GroupMakeRoomViewModel @Inject constructor( } } + fun loadMoreSearchResults() { + val currentState = _uiState.value + if (currentState.isLoadingMoreSearchResults || + currentState.isSearching || + currentState.isLastSearchPage || + currentState.searchResults.isEmpty() || + currentState.currentSearchQuery.isBlank()) { + return + } + + loadMoreSearchJob?.cancel() + loadMoreSearchJob = viewModelScope.launch { + updateState { it.copy(isLoadingMoreSearchResults = true) } + + try { + val nextPage = currentState.searchPage + 1 + val result = bookRepository.searchBooks( + currentState.currentSearchQuery, + page = nextPage, + isFinalized = false + ) + result.onSuccess { response -> + if (response != null) { + val newResults = response.searchResult.map { it.toBookData() } + updateState { + it.copy( + searchResults = currentState.searchResults + newResults, + searchPage = response.page, + isLastSearchPage = response.last, + isLoadingMoreSearchResults = false + ) + } + } else { + updateState { + it.copy( + isLoadingMoreSearchResults = false, + isLastSearchPage = true + ) + } + } + }.onFailure { + updateState { + it.copy(isLoadingMoreSearchResults = false) + } + } + } catch (e: Exception) { + updateState { + it.copy(isLoadingMoreSearchResults = false) + } + } + } + } + fun selectGenre(index: Int) { updateState { it.copy(selectedGenreIndex = index) } } @@ -278,4 +455,10 @@ class GroupMakeRoomViewModel @Inject constructor( fun clearError() { updateState { it.copy(errorMessage = null) } } + + override fun onCleared() { + super.onCleared() + searchJob?.cancel() + loadMoreSearchJob?.cancel() + } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/component/BookContent.kt b/app/src/main/java/com/texthip/thip/ui/mypage/component/BookContent.kt index 40aca106..f7024906 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/component/BookContent.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/component/BookContent.kt @@ -9,9 +9,13 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -32,7 +36,25 @@ fun BookContent( if (bookList.isEmpty()) { EmptyBookContent() } else { + val listState = rememberLazyListState() + val shouldLoadMore = remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItemsNumber = layoutInfo.totalItemsCount + val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 + + totalItemsNumber > 0 && lastVisibleItemIndex >= totalItemsNumber - 3 + } + } + + LaunchedEffect(shouldLoadMore.value) { + if (shouldLoadMore.value) { + viewModel.loadMoreBooks() + } + } + LazyColumn( + state = listState, modifier = Modifier .fillMaxSize() .padding(horizontal = 20.dp), From 421e3a62a544140980e0c04a2a4e50b8063bd151 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 2 Sep 2025 18:51:44 +0900 Subject: [PATCH 14/27] =?UTF-8?q?[refactor]:=20=EB=AA=A8=EC=9E=84=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=BC=EB=93=9C=20=EC=83=9D=EC=84=B1=EC=8B=9C=20?= =?UTF-8?q?=EC=B1=85=EA=B2=80=EC=83=89=20=EB=AC=B4=ED=95=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EB=A1=9C=EC=A7=81=20=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/GroupBookListWithScrollbar.kt | 86 +++++++++++++------ .../component/GroupBookSearchBottomSheet.kt | 48 +++++++++-- 2 files changed, 100 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookListWithScrollbar.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookListWithScrollbar.kt index 896ce011..bcf37098 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookListWithScrollbar.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookListWithScrollbar.kt @@ -7,8 +7,15 @@ 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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -23,36 +30,61 @@ import com.texthip.thip.ui.theme.ThipTheme.colors @Composable fun GroupBookListWithScrollbar( books: List, - onBookClick: (BookData) -> Unit + onBookClick: (BookData) -> Unit, + isLoadingMore: Boolean = false, + hasMore: Boolean = true, + onLoadMore: () -> Unit = {} ) { - val scrollState = rememberScrollState() + val listState = rememberLazyListState() + + val shouldLoadMore = remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItemsNumber = layoutInfo.totalItemsCount + val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 - Box( - Modifier + totalItemsNumber > 0 && lastVisibleItemIndex >= (totalItemsNumber - 3) + } + } + + LaunchedEffect(shouldLoadMore.value, hasMore, isLoadingMore) { + if (shouldLoadMore.value && hasMore && !isLoadingMore && books.isNotEmpty()) { + onLoadMore() + } + } + + LazyColumn( + state = listState, + modifier = Modifier .fillMaxWidth() + .drawVerticalScrollbar(rememberScrollState()) ) { - Column( - Modifier - .fillMaxWidth() - .verticalScroll(scrollState) - .drawVerticalScrollbar(scrollState) - ) { - books.forEachIndexed { index, book -> - CardBookSearch( - title = book.title, - imageUrl = book.imageUrl, - onClick = { onBookClick(book) } - ) - Spacer(modifier = Modifier.height(12.dp)) - if (index < books.size - 1) { - Spacer( - modifier = Modifier - .fillMaxWidth() - .padding(end = 6.dp) - .height(1.dp) - .background(color = colors.Grey02) - ) - Spacer(modifier = Modifier.height(12.dp)) + items(books) { book -> + CardBookSearch( + title = book.title, + imageUrl = book.imageUrl, + onClick = { onBookClick(book) } + ) + Spacer(modifier = Modifier.height(12.dp)) + Spacer( + modifier = Modifier + .fillMaxWidth() + .padding(end = 6.dp) + .height(1.dp) + .background(color = colors.Grey02) + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + if (isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } } } diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookSearchBottomSheet.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookSearchBottomSheet.kt index dd1bd0fd..730951e9 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookSearchBottomSheet.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookSearchBottomSheet.kt @@ -40,7 +40,16 @@ fun GroupBookSearchBottomSheet( searchResults: List = emptyList(), isLoading: Boolean = false, isSearching: Boolean = false, - onSearch: (String) -> Unit = {} + isLoadingMoreSaved: Boolean = false, + isLoadingMoreGroup: Boolean = false, + isLoadingMoreSearch: Boolean = false, + hasMoreSaved: Boolean = true, + hasMoreGroup: Boolean = true, + hasMoreSearch: Boolean = true, + onSearch: (String) -> Unit = {}, + onLoadMoreSaved: () -> Unit = {}, + onLoadMoreGroup: () -> Unit = {}, + onLoadMoreSearch: () -> Unit = {} ) { var selectedTab by rememberSaveable { mutableIntStateOf(0) } val tabs = listOf( @@ -111,10 +120,35 @@ fun GroupBookSearchBottomSheet( else -> { Column(Modifier.padding(horizontal = 20.dp)) { - GroupBookListWithScrollbar( - books = displayBooks, - onBookClick = onBookSelect - ) + when { + searchText.isNotEmpty() -> { + GroupBookListWithScrollbar( + books = displayBooks, + onBookClick = onBookSelect, + isLoadingMore = isLoadingMoreSearch, + hasMore = hasMoreSearch, + onLoadMore = onLoadMoreSearch + ) + } + selectedTab == 0 -> { + GroupBookListWithScrollbar( + books = displayBooks, + onBookClick = onBookSelect, + isLoadingMore = isLoadingMoreSaved, + hasMore = hasMoreSaved, + onLoadMore = onLoadMoreSaved + ) + } + else -> { + GroupBookListWithScrollbar( + books = displayBooks, + onBookClick = onBookSelect, + isLoadingMore = isLoadingMoreGroup, + hasMore = hasMoreGroup, + onLoadMore = onLoadMoreGroup + ) + } + } } } } @@ -134,7 +168,7 @@ fun PreviewBookSearchBottomSheet_HasBooks() { onDismiss = { showSheet = false }, onBookSelect = {}, onRequestBook = {}, - savedBooks = dummySavedBooks, // 데이터 있음 + savedBooks = dummySavedBooks, groupBooks = dummyGroupBooks, isLoading = false ) @@ -152,7 +186,7 @@ fun PreviewBookSearchBottomSheet_Empty() { onDismiss = { showSheet = false }, onBookSelect = {}, onRequestBook = {}, - savedBooks = emptyList(), // 데이터 없음 + savedBooks = emptyList(), groupBooks = emptyList(), isLoading = false ) From bd017ca24debb925b3ef20b685634b9584e36453 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 2 Sep 2025 18:52:00 +0900 Subject: [PATCH 15/27] =?UTF-8?q?[refactor]:=20=EC=B1=85=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20api=20cursor=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/mypage/viewmodel/SavedBookViewModel.kt | 74 ++++++++++++++++--- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/SavedBookViewModel.kt b/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/SavedBookViewModel.kt index 1439e548..1ad985e3 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/SavedBookViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/SavedBookViewModel.kt @@ -11,23 +11,79 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject +data class SavedBookUiState( + val books: List = emptyList(), + val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, + val isLast: Boolean = false, + val error: String? = null +) { + val canLoadMore: Boolean get() = !isLoading && !isLoadingMore && !isLast +} + @HiltViewModel class SavedBookViewModel @Inject constructor( private val bookRepository: BookRepository ) : ViewModel() { - private val _books = MutableStateFlow>(emptyList()) - val books = _books.asStateFlow() + private val _uiState = MutableStateFlow(SavedBookUiState()) + val uiState = _uiState.asStateFlow() + + private var nextCursor: String? = null + private var isLoadingBooks = false + + private fun updateState(update: (SavedBookUiState) -> SavedBookUiState) { + _uiState.value = update(_uiState.value) + } + + fun loadSavedBooks(isInitial: Boolean = true) { + if (isLoadingBooks && !isInitial) return + if (_uiState.value.isLast && !isInitial) return - fun loadSavedBooks() { viewModelScope.launch { - bookRepository.getSavedBooks() - .onSuccess { response -> - _books.value = response?.bookList?.map { it.toBookItem() } ?: emptyList() - } - .onFailure { - it.printStackTrace() + try { + isLoadingBooks = true + + if (isInitial) { + updateState { it.copy(isLoading = true, books = emptyList(), isLast = false) } + nextCursor = null + } else { + updateState { it.copy(isLoadingMore = true) } } + + val cursor = if (isInitial) null else nextCursor + + bookRepository.getSavedBooks(cursor) + .onSuccess { response -> + if (response != null) { + val currentList = if (isInitial) emptyList() else _uiState.value.books + val newBooks = response.bookList.map { it.toBookItem() } + updateState { + it.copy( + books = currentList + newBooks, + error = null, + isLast = response.isLast + ) + } + nextCursor = response.nextCursor + } else { + updateState { it.copy(isLast = true) } + } + } + .onFailure { exception -> + updateState { it.copy(error = exception.message) } + exception.printStackTrace() + } + } finally { + isLoadingBooks = false + updateState { it.copy(isLoading = false, isLoadingMore = false) } + } + } + } + + fun loadMoreBooks() { + if (_uiState.value.canLoadMore) { + loadSavedBooks(isInitial = false) } } From ef29f5875756477a947dc507464a8811e6fa498c Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 2 Sep 2025 18:52:35 +0900 Subject: [PATCH 16/27] =?UTF-8?q?[refactor]:=20=EC=B0=B8=EC=97=AC=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EC=9E=84=EB=B0=A9=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?api=20cursor=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rooms/response/JoinedRoomListResponse.kt | 3 +- .../thip/data/repository/RoomsRepository.kt | 4 +- .../texthip/thip/data/service/RoomsService.kt | 2 +- .../thip/ui/group/screen/GroupScreen.kt | 2 +- .../thip/ui/group/viewmodel/GroupUiState.kt | 4 +- .../thip/ui/group/viewmodel/GroupViewModel.kt | 94 +++++++++---------- 6 files changed, 51 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/data/model/rooms/response/JoinedRoomListResponse.kt b/app/src/main/java/com/texthip/thip/data/model/rooms/response/JoinedRoomListResponse.kt index bb057aaf..971f2891 100644 --- a/app/src/main/java/com/texthip/thip/data/model/rooms/response/JoinedRoomListResponse.kt +++ b/app/src/main/java/com/texthip/thip/data/model/rooms/response/JoinedRoomListResponse.kt @@ -8,8 +8,7 @@ import kotlinx.serialization.Serializable data class JoinedRoomListResponse( @SerialName("roomList") val roomList: List, @SerialName("nickname") val nickname: String, - @SerialName("page") val page: Int, - @SerialName("size") val size: Int, + @SerialName("nextCursor") val nextCursor: String? = null, @SerialName("last") val last: Boolean, @SerialName("first") val first: Boolean ) diff --git a/app/src/main/java/com/texthip/thip/data/repository/RoomsRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/RoomsRepository.kt index 4618c660..8f384d35 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/RoomsRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/RoomsRepository.kt @@ -42,8 +42,8 @@ class RoomsRepository @Inject constructor( } /** 내가 참여 중인 모임방 목록 조회 */ - suspend fun getMyJoinedRooms(page: Int): Result = runCatching { - val response = roomsService.getJoinedRooms(page) + suspend fun getMyJoinedRooms(cursor: String? = null): Result = runCatching { + val response = roomsService.getJoinedRooms(cursor) .handleBaseResponse() .getOrThrow() diff --git a/app/src/main/java/com/texthip/thip/data/service/RoomsService.kt b/app/src/main/java/com/texthip/thip/data/service/RoomsService.kt index b7365b58..a3684a4f 100644 --- a/app/src/main/java/com/texthip/thip/data/service/RoomsService.kt +++ b/app/src/main/java/com/texthip/thip/data/service/RoomsService.kt @@ -43,7 +43,7 @@ interface RoomsService { /** 참여 중인 모임방 목록 조회 */ @GET("rooms/home/joined") suspend fun getJoinedRooms( - @Query("page") page: Int = 1 + @Query("cursor") cursor: String? = null ): BaseResponse /** 카테고리별 모임방 목록 조회 (마감임박/인기) */ diff --git a/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt index 42de8590..307074b7 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt @@ -72,7 +72,7 @@ fun GroupScreen( onNavigateToGroupRecruit = onNavigateToGroupRecruit, onNavigateToGroupRoom = onNavigateToGroupRoom, onRefreshGroupData = { viewModel.refreshGroupData() }, - onCardVisible = { cardIndex -> viewModel.onCardVisible(cardIndex) }, + onCardVisible = { cardIndex -> viewModel.loadMoreGroups() }, onSelectGenre = { genreIndex -> viewModel.selectGenre(genreIndex) }, onHideToast = { viewModel.hideToast() } ) diff --git a/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupUiState.kt b/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupUiState.kt index 9facd3c7..ba99fd8a 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupUiState.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupUiState.kt @@ -13,7 +13,9 @@ data class GroupUiState( val userName: String = "", val selectedGenreIndex: Int = 0, val showToast: Boolean = false, - val toastMessage: String = "" + val toastMessage: String = "", + val isLast: Boolean = false, + val error: String? = null ) { val hasContent: Boolean get() = myJoinedRooms.isNotEmpty() || (roomMainList != null) val canLoadMore: Boolean get() = hasMoreMyGroups && !isRefreshing && !isLoadingMoreMyGroups diff --git a/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupViewModel.kt b/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupViewModel.kt index 854ebc4b..fb901a6d 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupViewModel.kt @@ -24,11 +24,8 @@ class GroupViewModel @Inject constructor( private val _uiState = MutableStateFlow(GroupUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private var currentMyGroupsPage = 1 - private var loadedPagesCount = 0 - private val pagesPerBatch = 3 - private val preloadThreshold = 2 - private var isBatchLoading = false + private var nextCursor: String? = null + private var isLoadingMyGroups = false private fun updateState(update: (GroupUiState) -> GroupUiState) { _uiState.value = update(_uiState.value) @@ -59,62 +56,57 @@ class GroupViewModel @Inject constructor( updateState { it.copy(isRefreshing = true) } } try { - loadPageBatchSuspend() + loadMyGroupsSuspend(isInitial = reset) } finally { updateState { it.copy(isRefreshing = false) } } } - private suspend fun loadPageBatchSuspend() { - if (!uiState.value.hasMoreMyGroups || isBatchLoading) return + private suspend fun loadMyGroupsSuspend(isInitial: Boolean = false) { + if (isLoadingMyGroups) return + if (!isInitial && _uiState.value.isLast) return try { - isBatchLoading = true - updateState { it.copy(isLoadingMoreMyGroups = true) } - - val currentBatchStart = currentMyGroupsPage - val batchEndPage = currentBatchStart + pagesPerBatch - 1 - - for (page in currentBatchStart..batchEndPage) { - if (!uiState.value.hasMoreMyGroups) break - - repository.getMyJoinedRooms(page) - .onSuccess { joinedRoomsResponse -> - joinedRoomsResponse?.let { response -> - updateState { - it.copy( - myJoinedRooms = it.myJoinedRooms + response.roomList, - hasMoreMyGroups = !response.last - ) - } - loadedPagesCount++ - currentMyGroupsPage = page + 1 - } ?: run { - // null 응답 시 더 이상 로드할 수 없음을 명시 - updateState { it.copy(hasMoreMyGroups = false) } + isLoadingMyGroups = true + + if (isInitial) { + updateState { it.copy(isLoadingMoreMyGroups = false) } + } else { + updateState { it.copy(isLoadingMoreMyGroups = true) } + } + + val cursor = if (isInitial) null else nextCursor + + repository.getMyJoinedRooms(cursor) + .onSuccess { joinedRoomsResponse -> + if (joinedRoomsResponse != null) { + val currentList = if (isInitial) emptyList() else _uiState.value.myJoinedRooms + updateState { + it.copy( + myJoinedRooms = currentList + joinedRoomsResponse.roomList, + hasMoreMyGroups = !joinedRoomsResponse.last, + isLast = joinedRoomsResponse.last + ) } + nextCursor = joinedRoomsResponse.nextCursor + } else { + updateState { it.copy(hasMoreMyGroups = false, isLast = true) } } - .onFailure { - break - } - } + } + .onFailure { exception -> + updateState { it.copy(error = exception.message) } + } } finally { - isBatchLoading = false + isLoadingMyGroups = false updateState { it.copy(isLoadingMoreMyGroups = false) } } } - private fun loadPageBatch() = viewModelScope.launch { - loadPageBatchSuspend() - } - - fun onCardVisible(cardIndex: Int) { - val currentPageEquivalent = (cardIndex / 3) + 1 - - if (currentPageEquivalent >= loadedPagesCount - preloadThreshold && - uiState.value.hasMoreMyGroups && !isBatchLoading - ) { - loadPageBatch() + fun loadMoreGroups() { + if (_uiState.value.hasMoreMyGroups && !isLoadingMyGroups) { + viewModelScope.launch { + loadMyGroupsSuspend(isInitial = false) + } } } @@ -166,7 +158,7 @@ class GroupViewModel @Inject constructor( async { loadUserName() }, async { resetMyGroupsData() - loadPageBatchSuspend() + loadMyGroupsSuspend(isInitial = true) }, async { loadRoomSections() }, ) @@ -179,12 +171,12 @@ class GroupViewModel @Inject constructor( } private fun resetMyGroupsData() { - currentMyGroupsPage = 1 - loadedPagesCount = 0 + nextCursor = null updateState { it.copy( myJoinedRooms = emptyList(), - hasMoreMyGroups = true + hasMoreMyGroups = true, + isLast = false ) } } From a4645b10e60ad7814c0bbd4f72ab8f82d1380b83 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 2 Sep 2025 18:52:40 +0900 Subject: [PATCH 17/27] =?UTF-8?q?[refactor]:=20=EC=B1=85=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20api=20cursor=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt index d53088f8..ff0eab58 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt @@ -53,12 +53,13 @@ fun MypageSaveScreen( val tabs = listOf(stringResource(R.string.feed), stringResource(R.string.book)) var selectedTabIndex by rememberSaveable { mutableStateOf(0) } val feedList by feedViewModel.feeds.collectAsState() - val bookList by bookViewModel.books.collectAsState() + val bookUiState by bookViewModel.uiState.collectAsState() + val bookList = bookUiState.books LaunchedEffect(selectedTabIndex) { when (selectedTabIndex) { 0 -> feedViewModel.loadSavedFeeds() - 1 -> bookViewModel.loadSavedBooks() + 1 -> bookViewModel.loadSavedBooks(isInitial = true) } } From e88aa37c43f3e30f880be4e205b888094a912fca Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 2 Sep 2025 19:41:37 +0900 Subject: [PATCH 18/27] =?UTF-8?q?[refactor]:=20=EC=B0=B8=EC=97=AC=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EC=9E=84=EB=B0=A9=20dto=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/data/model/rooms/response/JoinedRoomListResponse.kt | 3 +-- .../com/texthip/thip/ui/group/viewmodel/GroupViewModel.kt | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/data/model/rooms/response/JoinedRoomListResponse.kt b/app/src/main/java/com/texthip/thip/data/model/rooms/response/JoinedRoomListResponse.kt index 971f2891..84d6615f 100644 --- a/app/src/main/java/com/texthip/thip/data/model/rooms/response/JoinedRoomListResponse.kt +++ b/app/src/main/java/com/texthip/thip/data/model/rooms/response/JoinedRoomListResponse.kt @@ -9,8 +9,7 @@ data class JoinedRoomListResponse( @SerialName("roomList") val roomList: List, @SerialName("nickname") val nickname: String, @SerialName("nextCursor") val nextCursor: String? = null, - @SerialName("last") val last: Boolean, - @SerialName("first") val first: Boolean + @SerialName("isLast") val isLast: Boolean ) @Serializable diff --git a/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupViewModel.kt b/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupViewModel.kt index fb901a6d..381a28a2 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupViewModel.kt @@ -84,8 +84,8 @@ class GroupViewModel @Inject constructor( updateState { it.copy( myJoinedRooms = currentList + joinedRoomsResponse.roomList, - hasMoreMyGroups = !joinedRoomsResponse.last, - isLast = joinedRoomsResponse.last + hasMoreMyGroups = !joinedRoomsResponse.isLast, + isLast = joinedRoomsResponse.isLast ) } nextCursor = joinedRoomsResponse.nextCursor From 83882343977ae4a6266afe0497b10b8638dc95cb Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 2 Sep 2025 23:09:24 +0900 Subject: [PATCH 19/27] =?UTF-8?q?[refactor]:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=A9=EC=8B=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EC=9C=84=ED=95=9C=20dto=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/feed/request/CreateFeedRequest.kt | 4 +++- .../model/feed/request/PresignedUrlRequest.kt | 16 ++++++++++++++++ .../feed/response/PresignedUrlResponse.kt | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/texthip/thip/data/model/feed/request/PresignedUrlRequest.kt create mode 100644 app/src/main/java/com/texthip/thip/data/model/feed/response/PresignedUrlResponse.kt diff --git a/app/src/main/java/com/texthip/thip/data/model/feed/request/CreateFeedRequest.kt b/app/src/main/java/com/texthip/thip/data/model/feed/request/CreateFeedRequest.kt index b0b17e08..6b2a66ca 100644 --- a/app/src/main/java/com/texthip/thip/data/model/feed/request/CreateFeedRequest.kt +++ b/app/src/main/java/com/texthip/thip/data/model/feed/request/CreateFeedRequest.kt @@ -12,5 +12,7 @@ data class CreateFeedRequest( @SerialName("isPublic") val isPublic: Boolean, @SerialName("tagList") - val tagList: List = emptyList() + val tagList: List = emptyList(), + @SerialName("imageUrls") + val imageUrls: List = emptyList() ) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/feed/request/PresignedUrlRequest.kt b/app/src/main/java/com/texthip/thip/data/model/feed/request/PresignedUrlRequest.kt new file mode 100644 index 00000000..d9c1e69d --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feed/request/PresignedUrlRequest.kt @@ -0,0 +1,16 @@ +package com.texthip.thip.data.model.feed.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ImageMetadata( + @SerialName("filename") + val filename: String, + @SerialName("extension") + val extension: String, + @SerialName("size") + val size: Long +) + +typealias PresignedUrlRequest = List \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/feed/response/PresignedUrlResponse.kt b/app/src/main/java/com/texthip/thip/data/model/feed/response/PresignedUrlResponse.kt new file mode 100644 index 00000000..f69b68e4 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feed/response/PresignedUrlResponse.kt @@ -0,0 +1,18 @@ +package com.texthip.thip.data.model.feed.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PresignedUrlInfo( + @SerialName("presignedUrl") + val presignedUrl: String, + @SerialName("fileUrl") + val fileUrl: String +) + +@Serializable +data class PresignedUrlResponse( + @SerialName("presignedUrls") + val presignedUrls: List +) \ No newline at end of file From 7c67d8fcb6dc30cf40d818c2374acb1612392b97 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 2 Sep 2025 23:10:00 +0900 Subject: [PATCH 20/27] =?UTF-8?q?[refactor]:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=97=AC=ED=8D=BC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/texthip/thip/data/di/NetworkModule.kt | 10 +- .../thip/utils/image/ImageUploadHelper.kt | 104 ++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/texthip/thip/utils/image/ImageUploadHelper.kt diff --git a/app/src/main/java/com/texthip/thip/data/di/NetworkModule.kt b/app/src/main/java/com/texthip/thip/data/di/NetworkModule.kt index bb5aef2e..f2bacd8d 100644 --- a/app/src/main/java/com/texthip/thip/data/di/NetworkModule.kt +++ b/app/src/main/java/com/texthip/thip/data/di/NetworkModule.kt @@ -1,12 +1,14 @@ package com.texthip.thip.data.di +import android.content.Context import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.texthip.thip.BuildConfig -import com.texthip.thip.data.service.AuthService import com.texthip.thip.utils.auth.AuthInterceptor +import com.texthip.thip.utils.image.ImageUploadHelper import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -68,4 +70,10 @@ object NetworkModule { ) .client(okHttpClient) .build() + + @Provides + @Singleton + fun provideImageUploadHelper( + @ApplicationContext context: Context + ): ImageUploadHelper = ImageUploadHelper(context) } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/utils/image/ImageUploadHelper.kt b/app/src/main/java/com/texthip/thip/utils/image/ImageUploadHelper.kt new file mode 100644 index 00000000..2118d5e4 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/utils/image/ImageUploadHelper.kt @@ -0,0 +1,104 @@ +package com.texthip.thip.utils.image + +import android.content.Context +import android.net.Uri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.logging.HttpLoggingInterceptor +import java.io.File +import java.io.FileOutputStream +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ImageUploadHelper @Inject constructor( + private val context: Context +) { + + private val s3Client = OkHttpClient.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .writeTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .addInterceptor( + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.HEADERS + } + ) + .build() + + suspend fun uploadImageToS3( + uri: Uri, + presignedUrl: String, + filename: String + ): Result = withContext(Dispatchers.IO) { + runCatching { + val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" + val tempFile = File(context.cacheDir, filename) + + try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + FileOutputStream(tempFile).use { outputStream -> + inputStream.copyTo(outputStream) + } + } ?: throw IllegalStateException("Failed to open input stream for URI: $uri") + + val requestBody = tempFile.readBytes().toRequestBody(mimeType.toMediaType()) + val request = Request.Builder() + .url(presignedUrl) + .put(requestBody) + .build() + + val response = s3Client.newCall(request).execute() + + if (!response.isSuccessful) { + throw Exception("S3 upload failed: ${response.code} ${response.message}") + } + } finally { + if (tempFile.exists()) { + tempFile.delete() + } + } + } + } + + fun getImageMetadata(uri: Uri): ImageMetadata? { + return runCatching { + val mimeType = context.contentResolver.getType(uri) ?: return null + val extension = when (mimeType) { + "image/png" -> "png" + "image/jpeg", "image/jpg" -> "jpg" + "image/gif" -> "gif" + else -> return null + } + + // 파일 크기 더 정확하게 계산 + val size = context.contentResolver.openInputStream(uri)?.use { inputStream -> + var totalBytes = 0L + val buffer = ByteArray(8192) + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + totalBytes += bytesRead + } + totalBytes + } ?: return null + + val filename = "feed_image_${System.currentTimeMillis()}.$extension" + + ImageMetadata( + filename = filename, + extension = extension, + size = size + ) + }.getOrNull() + } +} + +data class ImageMetadata( + val filename: String, + val extension: String, + val size: Long +) \ No newline at end of file From 254207386c5e7612046daaca310f9604405dfdf3 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 2 Sep 2025 23:10:37 +0900 Subject: [PATCH 21/27] =?UTF-8?q?[refactor]:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=88=98=EC=A0=95=EB=90=9C=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=EC=9C=BC=EB=A1=9C=20=EA=B5=AC=ED=98=84=20(#1?= =?UTF-8?q?02)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/data/repository/FeedRepository.kt | 128 +++++++----------- .../texthip/thip/data/service/FeedService.kt | 17 ++- .../thip/ui/feed/screen/FeedMyScreen.kt | 1 - 3 files changed, 56 insertions(+), 90 deletions(-) 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 cf7d7463..c340e3dd 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 @@ -1,11 +1,11 @@ package com.texthip.thip.data.repository -import android.content.Context import android.net.Uri import com.texthip.thip.data.model.base.handleBaseResponse import com.texthip.thip.data.model.feed.request.CreateFeedRequest import com.texthip.thip.data.model.feed.request.FeedLikeRequest import com.texthip.thip.data.model.feed.request.FeedSaveRequest +import com.texthip.thip.data.model.feed.request.ImageMetadata import com.texthip.thip.data.model.feed.request.UpdateFeedRequest import com.texthip.thip.data.model.feed.response.AllFeedResponse import com.texthip.thip.data.model.feed.response.CreateFeedResponse @@ -18,27 +18,17 @@ 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 com.texthip.thip.utils.image.ImageUploadHelper 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 -import okhttp3.MultipartBody -import okhttp3.RequestBody.Companion.asRequestBody -import okhttp3.RequestBody.Companion.toRequestBody -import java.io.File -import java.io.FileOutputStream import javax.inject.Inject import javax.inject.Singleton @Singleton class FeedRepository @Inject constructor( private val feedService: FeedService, - @param:ApplicationContext private val context: Context, - private val json: Json + private val imageUploadHelper: ImageUploadHelper ) { private val _feedStateUpdateResult = MutableSharedFlow() val feedStateUpdateResult: Flow = _feedStateUpdateResult.asSharedFlow() @@ -72,80 +62,66 @@ class FeedRepository @Inject constructor( tagList: List, imageUris: List ): Result = runCatching { + val imageUrls = if (imageUris.isNotEmpty()) { + uploadImagesToS3(imageUris) + } else { + emptyList() + } + val request = CreateFeedRequest( isbn = isbn, contentBody = contentBody, isPublic = isPublic, - tagList = tagList + tagList = tagList, + imageUrls = imageUrls ) - // JSON 요청 부분을 RequestBody로 변환 - val requestJson = json.encodeToString(CreateFeedRequest.serializer(), request) - val requestBody = requestJson.toRequestBody("application/json".toMediaType()) - - // 임시 파일 목록 추적 - val tempFiles = mutableListOf() + feedService.createFeed(request) + .handleBaseResponse() + .getOrThrow() + } - // 이미지 파일들을 MultipartBody.Part로 변환 - val imageParts = if (imageUris.isNotEmpty()) { - withContext(Dispatchers.IO) { - imageUris.mapNotNull { uri -> - runCatching { - uriToMultipartBodyPart(uri, "images", tempFiles) - }.getOrNull() - } + /** 이미지들을 S3에 업로드하고 CloudFront URL 목록 반환 */ + private suspend fun uploadImagesToS3(imageUris: List): List { + val imageMetadataList = imageUris.mapNotNull { uri -> + imageUploadHelper.getImageMetadata(uri)?.let { metadata -> + ImageMetadata( + filename = metadata.filename, + extension = metadata.extension, + size = metadata.size + ) } - } else { - null } - try { - feedService.createFeed(requestBody, imageParts) - .handleBaseResponse() - .getOrThrow() - } finally { - // 임시 파일들 정리 - cleanupTempFiles(tempFiles) - } - } + if (imageMetadataList.isEmpty()) return emptyList() - private fun uriToMultipartBodyPart( - uri: Uri, - paramName: String, - tempFiles: MutableList - ): MultipartBody.Part? { - return runCatching { - // MIME 타입 확인 - val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" - val extension = when (mimeType) { - "image/png" -> "png" - "image/gif" -> "gif" - "image/jpeg", "image/jpg" -> "jpg" - else -> "jpg" // 기본값 - } + val presignedUrlRequest = imageMetadataList + + val presignedResponse = feedService.getPresignedUrls(presignedUrlRequest) + .handleBaseResponse() + .getOrThrow() ?: throw Exception("Failed to get presigned URLs") - // 파일명 생성 - val fileName = "feed_image_${System.currentTimeMillis()}.$extension" - val tempFile = File(context.cacheDir, fileName) + val uploadedImageUrls = mutableListOf() - // 임시 파일 목록에 추가 - tempFiles.add(tempFile) + presignedResponse.presignedUrls.forEachIndexed { index, presignedInfo -> + val uri = imageUris[index] + val originalFilename = imageMetadataList[index].filename - // InputStream을 use 블록으로 안전하게 관리 - context.contentResolver.openInputStream(uri)?.use { inputStream -> - FileOutputStream(tempFile).use { outputStream -> - inputStream.copyTo(outputStream) - } - } ?: throw IllegalStateException("Failed to open input stream for URI: $uri") + imageUploadHelper.uploadImageToS3( + uri = uri, + presignedUrl = presignedInfo.presignedUrl, + filename = originalFilename + ).onSuccess { + uploadedImageUrls.add(presignedInfo.fileUrl) + }.onFailure { exception -> + throw Exception("Failed to upload image ${index + 1}: ${exception.message}") + } + } - // MultipartBody.Part 생성 - val requestFile = tempFile.asRequestBody(mimeType.toMediaType()) - MultipartBody.Part.createFormData(paramName, fileName, requestFile) - }.onFailure { e -> - e.printStackTrace() - }.getOrNull() + return uploadedImageUrls } + /** 전체 피드 목록 조회 */ suspend fun getAllFeeds(cursor: String? = null): Result = runCatching { feedService.getAllFeeds(cursor) @@ -205,18 +181,6 @@ class FeedRepository @Inject constructor( .getOrThrow() } - /** 임시 파일들을 정리하는 함수 */ - private fun cleanupTempFiles(tempFiles: List) { - tempFiles.forEach { file -> - runCatching { - if (file.exists()) { - file.delete() - } - }.onFailure { e -> - e.printStackTrace() - } - } - } suspend fun getFeedUsersInfo(userId: Long) = runCatching { feedService.getFeedUsersInfo(userId) diff --git a/app/src/main/java/com/texthip/thip/data/service/FeedService.kt b/app/src/main/java/com/texthip/thip/data/service/FeedService.kt index f5457c5c..557820de 100644 --- a/app/src/main/java/com/texthip/thip/data/service/FeedService.kt +++ b/app/src/main/java/com/texthip/thip/data/service/FeedService.kt @@ -1,8 +1,10 @@ package com.texthip.thip.data.service import com.texthip.thip.data.model.base.BaseResponse +import com.texthip.thip.data.model.feed.request.CreateFeedRequest import com.texthip.thip.data.model.feed.request.FeedLikeRequest import com.texthip.thip.data.model.feed.request.FeedSaveRequest +import com.texthip.thip.data.model.feed.request.PresignedUrlRequest import com.texthip.thip.data.model.feed.request.UpdateFeedRequest import com.texthip.thip.data.model.feed.response.AllFeedResponse import com.texthip.thip.data.model.feed.response.CreateFeedResponse @@ -14,16 +16,13 @@ import com.texthip.thip.data.model.feed.response.FeedUsersInfoResponse import com.texthip.thip.data.model.feed.response.FeedUsersResponse 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.PresignedUrlResponse import com.texthip.thip.data.model.feed.response.RelatedBooksResponse -import okhttp3.MultipartBody -import okhttp3.RequestBody import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET -import retrofit2.http.Multipart import retrofit2.http.PATCH import retrofit2.http.POST -import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query @@ -34,12 +33,16 @@ interface FeedService { @GET("feeds/write-info") suspend fun getFeedWriteInfo(): BaseResponse + /** 피드 이미지 업로드용 presigned URL 발급 */ + @POST("feeds/images/presigned-url") + suspend fun getPresignedUrls( + @Body request: PresignedUrlRequest + ): BaseResponse + /** 피드 생성 */ - @Multipart @POST("feeds") suspend fun createFeed( - @Part("request") request: RequestBody, - @Part images: List? + @Body request: CreateFeedRequest ): BaseResponse /** 전체 피드 목록 조회 */ diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedMyScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedMyScreen.kt index e044684c..eda45f2f 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedMyScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedMyScreen.kt @@ -48,7 +48,6 @@ fun FeedMyScreen( LaunchedEffect(Unit) { viewModel.onTabSelected(1) - viewModel.fetchMyFeedInfo() } FeedMyContent( From 926e777e5187a9419dea7b557e12206995f0832a Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 3 Sep 2025 14:43:26 +0900 Subject: [PATCH 22/27] =?UTF-8?q?[refactor]:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20request=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/feed/request/PresignedUrlRequest.kt | 2 -- .../thip/data/repository/FeedRepository.kt | 13 ++----------- .../thip/utils/image/ImageUploadHelper.kt | 17 ++++------------- 3 files changed, 6 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/data/model/feed/request/PresignedUrlRequest.kt b/app/src/main/java/com/texthip/thip/data/model/feed/request/PresignedUrlRequest.kt index d9c1e69d..f10143ba 100644 --- a/app/src/main/java/com/texthip/thip/data/model/feed/request/PresignedUrlRequest.kt +++ b/app/src/main/java/com/texthip/thip/data/model/feed/request/PresignedUrlRequest.kt @@ -5,8 +5,6 @@ import kotlinx.serialization.Serializable @Serializable data class ImageMetadata( - @SerialName("filename") - val filename: String, @SerialName("extension") val extension: String, @SerialName("size") 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 c340e3dd..c091731a 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 @@ -5,7 +5,6 @@ import com.texthip.thip.data.model.base.handleBaseResponse import com.texthip.thip.data.model.feed.request.CreateFeedRequest import com.texthip.thip.data.model.feed.request.FeedLikeRequest import com.texthip.thip.data.model.feed.request.FeedSaveRequest -import com.texthip.thip.data.model.feed.request.ImageMetadata import com.texthip.thip.data.model.feed.request.UpdateFeedRequest import com.texthip.thip.data.model.feed.response.AllFeedResponse import com.texthip.thip.data.model.feed.response.CreateFeedResponse @@ -84,13 +83,7 @@ class FeedRepository @Inject constructor( /** 이미지들을 S3에 업로드하고 CloudFront URL 목록 반환 */ private suspend fun uploadImagesToS3(imageUris: List): List { val imageMetadataList = imageUris.mapNotNull { uri -> - imageUploadHelper.getImageMetadata(uri)?.let { metadata -> - ImageMetadata( - filename = metadata.filename, - extension = metadata.extension, - size = metadata.size - ) - } + imageUploadHelper.getImageMetadata(uri) } if (imageMetadataList.isEmpty()) return emptyList() @@ -105,12 +98,10 @@ class FeedRepository @Inject constructor( presignedResponse.presignedUrls.forEachIndexed { index, presignedInfo -> val uri = imageUris[index] - val originalFilename = imageMetadataList[index].filename imageUploadHelper.uploadImageToS3( uri = uri, - presignedUrl = presignedInfo.presignedUrl, - filename = originalFilename + presignedUrl = presignedInfo.presignedUrl ).onSuccess { uploadedImageUrls.add(presignedInfo.fileUrl) }.onFailure { exception -> diff --git a/app/src/main/java/com/texthip/thip/utils/image/ImageUploadHelper.kt b/app/src/main/java/com/texthip/thip/utils/image/ImageUploadHelper.kt index 2118d5e4..4e6a7478 100644 --- a/app/src/main/java/com/texthip/thip/utils/image/ImageUploadHelper.kt +++ b/app/src/main/java/com/texthip/thip/utils/image/ImageUploadHelper.kt @@ -2,6 +2,7 @@ package com.texthip.thip.utils.image import android.content.Context import android.net.Uri +import com.texthip.thip.data.model.feed.request.ImageMetadata import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -32,12 +33,11 @@ class ImageUploadHelper @Inject constructor( suspend fun uploadImageToS3( uri: Uri, - presignedUrl: String, - filename: String + presignedUrl: String ): Result = withContext(Dispatchers.IO) { runCatching { val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" - val tempFile = File(context.cacheDir, filename) + val tempFile = File(context.cacheDir, "temp_image_${System.currentTimeMillis()}") try { context.contentResolver.openInputStream(uri)?.use { inputStream -> @@ -86,19 +86,10 @@ class ImageUploadHelper @Inject constructor( totalBytes } ?: return null - val filename = "feed_image_${System.currentTimeMillis()}.$extension" - ImageMetadata( - filename = filename, extension = extension, size = size ) }.getOrNull() } -} - -data class ImageMetadata( - val filename: String, - val extension: String, - val size: Long -) \ No newline at end of file +} \ No newline at end of file From 89472288971ceab56aaaf87f001c79da1448267e Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 3 Sep 2025 15:01:12 +0900 Subject: [PATCH 23/27] =?UTF-8?q?[refactor]:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20request=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/data/repository/FeedRepository.kt | 31 +++++++--- .../thip/utils/image/ImageUploadHelper.kt | 58 +++++++++++++------ 2 files changed, 62 insertions(+), 27 deletions(-) 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 c091731a..93e2d12d 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 @@ -6,6 +6,10 @@ import com.texthip.thip.data.model.feed.request.CreateFeedRequest import com.texthip.thip.data.model.feed.request.FeedLikeRequest import com.texthip.thip.data.model.feed.request.FeedSaveRequest import com.texthip.thip.data.model.feed.request.UpdateFeedRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext import com.texthip.thip.data.model.feed.response.AllFeedResponse import com.texthip.thip.data.model.feed.response.CreateFeedResponse import com.texthip.thip.data.model.feed.response.FeedDetailResponse @@ -81,23 +85,32 @@ class FeedRepository @Inject constructor( } /** 이미지들을 S3에 업로드하고 CloudFront URL 목록 반환 */ - private suspend fun uploadImagesToS3(imageUris: List): List { - val imageMetadataList = imageUris.mapNotNull { uri -> - imageUploadHelper.getImageMetadata(uri) - } + private suspend fun uploadImagesToS3(imageUris: List): List = withContext(Dispatchers.IO) { + val validImagePairs = imageUris.map { uri -> + async { + imageUploadHelper.getImageMetadata(uri)?.let { metadata -> + uri to metadata + } + } + }.awaitAll().filterNotNull() - if (imageMetadataList.isEmpty()) return emptyList() + if (validImagePairs.isEmpty()) return@withContext emptyList() - val presignedUrlRequest = imageMetadataList + val presignedUrlRequest = validImagePairs.map { it.second } val presignedResponse = feedService.getPresignedUrls(presignedUrlRequest) .handleBaseResponse() .getOrThrow() ?: throw Exception("Failed to get presigned URLs") + // 개수 검증 + if (validImagePairs.size != presignedResponse.presignedUrls.size) { + throw Exception("Presigned URL count mismatch: expected ${validImagePairs.size}, got ${presignedResponse.presignedUrls.size}") + } + val uploadedImageUrls = mutableListOf() - presignedResponse.presignedUrls.forEachIndexed { index, presignedInfo -> - val uri = imageUris[index] + validImagePairs.forEachIndexed { index, (uri, _) -> + val presignedInfo = presignedResponse.presignedUrls[index] imageUploadHelper.uploadImageToS3( uri = uri, @@ -109,7 +122,7 @@ class FeedRepository @Inject constructor( } } - return uploadedImageUrls + uploadedImageUrls } diff --git a/app/src/main/java/com/texthip/thip/utils/image/ImageUploadHelper.kt b/app/src/main/java/com/texthip/thip/utils/image/ImageUploadHelper.kt index 4e6a7478..f763cb06 100644 --- a/app/src/main/java/com/texthip/thip/utils/image/ImageUploadHelper.kt +++ b/app/src/main/java/com/texthip/thip/utils/image/ImageUploadHelper.kt @@ -1,7 +1,10 @@ package com.texthip.thip.utils.image import android.content.Context +import android.database.Cursor import android.net.Uri +import android.provider.OpenableColumns +import com.texthip.thip.BuildConfig import com.texthip.thip.data.model.feed.request.ImageMetadata import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -24,11 +27,15 @@ class ImageUploadHelper @Inject constructor( .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) .writeTimeout(10, java.util.concurrent.TimeUnit.SECONDS) .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) - .addInterceptor( - HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.HEADERS + .apply { + if (BuildConfig.DEBUG) { + addInterceptor( + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + } + ) } - ) + } .build() suspend fun uploadImageToS3( @@ -65,26 +72,18 @@ class ImageUploadHelper @Inject constructor( } } - fun getImageMetadata(uri: Uri): ImageMetadata? { - return runCatching { - val mimeType = context.contentResolver.getType(uri) ?: return null + suspend fun getImageMetadata(uri: Uri): ImageMetadata? = withContext(Dispatchers.IO) { + runCatching { + val mimeType = context.contentResolver.getType(uri) ?: return@withContext null val extension = when (mimeType) { "image/png" -> "png" "image/jpeg", "image/jpg" -> "jpg" "image/gif" -> "gif" - else -> return null + else -> return@withContext null } - // 파일 크기 더 정확하게 계산 - val size = context.contentResolver.openInputStream(uri)?.use { inputStream -> - var totalBytes = 0L - val buffer = ByteArray(8192) - var bytesRead: Int - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - totalBytes += bytesRead - } - totalBytes - } ?: return null + // 성능 최적화된 파일 크기 계산 + val size = getFileSize(uri) ?: return@withContext null ImageMetadata( extension = extension, @@ -92,4 +91,27 @@ class ImageUploadHelper @Inject constructor( ) }.getOrNull() } + + private fun getFileSize(uri: Uri): Long? { + return try { + context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex >= 0) { + val size = cursor.getLong(sizeIndex) + if (size > 0) return size + } + } + } + + context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> + val size = pfd.statSize + if (size > 0) return size + } + + null + } catch (e: Exception) { + null + } + } } \ No newline at end of file From c171e22719f8ec84483665fc7e929550d6fbfb9b Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 3 Sep 2025 15:24:49 +0900 Subject: [PATCH 24/27] =?UTF-8?q?[refactor]:=20QA=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=EB=9D=84=EC=96=B4=EC=93=B0=EA=B8=B0=20=EB=B0=8F=20dp=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/texthip/thip/ui/common/forms/SearchBookTextField.kt | 2 +- .../com/texthip/thip/ui/common/topappbar/LeftNameTopAppBar.kt | 2 +- app/src/main/res/values/strings.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/common/forms/SearchBookTextField.kt b/app/src/main/java/com/texthip/thip/ui/common/forms/SearchBookTextField.kt index 11233ceb..066ada9d 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/forms/SearchBookTextField.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/forms/SearchBookTextField.kt @@ -53,7 +53,7 @@ fun SearchBookTextField( .fillMaxWidth() .height(40.dp) .clip(shape) - .background(colors.DarkGrey02), + .background(colors.DarkGrey), contentAlignment = Alignment.CenterStart ) { Row( diff --git a/app/src/main/java/com/texthip/thip/ui/common/topappbar/LeftNameTopAppBar.kt b/app/src/main/java/com/texthip/thip/ui/common/topappbar/LeftNameTopAppBar.kt index 3ef8ccaf..c46aebb6 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/topappbar/LeftNameTopAppBar.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/topappbar/LeftNameTopAppBar.kt @@ -37,7 +37,7 @@ fun LeftNameTopAppBar( modifier = Modifier .fillMaxWidth() .background(color = colors.Black) - .padding(horizontal = 20.dp, vertical = 20.dp), + .padding(horizontal = 20.dp, vertical = 16.dp), contentAlignment = Alignment.CenterStart ) { Text( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 24c043da..7bad8f78 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,7 +49,7 @@ " 모집 마감" 남음 - 종료 + " 종료" %1$s명 참여 %1$s From eb16b286bc800184fc1102bae2d190e546661c30 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 3 Sep 2025 21:40:23 +0900 Subject: [PATCH 25/27] =?UTF-8?q?[refactor]:=20QA=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=EC=9E=A5=20text=20field=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/common/forms/BookPageTextField.kt | 42 +++++++++++++------ .../group/note/component/PageInputSection.kt | 3 +- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/common/forms/BookPageTextField.kt b/app/src/main/java/com/texthip/thip/ui/common/forms/BookPageTextField.kt index 972b9d86..b0c7c26b 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/forms/BookPageTextField.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/forms/BookPageTextField.kt @@ -17,10 +17,12 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -47,11 +49,14 @@ fun BookPageTextField( text: String, isError: Boolean, onValueChange: (String) -> Unit, + showClearButton: Boolean = true, ) { + var hasFocusCleared by remember(text) { mutableStateOf(false) } + Column { OutlinedTextField( value = text, - onValueChange = { newText: String -> + onValueChange = { newText -> if (newText.isEmpty() || newText.all { it.isDigit() }) { onValueChange(newText) } @@ -65,6 +70,15 @@ fun BookPageTextField( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = modifier .size(width = 320.dp, height = 48.dp) + .onFocusChanged { focusState -> + if (focusState.isFocused && !hasFocusCleared && text.isNotEmpty()) { + hasFocusCleared = true + onValueChange("") + } + if (!focusState.isFocused) { + hasFocusCleared = false + } + } .then( if (isError) Modifier.border( @@ -89,18 +103,20 @@ fun BookPageTextField( disabledContainerColor = colors.DarkGrey02, cursorColor = colors.NeonGreen, ), - trailingIcon = { - Icon( - painter = painterResource(id = R.drawable.ic_x_circle_grey), - contentDescription = "Clear text", - modifier = Modifier.clickable { - if (text.isNotEmpty()) { - onValueChange("") - } - }, - tint = Color.Unspecified - ) - } + trailingIcon = if (showClearButton && enabled) { + { + Icon( + painter = painterResource(id = R.drawable.ic_x_circle_grey), + contentDescription = "Clear text", + modifier = Modifier.clickable { + if (text.isNotEmpty()) { + onValueChange("") + } + }, + tint = Color.Unspecified + ) + } + } else null ) Box(modifier = Modifier.height(24.dp)) { diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/component/PageInputSection.kt b/app/src/main/java/com/texthip/thip/ui/group/note/component/PageInputSection.kt index 8322e281..aa6014f2 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/note/component/PageInputSection.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/note/component/PageInputSection.kt @@ -72,7 +72,8 @@ fun PageInputSection( if (!isGeneralReview) onPageTextChange(it) }, enabled = !isGeneralReview, - isError = isError + isError = isError, + showClearButton = !isGeneralReview ) Row( From eab3e5c15b0b3acd604dec335bdea4b85ef320ec Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 3 Sep 2025 22:06:36 +0900 Subject: [PATCH 26/27] =?UTF-8?q?[refactor]:=20QA=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=EB=AA=A8=EC=A7=91=EC=A4=91=EC=9D=B8=20=EB=AA=A8=EC=9E=84?= =?UTF-8?q?=EB=B0=A9=20=EB=B2=84=ED=8A=BC=20=EB=B9=84=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EB=A9=94?= =?UTF-8?q?=EC=84=B8=EC=A7=80=20=ED=91=9C=EC=8B=9C=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/group/room/screen/GroupRoomRecruitScreen.kt | 4 +++- .../group/room/viewmodel/GroupRoomRecruitUiState.kt | 2 ++ .../room/viewmodel/GroupRoomRecruitViewModel.kt | 13 ++++++++++--- app/src/main/res/values/strings.xml | 3 ++- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomRecruitScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomRecruitScreen.kt index f8e95135..2517bafa 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomRecruitScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomRecruitScreen.kt @@ -453,8 +453,10 @@ fun GroupRoomRecruitContent( } } }, + enabled = buttonType != GroupBottomButtonType.JOIN || uiState.isJoinButtonEnabled, colors = ButtonDefaults.buttonColors( - containerColor = colors.Purple + containerColor = if (uiState.isJoinButtonEnabled || buttonType != GroupBottomButtonType.JOIN) colors.Purple else colors.Grey02, + disabledContainerColor = colors.Grey02 ), modifier = Modifier .align(Alignment.BottomCenter) diff --git a/app/src/main/java/com/texthip/thip/ui/group/room/viewmodel/GroupRoomRecruitUiState.kt b/app/src/main/java/com/texthip/thip/ui/group/room/viewmodel/GroupRoomRecruitUiState.kt index d1fc7bb5..b94bda6a 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/room/viewmodel/GroupRoomRecruitUiState.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/room/viewmodel/GroupRoomRecruitUiState.kt @@ -17,4 +17,6 @@ data class GroupRoomRecruitUiState( val roomId: Int? = null ) { val hasRoomDetail: Boolean get() = roomDetail != null + val isRoomFull: Boolean get() = roomDetail?.let { it.memberCount >= it.recruitCount } ?: false + val isJoinButtonEnabled: Boolean get() = currentButtonType == GroupBottomButtonType.JOIN && !isRoomFull } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/group/room/viewmodel/GroupRoomRecruitViewModel.kt b/app/src/main/java/com/texthip/thip/ui/group/room/viewmodel/GroupRoomRecruitViewModel.kt index 1875c59e..fcef2674 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/room/viewmodel/GroupRoomRecruitViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/room/viewmodel/GroupRoomRecruitViewModel.kt @@ -63,10 +63,17 @@ class GroupRoomRecruitViewModel @Inject constructor( } fun onParticipationClick() { + val currentState = uiState.value + val roomDetail = currentState.roomDetail ?: return + + // 인원이 가득 찬 경우 메시지 표시 + if (roomDetail.memberCount >= roomDetail.recruitCount) { + showToastMessage(stringResourceProvider.getString(R.string.error_max_participate)) + return + } + viewModelScope.launch { - val roomId = uiState.value.roomDetail?.roomId ?: return@launch - - repository.joinOrCancelRoom(roomId, RoomAction.JOIN.value) + repository.joinOrCancelRoom(roomDetail.roomId, RoomAction.JOIN.value) .onSuccess { updateState { it.copy(currentButtonType = GroupBottomButtonType.CANCEL) } showToastMessage(stringResourceProvider.getString(R.string.success_participation_complete)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7bad8f78..284c340a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -425,7 +425,8 @@ 이미 모집기간이 만료된 방입니다. 모집 마감 중 오류가 발생했습니다. 방 정보를 찾을 수 없습니다. - + 모임방 인원이 다 찼어요. + 모집중인 방 정보를 찾을 수 없습니다. 모집중인 방을 불러오는데 실패했습니다. From ce65cad9c948855935247eee1b331a09958b27c3 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 3 Sep 2025 22:46:50 +0900 Subject: [PATCH 27/27] =?UTF-8?q?[refactor]:=20QA=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=85=8C=EB=91=90=EB=A6=AC=20=EC=A0=81=EC=9A=A9=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/texthip/thip/ui/common/header/AuthorHeader.kt | 6 ++++++ .../java/com/texthip/thip/ui/common/header/ProfileBar.kt | 6 ++++++ .../com/texthip/thip/ui/common/header/ProfileBarFeed.kt | 6 ++++++ .../com/texthip/thip/ui/common/header/ProfileBarWithDate.kt | 6 ++++++ .../texthip/thip/ui/feed/component/FeedSubscribelistBar.kt | 6 ++++++ .../texthip/thip/ui/feed/component/MySubscribelistBar.kt | 6 ++++++ 6 files changed, 36 insertions(+) diff --git a/app/src/main/java/com/texthip/thip/ui/common/header/AuthorHeader.kt b/app/src/main/java/com/texthip/thip/ui/common/header/AuthorHeader.kt index ded4c777..483add11 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/header/AuthorHeader.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/header/AuthorHeader.kt @@ -1,6 +1,7 @@ package com.texthip.thip.ui.common.header import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -61,6 +62,11 @@ fun AuthorHeader( modifier = Modifier .size(profileImageSize) .clip(CircleShape) + .border( + width = 0.5.dp, + color = colors.Grey02, + shape = CircleShape + ) ) } else { Box( diff --git a/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBar.kt b/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBar.kt index 0b856f83..2c592a64 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBar.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBar.kt @@ -1,5 +1,6 @@ package com.texthip.thip.ui.common.header +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -50,6 +51,11 @@ fun ProfileBar( modifier = Modifier .size(36.dp) .clip(CircleShape) + .border( + width = 0.5.dp, + color = colors.Grey02, + shape = CircleShape + ) ) Spacer(modifier = Modifier.width(8.dp)) Column( diff --git a/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBarFeed.kt b/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBarFeed.kt index 9d4ff041..e6fd2ec6 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBarFeed.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBarFeed.kt @@ -1,6 +1,7 @@ package com.texthip.thip.ui.common.header 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 @@ -49,6 +50,11 @@ fun ProfileBarFeed( modifier = Modifier .size(24.dp) .clip(CircleShape) + .border( + width = 0.5.dp, + color = colors.Grey02, + shape = CircleShape + ) ) } else { Box( diff --git a/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBarWithDate.kt b/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBarWithDate.kt index d901bde5..11cf3e9b 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBarWithDate.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBarWithDate.kt @@ -1,5 +1,6 @@ package com.texthip.thip.ui.common.header +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -46,6 +47,11 @@ fun ProfileBarWithDate( modifier = Modifier .size(24.dp) .clip(CircleShape) + .border( + width = 0.5.dp, + color = colors.Grey02, + shape = CircleShape + ) ) Spacer(modifier = Modifier.width(4.dp)) Column { diff --git a/app/src/main/java/com/texthip/thip/ui/feed/component/FeedSubscribelistBar.kt b/app/src/main/java/com/texthip/thip/ui/feed/component/FeedSubscribelistBar.kt index ebed653d..eeb3a7f5 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/component/FeedSubscribelistBar.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/component/FeedSubscribelistBar.kt @@ -1,6 +1,7 @@ package com.texthip.thip.ui.feed.component import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -83,6 +84,11 @@ fun FeedSubscribeBarlist( modifier = Modifier .size(24.dp) .clip(CircleShape) + .border( + width = 0.5.dp, + color = colors.Grey02, + shape = CircleShape + ) .background(Color.LightGray) ) diff --git a/app/src/main/java/com/texthip/thip/ui/feed/component/MySubscribelistBar.kt b/app/src/main/java/com/texthip/thip/ui/feed/component/MySubscribelistBar.kt index 5bf67212..0315a10c 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/component/MySubscribelistBar.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/component/MySubscribelistBar.kt @@ -1,6 +1,7 @@ package com.texthip.thip.ui.feed.component import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -98,6 +99,11 @@ fun MySubscribeBarlist( modifier = Modifier .size(36.dp) .clip(CircleShape) + .border( + width = 0.5.dp, + color = colors.Grey02, + shape = CircleShape + ) .background(Color.LightGray) ) Text(