diff --git a/app/src/main/java/com/texthip/thip/data/di/ServiceModule.kt b/app/src/main/java/com/texthip/thip/data/di/ServiceModule.kt index c1342a97..1c41a09d 100644 --- a/app/src/main/java/com/texthip/thip/data/di/ServiceModule.kt +++ b/app/src/main/java/com/texthip/thip/data/di/ServiceModule.kt @@ -3,6 +3,7 @@ package com.texthip.thip.data.di import com.texthip.thip.data.service.BookService import com.texthip.thip.data.service.RecentSearchService import com.texthip.thip.data.service.CommentsService +import com.texthip.thip.data.service.FeedService import com.texthip.thip.data.service.RoomsService import com.texthip.thip.data.service.UserService import dagger.Module @@ -42,4 +43,9 @@ object ServiceModule { @Singleton fun providesCommentsService(retrofit: Retrofit): CommentsService = retrofit.create(CommentsService::class.java) + + @Provides + @Singleton + fun provideFeedService(retrofit: Retrofit): FeedService = + retrofit.create(FeedService::class.java) } 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 new file mode 100644 index 00000000..b0b17e08 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feed/request/CreateFeedRequest.kt @@ -0,0 +1,16 @@ +package com.texthip.thip.data.model.feed.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CreateFeedRequest( + @SerialName("isbn") + val isbn: String, + @SerialName("contentBody") + val contentBody: String, + @SerialName("isPublic") + val isPublic: Boolean, + @SerialName("tagList") + val tagList: List = emptyList() +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/feed/response/CreateFeedResponse.kt b/app/src/main/java/com/texthip/thip/data/model/feed/response/CreateFeedResponse.kt new file mode 100644 index 00000000..467ee374 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feed/response/CreateFeedResponse.kt @@ -0,0 +1,10 @@ +package com.texthip.thip.data.model.feed.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CreateFeedResponse( + @SerialName("feedId") + val feedId: Int +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedWriteInfoResponse.kt b/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedWriteInfoResponse.kt new file mode 100644 index 00000000..7b95c45f --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedWriteInfoResponse.kt @@ -0,0 +1,18 @@ +package com.texthip.thip.data.model.feed.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FeedWriteInfoResponse( + @SerialName("categoryList") + val categoryList: List +) + +@Serializable +data class FeedCategory( + @SerialName("category") + val category: String, + @SerialName("tagList") + val tagList: List +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt new file mode 100644 index 00000000..0a8dc1ae --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt @@ -0,0 +1,144 @@ +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.response.CreateFeedResponse +import com.texthip.thip.data.model.feed.response.FeedWriteInfoResponse +import com.texthip.thip.data.service.FeedService +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +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 +) { + + /** 피드 작성에 필요한 카테고리 및 태그 목록 조회 */ + suspend fun getFeedWriteInfo(): Result = runCatching { + val response = feedService.getFeedWriteInfo() + .handleBaseResponse() + .getOrThrow() + + // 카테고리 순서 조정 + val orderedCategories = response?.categoryList?.sortedBy { category -> + when (category.category) { + "문학" -> 0 + "과학·IT" -> 1 + "사회과학" -> 2 + "인문학" -> 3 + "예술" -> 4 + else -> 999 + } + } ?: emptyList() + + response?.copy(categoryList = orderedCategories) + } + + /** 피드 생성 */ + suspend fun createFeed( + isbn: String, + contentBody: String, + isPublic: Boolean, + tagList: List, + imageUris: List + ): Result = runCatching { + val request = CreateFeedRequest( + isbn = isbn, + contentBody = contentBody, + isPublic = isPublic, + tagList = tagList + ) + + // JSON 요청 부분을 RequestBody로 변환 + val requestJson = json.encodeToString(CreateFeedRequest.serializer(), request) + val requestBody = requestJson.toRequestBody("application/json".toMediaType()) + + // 임시 파일 목록 추적 + val tempFiles = mutableListOf() + + try { + // 이미지 파일들을 MultipartBody.Part로 변환 + val imageParts = if (imageUris.isNotEmpty()) { + withContext(Dispatchers.IO) { + imageUris.mapNotNull { uri -> + try { + uriToMultipartBodyPart(uri, "images", tempFiles) + } catch (e: Exception) { + null + } + } + } + } else { + null + } + + feedService.createFeed(requestBody, imageParts) + .handleBaseResponse() + .getOrThrow() + } finally { + // 임시 파일들 정리 + cleanupTempFiles(tempFiles) + } + } + + private fun uriToMultipartBodyPart(uri: Uri, paramName: String, tempFiles: MutableList): MultipartBody.Part? { + return try { + // 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 fileName = "feed_image_${System.currentTimeMillis()}.$extension" + val tempFile = File(context.cacheDir, fileName) + + // 임시 파일 목록에 추가 + tempFiles.add(tempFile) + + // InputStream을 use 블록으로 안전하게 관리 + context.contentResolver.openInputStream(uri)?.use { inputStream -> + FileOutputStream(tempFile).use { outputStream -> + inputStream.copyTo(outputStream) + } + } ?: return null + + // MultipartBody.Part 생성 + val requestFile = tempFile.asRequestBody(mimeType.toMediaType()) + MultipartBody.Part.createFormData(paramName, fileName, requestFile) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + /** 임시 파일들을 정리하는 함수 */ + private fun cleanupTempFiles(tempFiles: List) { + tempFiles.forEach { file -> + try { + if (file.exists()) { + file.delete() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } +} \ 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 new file mode 100644 index 00000000..fc216f1e --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/service/FeedService.kt @@ -0,0 +1,26 @@ +package com.texthip.thip.data.service + +import com.texthip.thip.data.model.base.BaseResponse +import com.texthip.thip.data.model.feed.response.CreateFeedResponse +import com.texthip.thip.data.model.feed.response.FeedWriteInfoResponse +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part + +interface FeedService { + + /** 피드 작성에 필요한 카테고리 및 태그 목록 조회 */ + @GET("feeds/write-info") + suspend fun getFeedWriteInfo(): BaseResponse + + /** 피드 생성 */ + @Multipart + @POST("feeds") + suspend fun createFeed( + @Part("request") request: RequestBody, + @Part images: List? + ): BaseResponse +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/common/buttons/SubGenreChipRow.kt b/app/src/main/java/com/texthip/thip/ui/common/buttons/SubGenreChipRow.kt index e5da0e3b..383e5f61 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/buttons/SubGenreChipRow.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/buttons/SubGenreChipRow.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -19,7 +20,7 @@ fun SubGenreChipGrid( ) { FlowRow( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(8.dp) ) { subGenres.forEach { genre -> 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 45220924..af4e3ed8 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 @@ -1,32 +1,43 @@ package com.texthip.thip.ui.feed.screen +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight 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.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController import com.texthip.thip.R import com.texthip.thip.ui.common.buttons.FloatingButton import com.texthip.thip.ui.common.header.AuthorHeader @@ -39,22 +50,25 @@ import com.texthip.thip.ui.feed.mock.MySubscriptionData import com.texthip.thip.ui.feed.viewmodel.MySubscriptionViewModel import com.texthip.thip.ui.mypage.component.SavedFeedCard import com.texthip.thip.ui.mypage.mock.FeedItem -import com.texthip.thip.ui.navigator.routes.FeedRoutes import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Composable fun FeedScreen( - navController: NavController? = null, onNavigateToMySubscription: () -> Unit = {}, + onNavigateToFeedWrite: () -> Unit = {}, nickname: String = "", userRole: String = "", feeds: List = emptyList(), totalFeedCount: Int = 0, selectedTabIndex: Int = 0, followerProfileImageUrls: List = emptyList(), + resultFeedId: Int? = null, + onResultConsumed: () -> Unit = {}, viewModel: MySubscriptionViewModel = hiltViewModel() ) { val selectedIndex = rememberSaveable { mutableIntStateOf(selectedTabIndex) } @@ -63,6 +77,29 @@ fun FeedScreen( addAll(feeds) } } + val scope = rememberCoroutineScope() + + var showProgressBar by remember { mutableStateOf(false) } + val progress = remember { Animatable(0f) } + + LaunchedEffect(resultFeedId) { + if (resultFeedId != null) { + onResultConsumed() + + showProgressBar = true + progress.snapTo(0f) + scope.launch { + progress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 1000, easing = LinearEasing) + ) + delay(500) + if (showProgressBar) { + showProgressBar = false + } + } + } + } val mySubscriptions = listOf( MySubscriptionData( profileImageUrl = "https://example.com/image1.jpg", @@ -136,6 +173,44 @@ fun FeedScreen( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp) ) { + item { + AnimatedVisibility(visible = showProgressBar) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = 32.dp), + ) { + Text( + modifier = Modifier.padding(bottom = 12.dp), + text = if (progress.value < 1.0f) { + stringResource(R.string.posting_in_progress_feed) + } else { + stringResource(R.string.posting_complete_feed) + }, + style = typography.view_m500_s14, + color = colors.NeonGreen + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(12.dp)) + .background(color = colors.Grey02) // 트랙(배경) 색상 + ) { + Box( + modifier = Modifier + .fillMaxWidth(fraction = progress.value) + .fillMaxHeight() + .background( + color = colors.NeonGreen, + shape = RoundedCornerShape(12.dp) + ) + ) + } + } + } + } if (selectedIndex.value == 1) { // 내 피드 item { @@ -261,7 +336,7 @@ fun FeedScreen( } FloatingButton( icon = painterResource(id = R.drawable.ic_write), - onClick = { } + onClick = onNavigateToFeedWrite ) } } @@ -302,7 +377,8 @@ private fun FeedScreenPreview() { selectedTabIndex = 1, feeds = mockFeeds, totalFeedCount = mockFeeds.size, - followerProfileImageUrls = mockFollowerImages + followerProfileImageUrls = mockFollowerImages, + onNavigateToFeedWrite = { } ) } } @@ -322,7 +398,8 @@ private fun FeedScreenWithoutDataPreview() { selectedTabIndex = 0, feeds = mockFeeds, totalFeedCount = mockFeeds.size, - followerProfileImageUrls = mockFollowerImages + followerProfileImageUrls = mockFollowerImages, + onNavigateToFeedWrite = { } ) } } 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 b0bac9dc..9620029c 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 @@ -26,10 +26,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur @@ -40,66 +38,101 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage import com.texthip.thip.R +import com.texthip.thip.data.model.feed.response.FeedCategory import com.texthip.thip.ui.common.buttons.GenreChipButton import com.texthip.thip.ui.common.buttons.GenreChipRow import com.texthip.thip.ui.common.buttons.SubGenreChipGrid import com.texthip.thip.ui.common.buttons.ToggleSwitchButton import com.texthip.thip.ui.common.topappbar.InputTopAppBar -import com.texthip.thip.ui.feed.mock.FeedData +import com.texthip.thip.ui.feed.viewmodel.FeedWriteUiState +import com.texthip.thip.ui.feed.viewmodel.FeedWriteViewModel import com.texthip.thip.ui.group.makeroom.component.GroupBookSearchBottomSheet import com.texthip.thip.ui.group.makeroom.component.GroupInputField import com.texthip.thip.ui.group.makeroom.component.GroupSelectBook import com.texthip.thip.ui.group.makeroom.component.SectionDivider -import com.texthip.thip.ui.group.makeroom.mock.dummyGroupBooks -import com.texthip.thip.ui.group.makeroom.mock.dummySavedBooks +import com.texthip.thip.ui.group.makeroom.mock.BookData import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun FeedWriteScreen( + modifier: Modifier = Modifier, onNavigateBack: () -> Unit, - modifier: Modifier = Modifier + onFeedCreated: (Int) -> Unit = {}, + viewModel: FeedWriteViewModel = hiltViewModel() ) { - var feedData by remember { mutableStateOf(FeedData()) } - val scrollState = rememberScrollState() - val genres = listOf("문학", "과학·IT", "사회과학", "인문학", "예술") - val subGenreMap = mapOf( - 0 to listOf("소설", "에세이", "시", "고전", "추리", "판타지", "로맨스", "SF", "공포", "역사"), - 1 to listOf("AI", "프로그래밍", "로봇", "IT 일반", "수학", "물리", "화학"), - 2 to listOf("정치", "경제", "법", "사회", "교육"), - 3 to listOf("철학", "역사", "심리", "종교", "윤리"), - 4 to listOf("음악", "미술", "공예", "무용", "연극") + val uiState by viewModel.uiState.collectAsState() + + FeedWriteContent( + uiState = uiState, + onNavigateBack = onNavigateBack, + onCreateFeed = { + viewModel.createFeed( + onSuccess = { feedId -> + onFeedCreated(feedId) + }, + onError = { errorMessage -> + } + ) + }, + onSelectBook = viewModel::selectBook, + onToggleBookSearchSheet = viewModel::toggleBookSearchSheet, + onUpdateFeedContent = viewModel::updateFeedContent, + onAddImages = viewModel::addImages, + onRemoveImage = viewModel::removeImage, + onTogglePrivate = viewModel::togglePrivate, + onSelectCategory = viewModel::selectCategory, + onToggleTag = viewModel::toggleTag, + onRemoveTag = viewModel::removeTag, + onSearchBooks = viewModel::searchBooks, + modifier = modifier ) - val showBookSearchSheet = remember { mutableStateOf(false) } +} + +@Composable +fun FeedWriteContent( + modifier: Modifier = Modifier, + uiState: FeedWriteUiState, + onNavigateBack: () -> Unit = {}, + onCreateFeed: () -> Unit = {}, + onSelectBook: (BookData) -> Unit = {}, + onToggleBookSearchSheet: (Boolean) -> Unit = {}, + onUpdateFeedContent: (String) -> Unit = {}, + onAddImages: (List) -> Unit = {}, + onRemoveImage: (Int) -> Unit = {}, + onTogglePrivate: (Boolean) -> Unit = {}, + onSelectCategory: (Int) -> Unit = {}, + onToggleTag: (String) -> Unit = {}, + onRemoveTag: (String) -> Unit = {}, + onSearchBooks: (String) -> Unit = {} +) { + val scrollState = rememberScrollState() val imagePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetMultipleContents() ) { uris: List -> if (uris.isNotEmpty()) { - val availableSlots = 3 - feedData.imageUris.size - val imagesToAdd = uris.take(availableSlots) // 3장까지만 유지 - feedData.imageUris.addAll(imagesToAdd) + onAddImages(uris) } } - val isImageLimitReached = feedData.imageUris.size >= 3 val focusManager = LocalFocusManager.current Box { Column( modifier = modifier .fillMaxSize() - .then(if (showBookSearchSheet.value) Modifier.blur(5.dp) else Modifier), + .then(if (uiState.showBookSearchSheet) Modifier.blur(5.dp) else Modifier), horizontalAlignment = Alignment.CenterHorizontally ) { - val isRightButtonEnabled = feedData.selectedBook != null && feedData.feedContent.isNotBlank() && feedData.selectedGenreIndex != -1 && feedData.selectedSubGenres.isNotEmpty() InputTopAppBar( title = stringResource(R.string.new_feed), rightButtonName = stringResource(R.string.registration), - isRightButtonEnabled = isRightButtonEnabled, + isRightButtonEnabled = uiState.isFormValid && !uiState.isLoading, onLeftClick = onNavigateBack, - onRightClick = {} + onRightClick = onCreateFeed ) Column( modifier = Modifier @@ -116,9 +149,10 @@ fun FeedWriteScreen( Spacer(modifier = Modifier.height(20.dp)) GroupSelectBook( - selectedBook = feedData.selectedBook, - onChangeBookClick = { showBookSearchSheet.value = true }, - onSelectBookClick = { showBookSearchSheet.value = true } + selectedBook = uiState.selectedBook, + onChangeBookClick = { onToggleBookSearchSheet(true) }, + onSelectBookClick = { onToggleBookSearchSheet(true) }, + isBookPreselected = uiState.isBookPreselected ) SectionDivider() @@ -126,11 +160,9 @@ fun FeedWriteScreen( GroupInputField( title = stringResource(R.string.write_feed), hint = stringResource(R.string.write_feed_hint), - value = feedData.feedContent, + value = uiState.feedContent, maxLength = 2000, - onValueChange = { newText -> - feedData = feedData.copy(feedContent = newText) - } + onValueChange = onUpdateFeedContent ) SectionDivider() @@ -151,10 +183,10 @@ fun FeedWriteScreen( modifier = Modifier .size(80.dp) .background(color = colors.DarkGrey02) - .border(width = 1.dp, color = if (isImageLimitReached) colors.DarkGrey else colors.Grey02, + .border(width = 1.dp, color = if (!uiState.canAddMoreImages) colors.DarkGrey else colors.Grey02, ) .let { - if (!isImageLimitReached) it.clickable { + if (uiState.canAddMoreImages) it.clickable { imagePickerLauncher.launch("image/*") } else it // 클릭 비활성화 }, @@ -164,20 +196,20 @@ fun FeedWriteScreen( Icon( painter = painterResource(id = R.drawable.ic_plus), contentDescription = null, - tint = if (isImageLimitReached) colors.DarkGrey else colors.White + tint = if (!uiState.canAddMoreImages) colors.DarkGrey else colors.White ) } } - items(feedData.imageUris.size) { index -> + items(uiState.imageUris.size) { index -> Box(modifier = Modifier.size(80.dp)) { AsyncImage( - model = feedData.imageUris[index], + model = uiState.imageUris[index], contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop ) IconButton( - onClick = { feedData.imageUris.removeAt(index) }, + onClick = { onRemoveImage(index) }, modifier = Modifier .align(Alignment.TopEnd) .size(24.dp) @@ -196,7 +228,7 @@ fun FeedWriteScreen( horizontalArrangement = Arrangement.End ) { Text( - text = stringResource(id = R.string.photo_count, feedData.imageUris.size, 3), + text = stringResource(id = R.string.photo_count, uiState.imageUris.size, 3), style = typography.info_r400_s12, color = colors.NeonGreen, ) @@ -219,10 +251,8 @@ fun FeedWriteScreen( color = colors.White ) ToggleSwitchButton( - isChecked = feedData.isPrivate, - onToggleChange = { isChecked -> - feedData = feedData.copy(isPrivate = isChecked) - } + isChecked = uiState.isPrivate, + onToggleChange = onTogglePrivate ) } SectionDivider() @@ -235,43 +265,29 @@ fun FeedWriteScreen( Spacer(modifier = Modifier.padding(top = 12.dp)) GenreChipRow( modifier = Modifier.width(18.dp), - genres = genres, - selectedIndex = feedData.selectedGenreIndex, - onSelect = { - feedData = feedData.copy(selectedGenreIndex = it, selectedSubGenres = emptyList()) - } + genres = uiState.categories.map { it.category }, + selectedIndex = uiState.selectedCategoryIndex, + onSelect = onSelectCategory ) Spacer(modifier = Modifier.height(12.dp)) - if (feedData.selectedGenreIndex != -1) { - val subGenres = subGenreMap[feedData.selectedGenreIndex].orEmpty() + if (uiState.selectedCategoryIndex != -1) { Spacer(modifier = Modifier.height(8.dp)) SubGenreChipGrid( - subGenres = subGenres, - selectedGenres = feedData.selectedSubGenres, - onGenreToggle = { genre -> - val newSelected = if (feedData.selectedSubGenres.contains(genre)) { - feedData.selectedSubGenres - genre - } else { - if (feedData.selectedSubGenres.size < 5) { - feedData.selectedSubGenres + genre - } else { - feedData.selectedSubGenres - } - } - - feedData = feedData.copy(selectedSubGenres = newSelected) - } + subGenres = uiState.availableTags, + selectedGenres = uiState.selectedTags, + onGenreToggle = onToggleTag ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End ) { Text( - text = stringResource(id = R.string.tag_count, feedData.selectedSubGenres.size, 5), + modifier = Modifier.padding(top = 12.dp), + text = stringResource(id = R.string.tag_count, uiState.selectedTags.size, 5), style = typography.info_r400_s12, - color = colors.NeonGreen, - ) + color = colors.NeonGreen + ) } } Spacer(modifier = Modifier.height(12.dp)) @@ -281,25 +297,21 @@ fun FeedWriteScreen( color = colors.White ) Spacer(modifier = Modifier.height(12.dp)) - if (feedData.selectedSubGenres.isNotEmpty()) { + if (uiState.selectedTags.isNotEmpty()) { LazyRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - items(feedData.selectedSubGenres) { subGenre -> + items(uiState.selectedTags) { tag -> GenreChipButton( - text = subGenre, + text = tag, onClick = { - //해당 칩 눌렀을 때도 서브장르 삭제 - feedData = feedData.copy( - selectedSubGenres =feedData.selectedSubGenres - subGenre - ) + //해당 칩 눌렀을 때도 태그 삭제 + onRemoveTag(tag) }, onCloseClick = { - //x버튼 누르면 서브장르 삭제 - feedData = feedData.copy( - selectedSubGenres = feedData.selectedSubGenres - subGenre - ) + //x버튼 누르면 태그 삭제 + onRemoveTag(tag) } ) } @@ -309,18 +321,22 @@ fun FeedWriteScreen( } } - if (showBookSearchSheet.value) { + if (uiState.showBookSearchSheet) { GroupBookSearchBottomSheet( - onDismiss = { showBookSearchSheet.value = false }, + onDismiss = { onToggleBookSearchSheet(false) }, onBookSelect = { book -> - feedData = feedData.copy(selectedBook= book) - showBookSearchSheet.value = false + onSelectBook(book) + onToggleBookSearchSheet(false) }, onRequestBook = { - showBookSearchSheet.value = false + onToggleBookSearchSheet(false) }, - savedBooks = dummySavedBooks, - groupBooks = dummyGroupBooks + savedBooks = uiState.savedBooks, + groupBooks = uiState.groupBooks, + searchResults = uiState.searchResults, + isLoading = uiState.isLoadingBooks, + isSearching = uiState.isSearching, + onSearch = onSearchBooks ) } } @@ -331,8 +347,42 @@ fun FeedWriteScreen( @Composable private fun FeedWriteScreenPreview() { ThipTheme { - FeedWriteScreen( - onNavigateBack = { } + FeedWriteContent( + uiState = FeedWriteUiState( + selectedBook = BookData( + title = "미드나이트 라이브러리", + imageUrl = "https://picsum.photos/300/400?1", + author = "매트 헤이그", + isbn = "9788937477263" + ), + feedContent = "이 책을 읽고 정말 많은 생각이 들었습니다...", + selectedCategoryIndex = 0, + selectedTags = listOf("한국소설", "에세이"), + categories = listOf( + FeedCategory( + category = "문학", + tagList = listOf("한국소설", "외국소설", "에세이", "시", "고전") + ), + FeedCategory( + category = "과학·IT", + tagList = listOf("프로그래밍", "AI", "과학일반") + ), + FeedCategory( + category = "사회과학", + tagList = listOf("프로그래밍", "AI", "과학일반") + ), + FeedCategory( + category = "인문학", + tagList = listOf("프로그래밍", "AI", "과학일반") + ), + FeedCategory( + category = "예술", + tagList = listOf("프로그래밍", "AI", "과학일반") + ) + ), + imageUris = emptyList(), + isPrivate = false + ) ) } } 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 new file mode 100644 index 00000000..a598ba83 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteUiState.kt @@ -0,0 +1,62 @@ +package com.texthip.thip.ui.feed.viewmodel + +import android.net.Uri +import com.texthip.thip.data.model.feed.response.FeedCategory +import com.texthip.thip.ui.group.makeroom.mock.BookData + +data class FeedWriteUiState( + val selectedBook: BookData? = null, + val showBookSearchSheet: Boolean = false, + val feedContent: String = "", + val imageUris: List = emptyList(), + val isPrivate: Boolean = false, + val selectedCategoryIndex: Int = -1, + val selectedTags: List = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null, + val savedBooks: List = emptyList(), + val groupBooks: List = emptyList(), + val isLoadingBooks: Boolean = false, + val searchResults: List = emptyList(), + val isSearching: Boolean = false, + val categories: List = emptyList(), + val isBookPreselected: Boolean = false, + val isLoadingCategories: Boolean = false +) { + // 유효성 검사 로직 + val isContentValid: Boolean + get() = feedContent.isNotBlank() && feedContent.length <= 2000 + + val isImageCountValid: Boolean + get() = imageUris.size <= 3 + + val isFormValid: Boolean + get() = selectedBook != null && + isContentValid && + isImageCountValid && + selectedTags.size <= 5 // 태그는 최대 5개까지만 + + // 태그 개수 제한 (최대 5개) + val canAddMoreTags: Boolean + get() = selectedTags.size < 5 + + // 이미지 개수 제한 (최대 3개) + val canAddMoreImages: Boolean + get() = imageUris.size < 3 + + // 현재 선택된 카테고리의 태그 목록 + val availableTags: List + get() = if (selectedCategoryIndex >= 0 && selectedCategoryIndex < categories.size) { + categories[selectedCategoryIndex].tagList + } else { + emptyList() + } + + // 현재 선택된 카테고리 이름 + val selectedCategoryName: String? + get() = if (selectedCategoryIndex >= 0 && selectedCategoryIndex < categories.size) { + categories[selectedCategoryIndex].category + } else { + null + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..b6fc4b6b --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteViewModel.kt @@ -0,0 +1,283 @@ +package com.texthip.thip.ui.feed.viewmodel + +import android.net.Uri +import androidx.lifecycle.ViewModel +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.provider.StringResourceProvider +import com.texthip.thip.data.repository.BookRepository +import com.texthip.thip.data.repository.FeedRepository +import com.texthip.thip.ui.group.makeroom.mock.BookData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FeedWriteViewModel @Inject constructor( + private val feedRepository: FeedRepository, + private val bookRepository: BookRepository, + private val stringResourceProvider: StringResourceProvider +) : ViewModel() { + + private val _uiState = MutableStateFlow(FeedWriteUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var searchJob: Job? = null + + private fun updateState(update: (FeedWriteUiState) -> FeedWriteUiState) { + _uiState.value = update(_uiState.value) + } + + init { + loadFeedWriteInfo() + } + + private fun loadFeedWriteInfo() { + viewModelScope.launch { + updateState { it.copy(isLoadingCategories = true) } + feedRepository.getFeedWriteInfo() + .onSuccess { response -> + updateState { + it.copy( + categories = response?.categoryList ?: emptyList(), + isLoadingCategories = false + ) + } + } + .onFailure { + updateState { + it.copy( + categories = emptyList(), + isLoadingCategories = false, + errorMessage = stringResourceProvider.getString(R.string.error_network_error) + ) + } + } + } + } + + fun selectBook(book: BookData) { + updateState { it.copy(selectedBook = book) } + } + + fun toggleBookSearchSheet(show: Boolean) { + updateState { it.copy(showBookSearchSheet = show) } + if (show) { + loadBooks() + } + } + + private fun loadBooks() { + 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()) } + } + + val groupBooksResult = bookRepository.getBooks("JOINING") + groupBooksResult.onSuccess { response -> + updateState { + it.copy(groupBooks = response?.bookList?.map { dto -> dto.toBookData() } + ?: emptyList()) + } + }.onFailure { + updateState { it.copy(groupBooks = emptyList()) } + } + } catch (e: Exception) { + updateState { it.copy(savedBooks = emptyList(), groupBooks = emptyList()) } + } finally { + updateState { it.copy(isLoadingBooks = false) } + } + } + } + + private fun BookSavedResponse.toBookData(): 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, + imageUrl = this.imageUrl, + author = this.authorName, + isbn = this.isbn + ) + } + + fun searchBooks(query: String) { + searchJob?.cancel() + + if (query.isBlank()) { + updateState { it.copy(searchResults = emptyList(), isSearching = false) } + return + } + + searchJob = viewModelScope.launch { + delay(300) // 디바운싱 + updateState { it.copy(isSearching = true) } + + 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 + ) + } + }.onFailure { + updateState { + it.copy( + searchResults = emptyList(), + isSearching = false + ) + } + } + } catch (e: Exception) { + updateState { + it.copy( + searchResults = emptyList(), + isSearching = false + ) + } + } + } + } + + fun updateFeedContent(content: String) { + if (content.length <= 2000) { + updateState { it.copy(feedContent = content) } + } + } + + fun addImages(newImageUris: List) { + val currentState = _uiState.value + val availableSlots = 3 - currentState.imageUris.size + val imagesToAdd = newImageUris.take(availableSlots) + + updateState { + it.copy(imageUris = currentState.imageUris + imagesToAdd) + } + } + + fun removeImage(index: Int) { + val currentImages = _uiState.value.imageUris.toMutableList() + if (index in currentImages.indices) { + currentImages.removeAt(index) + updateState { it.copy(imageUris = currentImages) } + } + } + + fun togglePrivate(isPrivate: Boolean) { + updateState { it.copy(isPrivate = isPrivate) } + } + + fun selectCategory(index: Int) { + updateState { + it.copy( + selectedCategoryIndex = index, + selectedTags = emptyList() // 카테고리 변경 시 태그 초기화 + ) + } + } + + fun toggleTag(tag: String) { + val currentState = _uiState.value + val newSelectedTags = if (currentState.selectedTags.contains(tag)) { + currentState.selectedTags - tag + } else { + if (currentState.canAddMoreTags) { + currentState.selectedTags + tag + } else { + currentState.selectedTags + } + } + updateState { it.copy(selectedTags = newSelectedTags) } + } + + fun removeTag(tag: String) { + val currentTags = _uiState.value.selectedTags + updateState { + it.copy(selectedTags = currentTags - tag) + } + } + + fun createFeed(onSuccess: (Int) -> Unit, onError: (String) -> Unit) { + val currentState = _uiState.value + + if (!currentState.isFormValid) { + onError(stringResourceProvider.getString(R.string.error_form_validation)) + return + } + + val selectedBook = currentState.selectedBook + if (selectedBook?.isbn == null) { + onError(stringResourceProvider.getString(R.string.error_book_info_invalid)) + return + } + + viewModelScope.launch { + try { + updateState { it.copy(isLoading = true, errorMessage = null) } + + val result = feedRepository.createFeed( + isbn = selectedBook.isbn, + contentBody = currentState.feedContent.trim(), + isPublic = !currentState.isPrivate, + tagList = currentState.selectedTags, + imageUris = currentState.imageUris + ) + + result.onSuccess { response -> + val feedId = response?.feedId + if (feedId != null) { + onSuccess(feedId) + } else { + onError(stringResourceProvider.getString(R.string.error_feed_id_not_returned)) + } + }.onFailure { exception -> + onError( + exception.message ?: stringResourceProvider.getString(R.string.error_network_error) + ) + } + + } catch (e: Exception) { + onError( + stringResourceProvider.getString( + R.string.error_network_error, + e.message ?: "" + ) + ) + } finally { + updateState { it.copy(isLoading = false) } + } + } + } + + fun clearError() { + updateState { it.copy(errorMessage = null) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt index ef71bb4e..02387a67 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt @@ -12,4 +12,9 @@ fun NavHostController.navigateToFeed() { // 내 띱 목록으로 fun NavHostController.navigateToMySubscription() { navigate(FeedRoutes.MySubscription) +} + +// 피드 작성으로 +fun NavHostController.navigateToFeedWrite() { + navigate(FeedRoutes.Write) } \ No newline at end of file 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 740a9711..40df9757 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 @@ -4,29 +4,52 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable import com.texthip.thip.ui.feed.screen.FeedScreen +import com.texthip.thip.ui.feed.screen.FeedWriteScreen import com.texthip.thip.ui.feed.screen.MySubscriptionScreen +import com.texthip.thip.ui.navigator.extensions.navigateToFeedWrite import com.texthip.thip.ui.navigator.extensions.navigateToMySubscription import com.texthip.thip.ui.navigator.routes.FeedRoutes import com.texthip.thip.ui.navigator.routes.MainTabRoutes // Feed fun NavGraphBuilder.feedNavigation(navController: NavHostController) { - composable { - //TODO 추후 view model 적용 예정 + composable { backStackEntry -> + val resultFeedId = backStackEntry.savedStateHandle.get("feedId") + FeedScreen( - navController = navController, nickname = "ThipUser01", userRole = "문학가", feeds = emptyList(), totalFeedCount = 0, selectedTabIndex = 0, followerProfileImageUrls = emptyList(), + resultFeedId = resultFeedId, + onResultConsumed = { + backStackEntry.savedStateHandle.remove("feedId") + }, onNavigateToMySubscription = { navController.navigateToMySubscription() + }, + onNavigateToFeedWrite = { + navController.navigateToFeedWrite() } ) } composable { MySubscriptionScreen(navController = navController) } + composable { + FeedWriteScreen( + onNavigateBack = { + navController.popBackStack() + }, + onFeedCreated = { feedId -> + // 피드 생성 성공 시 결과를 저장하고 피드 목록으로 돌아가기 + navController.getBackStackEntry(MainTabRoutes.Feed) + .savedStateHandle + .set("feedId", feedId) + navController.popBackStack() + } + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/routes/FeedRoutes.kt b/app/src/main/java/com/texthip/thip/ui/navigator/routes/FeedRoutes.kt index 4bb9935a..c8c9eeb6 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/routes/FeedRoutes.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/routes/FeedRoutes.kt @@ -4,9 +4,7 @@ import kotlinx.serialization.Serializable @Serializable sealed class FeedRoutes : Routes() { - // 향후 추가될 Feed 관련 화면들 - // @Serializable data object SubscriptionList : FeedRoutes - // @Serializable data object Detail : FeedRoutes @Serializable data object MySubscription : FeedRoutes() + @Serializable data object Write : FeedRoutes() } \ 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 b58bc2c7..af22d257 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -330,9 +330,9 @@ 비공개로 설정 태그 사진 추가 - %1$d / %2$d + %1$d / %2$d개 선택된 태그 - %1$d / %2$d + %1$d / %2$d개 수정하기 삭제하기 이 피드를 삭제하시겠어요? @@ -340,6 +340,8 @@ 찾는 사용자가 없어요 사용자 찾기 내가 찾는 사용자를 검색해보세요. + 글을 작성중이에요... + 새 글 작성을 완료했어요! @@ -404,6 +406,9 @@ 알 수 없는 오류가 발생했습니다. + + + 서버 feedId 반환 오류 과학/IT