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/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/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/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..f10143ba --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feed/request/PresignedUrlRequest.kt @@ -0,0 +1,14 @@ +package com.texthip.thip.data.model.feed.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ImageMetadata( + @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 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..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 @@ -8,10 +8,8 @@ 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("last") val last: Boolean, - @SerialName("first") val first: Boolean + @SerialName("nextCursor") val nextCursor: String? = null, + @SerialName("isLast") val isLast: Boolean ) @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/repository/FeedRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt index cf7d7463..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 @@ -1,12 +1,15 @@ 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.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 @@ -18,27 +21,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 +65,67 @@ 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 = withContext(Dispatchers.IO) { + val validImagePairs = imageUris.map { uri -> + async { + imageUploadHelper.getImageMetadata(uri)?.let { metadata -> + uri to metadata } } - } else { - null - } + }.awaitAll().filterNotNull() - try { - feedService.createFeed(requestBody, imageParts) - .handleBaseResponse() - .getOrThrow() - } finally { - // 임시 파일들 정리 - cleanupTempFiles(tempFiles) - } - } + if (validImagePairs.isEmpty()) return@withContext 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 = validImagePairs.map { it.second } + + 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) + // 개수 검증 + if (validImagePairs.size != presignedResponse.presignedUrls.size) { + throw Exception("Presigned URL count mismatch: expected ${validImagePairs.size}, got ${presignedResponse.presignedUrls.size}") + } - // 임시 파일 목록에 추가 - tempFiles.add(tempFile) + val uploadedImageUrls = mutableListOf() - // 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") + validImagePairs.forEachIndexed { index, (uri, _) -> + val presignedInfo = presignedResponse.presignedUrls[index] - // MultipartBody.Part 생성 - val requestFile = tempFile.asRequestBody(mimeType.toMediaType()) - MultipartBody.Part.createFormData(paramName, fileName, requestFile) - }.onFailure { e -> - e.printStackTrace() - }.getOrNull() + imageUploadHelper.uploadImageToS3( + uri = uri, + presignedUrl = presignedInfo.presignedUrl + ).onSuccess { + uploadedImageUrls.add(presignedInfo.fileUrl) + }.onFailure { exception -> + throw Exception("Failed to upload image ${index + 1}: ${exception.message}") + } + } + + uploadedImageUrls } + /** 전체 피드 목록 조회 */ suspend fun getAllFeeds(cursor: String? = null): Result = runCatching { feedService.getAllFeeds(cursor) @@ -205,18 +185,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/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/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/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/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/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/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/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/forms/WarningTextField.kt b/app/src/main/java/com/texthip/thip/ui/common/forms/WarningTextField.kt index bec351f1..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 @@ -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.lowercase() 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 - ) - } -} 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/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/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/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( 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, 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( 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..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 @@ -45,12 +45,15 @@ 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 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 @@ -61,13 +64,17 @@ 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 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 @@ -112,7 +119,7 @@ fun FeedCommentScreen( } LaunchedEffect(feedId) { feedDetailViewModel.loadFeedDetail(feedId) - commentsViewModel.initialize(postId = feedId.toLong(), postType = "FEED") + commentsViewModel.initialize(postId = feedId, postType = "FEED") } // 댓글이 생성되면 피드 상세 정보를 다시 로드 @@ -123,6 +130,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 +187,7 @@ fun FeedCommentScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = feedDetailUiState.error!!, + text = feedDetailUiState.error, style = typography.copy_r400_s14, color = colors.Grey ) @@ -244,6 +280,7 @@ fun FeedCommentScreen( profileImage = feedDetail.creatorProfileImageUrl ?: "", topText = feedDetail.creatorNickname, bottomText = feedDetail.aliasName, + bottomTextColor = hexToColor(feedDetail.aliasColor), showSubscriberInfo = false, hoursAgo = feedDetail.postDate, onClick = { onNavigateToUserProfile(feedDetail.creatorId) } @@ -324,8 +361,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 +417,7 @@ fun FeedCommentScreen( ) { commentItem -> CommentSection( commentItem = commentItem, - onEvent = commentsViewModel::onEvent, + onEvent = onCommentEvent, onReplyClick = { commentId, nickname -> replyingToCommentId = commentId replyingToNickname = nickname @@ -409,7 +446,7 @@ fun FeedCommentScreen( onInputChange = { commentInput = it }, onSendClick = { if (commentInput.isNotBlank()) { - commentsViewModel.onEvent( + onCommentEvent( CommentsEvent.CreateComment( content = commentInput, parentId = replyingToCommentId @@ -498,11 +535,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 +574,7 @@ fun FeedCommentScreen( description = stringResource(R.string.delete_feed_dialog_description), onConfirm = { showDeleteDialog = false - feedDetailViewModel.deleteFeed(feedId) + onDeleteFeed() }, onCancel = { showDeleteDialog = false @@ -557,3 +593,62 @@ fun FeedCommentScreen( } } } + +@Preview +@Composable +private fun FeedCommentContentPreview() { + ThipTheme { + FeedCommentContent( + 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 = {}, + onNavigateToBookDetail = {}, + onLikeClick = {}, + onBookmarkClick = {}, + onDeleteFeed = {}, + onCommentEvent = {} + ) + } +} 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/screen/FeedScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt index 6e07877d..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 @@ -76,6 +79,7 @@ fun FeedScreen( onNavigateToSearchPeople: () -> Unit = {}, onNavigateToNotification: () -> Unit = {}, refreshFeed: Boolean? = null, + onFeedTabReselected: Int = 0, // 바텀 네비게이션 재선택 트리거 onNavigateToOthersSubscription: (userId: Long) -> Unit = {}, onResultConsumed: () -> Unit = {}, onRefreshConsumed: () -> Unit = {}, @@ -139,6 +143,7 @@ fun FeedScreen( } var isUserTabChange by remember { mutableStateOf(false) } + var shouldScrollToTop by remember { mutableStateOf(false) } LaunchedEffect(Unit) { // 최초 진입시에만 데이터 로딩 @@ -172,6 +177,16 @@ fun FeedScreen( isUserTabChange = false } } + + // 같은 탭 재클릭 시 스크롤 상단 이동 처리 + LaunchedEffect(shouldScrollToTop) { + if (shouldScrollToTop) { + currentListState.scrollToItem(0) + shouldScrollToTop = false + } + } + + // 중복된 로직 제거 - 기존 bottomNavReselected 방식만 사용 LaunchedEffect(resultFeedId) { if (resultFeedId != null) { @@ -202,6 +217,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 -> @@ -230,6 +253,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( @@ -247,7 +324,7 @@ fun FeedScreen( Box(modifier = Modifier.fillMaxSize()) { PullToRefreshBox( isRefreshing = feedUiState.isPullToRefreshing, - onRefresh = { feedViewModel.pullToRefresh() } + onRefresh = onPullToRefresh ) { Column( modifier = Modifier.fillMaxSize() @@ -263,8 +340,8 @@ fun FeedScreen( titles = feedTabTitles, selectedTabIndex = feedUiState.selectedTabIndex, onTabSelected = { index -> - isUserTabChange = true - feedViewModel.onTabSelected(index) + val isCurrentTab = feedUiState.selectedTabIndex == index + onTabSelected(index) } ) @@ -283,7 +360,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) @@ -301,7 +378,7 @@ fun FeedScreen( ) { Box( modifier = Modifier - .fillMaxWidth(fraction = progress.value) + .fillMaxWidth(fraction = progress) .fillMaxHeight() .background( color = colors.NeonGreen, @@ -401,7 +478,7 @@ fun FeedScreen( MyFeedCard( feedItem = feedItem, - onLikeClick = { feedViewModel.changeFeedLike(feedItem.id) }, + onLikeClick = { onChangeFeedLike(feedItem.id) }, onContentClick = { onNavigateToFeedComment(feedItem.id) }, @@ -456,10 +533,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) @@ -471,8 +548,6 @@ fun FeedScreen( onNavigateToBookDetail(allFeed.isbn) }, onProfileClick = { - // 프로필에서 돌아올 때를 위한 플래그 설정 - navController.currentBackStackEntry?.savedStateHandle?.set("from_profile", true) onNavigateToUserProfile(allFeed.creatorId) } ) @@ -526,24 +601,80 @@ fun FeedScreen( @Preview(showBackground = true) @Composable -private fun FeedScreenPreview() { +private fun FeedContentPreview() { ThipTheme { - FeedScreen( - onNavigateToFeedWrite = { }, - onNavigateToBookDetail = { }, - navController = rememberNavController() - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun FeedScreenWithoutDataPreview() { - ThipTheme { - FeedScreen( - onNavigateToFeedWrite = { }, - onNavigateToBookDetail = { }, - navController = rememberNavController() + FeedContent( + 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(), + 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/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/FeedOthersViewModel.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedOthersViewModel.kt index 3c71985a..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 @@ -65,7 +63,7 @@ class FeedOthersViewModel @Inject constructor( } - private fun fetchData() { + fun fetchData() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } @@ -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, 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..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,30 +68,91 @@ 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) } 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 @@ -312,7 +373,7 @@ class FeedViewModel @Inject constructor( } } - private fun fetchMyFeedInfo() { + fun fetchMyFeedInfo() { viewModelScope.launch { feedRepository.getMyFeedInfo() .onSuccess { data -> 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/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 ) 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/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( 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/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/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/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/search/screen/GroupSearchScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/search/screen/GroupSearchScreen.kt index b9d3bc81..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 @@ -22,6 +22,9 @@ 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.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 @@ -29,6 +32,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 +45,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 +93,6 @@ fun GroupSearchScreen( else -> 0 } - LaunchedEffect(uiState.isCompleteSearching) { if (uiState.isCompleteSearching) { focusManager.clearFocus() @@ -91,12 +122,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 +132,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 +159,7 @@ fun GroupSearchScreen( onRoomClick = { room -> onRoomClick(room.roomId) }, canLoadMore = uiState.canLoadMore, isLoadingMore = uiState.isLoadingMore, - onLoadMore = { viewModel.loadMoreRooms() } + onLoadMore = onLoadMoreRooms ) } } @@ -157,14 +182,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 +209,7 @@ fun GroupSearchScreen( 1 -> "memberCount" else -> "deadline" } - viewModel.updateSortType(sortType) + onUpdateSortType(sortType) } ) } @@ -194,8 +219,42 @@ fun GroupSearchScreen( @Preview @Composable -fun PreviewGroupSearchScreen() { +private fun GroupSearchContentPreview() { ThipTheme { - GroupSearchScreen() + GroupSearchContent( + 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/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..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 @@ -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.isLast, + isLast = joinedRoomsResponse.isLast + ) } + 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 ) } } 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), 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/mypage/screen/MypageSaveScreen.kt b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt index 76ea537b..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 @@ -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 @@ -34,6 +33,8 @@ 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 @@ -52,15 +53,43 @@ 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) } } + MypageSaveContent( + selectedTabIndex = selectedTabIndex, + onTabSelected = { selectedTabIndex = it }, + feedList = feedList, + bookList = bookList, + onNavigateBack = onNavigateBack, + onBookClick = onBookClick, + onFeedClick = onFeedClick, + feedViewModel = feedViewModel, + bookViewModel = bookViewModel + ) +} + +@Composable +private fun MypageSaveContent( + selectedTabIndex: Int, + onTabSelected: (Int) -> Unit, + feedList: List, + bookList: List, + onNavigateBack: () -> Unit, + onBookClick: (isbn: String) -> Unit, + onFeedClick: (feedId: Long) -> Unit, + feedViewModel: SavedFeedViewModel?, + bookViewModel: SavedBookViewModel? +) { + val tabs = listOf(stringResource(R.string.feed), stringResource(R.string.book)) + Column( Modifier .background(colors.Black) @@ -103,7 +132,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 +152,21 @@ fun MypageSaveScreen( .fillMaxWidth() ) { when (selectedTabIndex) { - 0 -> FeedContent( - feedList = feedList, - onFeedClick = onFeedClick, - viewModel = feedViewModel, - ) + 0 -> feedViewModel?.let { + FeedContent( + feedList = feedList, + onFeedClick = onFeedClick, + viewModel = it + ) + } - 1 -> BookContent( - bookList = bookList, - onBookClick = onBookClick, - viewModel = bookViewModel, - ) + 1 -> bookViewModel?.let { + BookContent( + bookList = bookList, + onBookClick = onBookClick, + viewModel = it + ) + } } } } @@ -141,22 +174,83 @@ 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 = {}, + feedViewModel = null, + bookViewModel = null ) } } \ No newline at end of file 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) } } 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/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/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") }, 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 ) } } 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 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..f763cb06 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/utils/image/ImageUploadHelper.kt @@ -0,0 +1,117 @@ +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 +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) + .apply { + if (BuildConfig.DEBUG) { + addInterceptor( + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + } + ) + } + } + .build() + + suspend fun uploadImageToS3( + uri: Uri, + presignedUrl: String + ): Result = withContext(Dispatchers.IO) { + runCatching { + val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" + val tempFile = File(context.cacheDir, "temp_image_${System.currentTimeMillis()}") + + 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() + } + } + } + } + + 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@withContext null + } + + // 성능 최적화된 파일 크기 계산 + val size = getFileSize(uri) ?: return@withContext null + + ImageMetadata( + extension = extension, + size = size + ) + }.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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 24c043da..284c340a 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 @@ -425,7 +425,8 @@ 이미 모집기간이 만료된 방입니다. 모집 마감 중 오류가 발생했습니다. 방 정보를 찾을 수 없습니다. - + 모임방 인원이 다 찼어요. + 모집중인 방 정보를 찾을 수 없습니다. 모집중인 방을 불러오는데 실패했습니다.