diff --git a/app/src/main/java/com/flint/android/domain/mapper/content/ContentMapper.kt b/app/src/main/java/com/flint/android/domain/mapper/content/ContentMapper.kt index d3ec389d..81adc604 100644 --- a/app/src/main/java/com/flint/android/domain/mapper/content/ContentMapper.kt +++ b/app/src/main/java/com/flint/android/domain/mapper/content/ContentMapper.kt @@ -9,6 +9,7 @@ import com.flint.android.domain.model.content.BookmarkedContentListModel import com.flint.android.domain.model.content.ContentModel import com.flint.android.domain.type.OttType import kotlinx.collections.immutable.toImmutableList +import timber.log.Timber // /api/v1/users/{userId}/bookmarked-contents (타 유저) fun BookmarkedContentListResponseDto.toModel() : BookmarkedContentListModel { @@ -27,7 +28,9 @@ fun BookmarkedContentResponseDto.toModel() : BookmarkedContentItemModel { bookmarkCount = bookmarkCount, isBookmarked = isBookmarked, getOttSimpleList = getOttSimpleList.mapNotNull { ottSimple -> - runCatching { OttType.valueOf(ottSimple.ottName) }.getOrNull() + runCatching { OttType.valueOf(ottSimple.ottName) } + .onFailure { Timber.w("Unknown OTT name from server: ${ottSimple.ottName}") } + .getOrNull() } ) } diff --git a/app/src/main/java/com/flint/android/domain/repository/CollectionRepository.kt b/app/src/main/java/com/flint/android/domain/repository/CollectionRepository.kt index 4a38a0bd..9aa04739 100644 --- a/app/src/main/java/com/flint/android/domain/repository/CollectionRepository.kt +++ b/app/src/main/java/com/flint/android/domain/repository/CollectionRepository.kt @@ -12,10 +12,20 @@ import com.flint.android.domain.model.collection.CollectionListModel import com.flint.android.domain.model.collection.CollectionReportRequestModel import com.flint.android.domain.model.collection.CollectionsModel import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +@Singleton class CollectionRepository @Inject constructor( private val apiService: CollectionApi, ) { + // 컬렉션 삭제 시 발생하는 전역 이벤트 (삭제된 collectionId). + // 컬렉션 상세에서 삭제해도 이전 depth의 목록(생성한 컬렉션 리스트, MY 프로필 등)에 + // 즉시 반영되도록 구독한다. + private val _collectionDeletions = MutableSharedFlow() + val collectionDeletions = _collectionDeletions.asSharedFlow() + // 컬렉션 목록 조회 (페이지네이션) suspend fun getCollections(cursor: Long?, size: Int): Result = suspendRunCatching { @@ -54,11 +64,16 @@ class CollectionRepository @Inject constructor( } // 컬렉션 삭제 - suspend fun deleteCollection(collectionId: String): Result = - suspendRunCatching { + suspend fun deleteCollection(collectionId: String): Result { + val result = suspendRunCatching { apiService.deleteCollection(collectionId) Unit } + if (result.isSuccess) { + _collectionDeletions.emit(collectionId) + } + return result + } // 최근 본 컬렉션 목록 조회 suspend fun getRecentCollectionList(): Result = diff --git a/app/src/main/java/com/flint/android/domain/repository/ProfileImageUploader.kt b/app/src/main/java/com/flint/android/domain/repository/ProfileImageUploader.kt new file mode 100644 index 00000000..a39ff967 --- /dev/null +++ b/app/src/main/java/com/flint/android/domain/repository/ProfileImageUploader.kt @@ -0,0 +1,55 @@ +package com.flint.android.domain.repository + +import android.content.Context +import android.net.Uri +import com.flint.android.core.common.util.suspendRunCatching +import com.flint.android.domain.type.FileExtension +import com.flint.android.domain.type.StoragePathType +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +// 온보딩(회원가입)과 프로필 수정 화면에서 공통으로 쓰는 +// "이미지 읽기 -> presigned URL 발급 -> S3 업로드 -> 서버에 반영" 흐름을 한 곳에 모아둔다. +class ProfileImageUploader @Inject constructor( + @ApplicationContext private val context: Context, + private val storageRepository: StorageRepository, + private val userRepository: UserRepository, +) { + suspend fun upload(uri: Uri): Result { + val (mimeType, imageBytes) = suspendRunCatching { + withContext(Dispatchers.IO) { + val resolvedMimeType = context.contentResolver.getType(uri) ?: "image/jpeg" + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + resolvedMimeType to bytes + } + }.getOrElse { error -> return Result.failure(error) } + + if (imageBytes == null) { + return Result.failure(IllegalStateException("Failed to open image stream")) + } + + val extension = mimeTypeToFileExtension(mimeType) + + val presignedUrl = storageRepository.getPresignedUrl( + pathType = StoragePathType.USER_PROFILE, + extension = extension, + ).getOrElse { error -> return Result.failure(error) } + + storageRepository.uploadToS3( + uploadUrl = presignedUrl.uploadUrl, + imageBytes = imageBytes, + mimeType = mimeType, + ).getOrElse { error -> return Result.failure(error) } + + return userRepository.updateProfileImage(presignedUrl.key) + } + + private fun mimeTypeToFileExtension(mimeType: String): FileExtension = when (mimeType) { + "image/png" -> FileExtension.PNG + "image/gif" -> FileExtension.GIF + "image/webp" -> FileExtension.WEBP + else -> FileExtension.JPEG + } +} diff --git a/app/src/main/java/com/flint/android/presentation/collectionlist/CollectionListScreen.kt b/app/src/main/java/com/flint/android/presentation/collectionlist/CollectionListScreen.kt index e55aeb2a..ad34b975 100644 --- a/app/src/main/java/com/flint/android/presentation/collectionlist/CollectionListScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/collectionlist/CollectionListScreen.kt @@ -56,6 +56,7 @@ fun CollectionListRoute( CollectionListScreen( modifier = Modifier.padding(paddingValues), title = uiState.appbarTitle, + routeType = uiState.routeType, onBackClick = navigateUp, onCollectionItemClick = navigateToCollectionDetail, onBookmarkClick = viewModel::toggleCollectionBookmark, @@ -109,6 +110,7 @@ fun CollectionListRoute( private fun CollectionListScreen( onBackClick: () -> Unit, title: String, + routeType: CollectionListRouteType, collectionList: UiState, onCollectionItemClick: (collectionId: String) -> Unit, onBookmarkClick: (collectionId: String) -> Unit, @@ -145,8 +147,14 @@ private fun CollectionListScreen( ) { Spacer(Modifier.height(12.dp)) + val displayCount = if (routeType == CollectionListRouteType.SAVED) { + collections.count { it.isBookmarked } + } else { + collections.size + } + Text( - text = "총 ${collections.size}개", + text = "총 ${displayCount}개", color = FlintTheme.colors.gray100, modifier = Modifier @@ -207,6 +215,7 @@ private fun CollectionListScreenPreview() { CollectionListScreen( onBackClick = {}, title = "전체 컬렉션", + routeType = CollectionListRouteType.CREATED, collectionList = UiState.Success(CollectionListModel.FakeList), onCollectionItemClick = {}, onBookmarkClick = {}, diff --git a/app/src/main/java/com/flint/android/presentation/collectionlist/CollectionListViewModel.kt b/app/src/main/java/com/flint/android/presentation/collectionlist/CollectionListViewModel.kt index 85bff31b..404d81e9 100644 --- a/app/src/main/java/com/flint/android/presentation/collectionlist/CollectionListViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/collectionlist/CollectionListViewModel.kt @@ -48,7 +48,27 @@ class CollectionListViewModel @Inject constructor( init { val routeReceiveData = savedStateHandle.toRoute() setAppBarTitle(routeReceiveData.routeType.title) + _uiState.update { it.copy(routeType = routeReceiveData.routeType) } getCollectionList(routeReceiveData) + observeCollectionDeletions() + } + + // 컬렉션 상세 등 다른 화면에서 컬렉션을 삭제해도 이 목록에 즉시 반영되도록 구독한다. + private fun observeCollectionDeletions() { + viewModelScope.launch { + collectionRepository.collectionDeletions.collect { deletedCollectionId -> + _uiState.update { currentState -> + val collectionList = currentState.collectionList + if (collectionList !is UiState.Success) return@update currentState + + val filtered = collectionList.data.collections + .filter { it.id != deletedCollectionId } + .toImmutableList() + + currentState.copy(collectionList = UiState.Success(CollectionListModel(collections = filtered))) + } + } + } } private fun setAppBarTitle(title: String) { diff --git a/app/src/main/java/com/flint/android/presentation/collectionlist/uistate/CollectionListUiState.kt b/app/src/main/java/com/flint/android/presentation/collectionlist/uistate/CollectionListUiState.kt index 6e6f1c3a..7c6ad70b 100644 --- a/app/src/main/java/com/flint/android/presentation/collectionlist/uistate/CollectionListUiState.kt +++ b/app/src/main/java/com/flint/android/presentation/collectionlist/uistate/CollectionListUiState.kt @@ -1,9 +1,11 @@ package com.flint.android.presentation.collectionlist.uistate import com.flint.android.core.common.util.UiState +import com.flint.android.core.navigation.model.CollectionListRouteType import com.flint.android.domain.model.collection.CollectionListModel data class CollectionListUiState( val appbarTitle: String = "", + val routeType: CollectionListRouteType = CollectionListRouteType.CREATED, val collectionList: UiState = UiState.Loading ) \ No newline at end of file diff --git a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingProfileScreen.kt b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingProfileScreen.kt index 8a72ff7e..9f39fbe7 100644 --- a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingProfileScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingProfileScreen.kt @@ -9,15 +9,17 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets 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.consumeWindowInsets -import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -29,6 +31,7 @@ import androidx.compose.runtime.LaunchedEffect 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 @@ -56,6 +59,7 @@ import com.flint.android.core.designsystem.interaction.pressClickable import com.flint.android.core.designsystem.interaction.pressScale import com.flint.android.core.designsystem.theme.FlintTheme import com.flint.android.presentation.onboarding.event.OnboardingProfileEvent +import kotlinx.coroutines.delay @Composable fun OnboardingProfileRoute( @@ -112,7 +116,7 @@ fun OnboardingProfileRoute( ) } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun OnboardingProfileScreen( nickname: String, @@ -134,7 +138,28 @@ fun OnboardingProfileScreen( modifier: Modifier = Modifier, ) { val keyboardController = LocalSoftwareKeyboardController.current - var showProfileBottomSheet by remember { mutableStateOf(false) } + var showProfileBottomSheet by rememberSaveable { mutableStateOf(false) } + // 0이면 대기 중인 요청이 없다는 뜻. 클릭할 때마다 증가시켜서, 이전 요청과 새 요청을 + // 구분하고(회전 등 config change에도 rememberSaveable로 값이 유지된다). + var pendingBottomSheetToken by rememberSaveable { mutableStateOf(0) } + val imeVisible = WindowInsets.isImeVisible + + LaunchedEffect(pendingBottomSheetToken, imeVisible) { + val token = pendingBottomSheetToken + if (token == 0) return@LaunchedEffect + + if (imeVisible) { + // 키보드가 내려갔다는 신호(WindowInsets.isImeVisible == false)가 오지 않는 + // 기기(하드웨어 키보드 등)를 대비해, 일정 시간 뒤에는 강제로 바텀시트를 띄운다. + delay(300) + } + + // delay 도중 새 클릭이 들어와 token이 바뀌었다면 그 요청은 무시한다. + if (pendingBottomSheetToken == token) { + pendingBottomSheetToken = 0 + showProfileBottomSheet = true + } + } val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia() @@ -147,93 +172,105 @@ fun OnboardingProfileScreen( modifier = Modifier .fillMaxSize() .background(color = FlintTheme.colors.background) - .imePadding() ) { - FlintBackTopAppbar( - onClick = onBackClick, - ) - Column( modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .weight(1f), ) { - - EditProfileImage( - imageUrl = profileImageUri?.toString() ?: "", - onEditClick = { showProfileBottomSheet = true } - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = "어떤 이름으로 불러드릴까요?", - color = FlintTheme.colors.white, - style = FlintTheme.typography.head3M18, - modifier = Modifier.fillMaxWidth(), + FlintBackTopAppbar( + onClick = onBackClick, ) - Spacer(modifier = Modifier.height(12.dp)) - - Row( + Column( modifier = Modifier - .fillMaxWidth() - .height(40.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, + .weight(1f) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - FlintBasicTextField( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - placeholder = "닉네임", - value = nickname, - singleLine = true, - maxLines = 1, - maxLength = OnboardingProfileUiState.MAX_LENGTH, - onValueChange = onNicknameChange, - borderColor = if (!isFormatValid || isNicknameAvailable == false) { - FlintTheme.colors.error500 - } else { - Color.Unspecified - }, - trailingContent = { - Text( - text = "${nickname.graphemeLength}/${OnboardingProfileUiState.MAX_LENGTH}", - style = FlintTheme.typography.body1R16, - color = FlintTheme.colors.gray300, - ) - }, + EditProfileImage( + imageUrl = profileImageUri?.toString() ?: "", + onEditClick = { + // 키보드가 떠 있으면 먼저 내리고, 완전히 내려간 뒤(위 LaunchedEffect)에 + // 바텀시트를 띄운다. 키보드가 이미 없으면 바로 띄운다. + if (imeVisible) { + keyboardController?.hide() + pendingBottomSheetToken += 1 + } else { + showProfileBottomSheet = true + } + } + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "어떤 이름으로 불러드릴까요?", + color = FlintTheme.colors.white, + style = FlintTheme.typography.head3M18, + modifier = Modifier.fillMaxWidth(), ) - val checkInteractionSource = remember { MutableInteractionSource() } + Spacer(modifier = Modifier.height(12.dp)) - Box( + Row( modifier = Modifier - .fillMaxHeight() - .pressScale(checkInteractionSource, enabled = canCheckNickname) - .clip(RoundedCornerShape(8.dp)) - .background( - if (canCheckNickname) FlintTheme.colors.primary400 - else FlintTheme.colors.gray700 - ) - .pressClickable( - interactionSource = checkInteractionSource, - enabled = canCheckNickname, - role = Role.Button, - ) { - keyboardController?.hide() - onCheckNickname() - } - .padding(horizontal = 16.dp, vertical = 10.dp), - contentAlignment = Alignment.Center, + .fillMaxWidth() + .height(40.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = "확인", - color = if (canCheckNickname) FlintTheme.colors.white else FlintTheme.colors.gray400, - style = if (canCheckNickname) FlintTheme.typography.body1Sb16 else FlintTheme.typography.body1M16, + FlintBasicTextField( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + placeholder = "닉네임", + value = nickname, + singleLine = true, + maxLines = 1, + maxLength = OnboardingProfileUiState.MAX_LENGTH, + onValueChange = onNicknameChange, + borderColor = if (!isFormatValid || isNicknameAvailable == false) { + FlintTheme.colors.error500 + } else { + Color.Unspecified + }, + trailingContent = { + Text( + text = "${nickname.graphemeLength}/${OnboardingProfileUiState.MAX_LENGTH}", + style = FlintTheme.typography.body1R16, + color = FlintTheme.colors.gray300, + ) + }, ) + + val checkInteractionSource = remember { MutableInteractionSource() } + + Box( + modifier = Modifier + .fillMaxHeight() + .pressScale(checkInteractionSource, enabled = canCheckNickname) + .clip(RoundedCornerShape(8.dp)) + .background( + if (canCheckNickname) FlintTheme.colors.primary400 + else FlintTheme.colors.gray700 + ) + .pressClickable( + interactionSource = checkInteractionSource, + enabled = canCheckNickname, + role = Role.Button, + ) { + keyboardController?.hide() + onCheckNickname() + } + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "확인", + color = if (canCheckNickname) FlintTheme.colors.white else FlintTheme.colors.gray400, + style = if (canCheckNickname) FlintTheme.typography.body1Sb16 else FlintTheme.typography.body1M16, + ) + } } } } diff --git a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt index 905b310b..cadd530c 100644 --- a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt @@ -1,6 +1,5 @@ package com.flint.android.presentation.onboarding -import android.content.Context import android.net.Uri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -11,18 +10,14 @@ import com.flint.android.core.navigation.Route import com.flint.android.domain.model.auth.SignupRequestModel import com.flint.android.domain.model.search.SearchContentItemModel import com.flint.android.domain.repository.AuthRepository +import com.flint.android.domain.repository.ProfileImageUploader import com.flint.android.domain.repository.SearchRepository -import com.flint.android.domain.repository.StorageRepository import com.flint.android.domain.repository.TermsRepository import com.flint.android.domain.repository.UserRepository -import com.flint.android.domain.type.FileExtension import com.flint.android.domain.type.OttType -import com.flint.android.domain.type.StoragePathType import com.flint.android.presentation.onboarding.event.OnboardingProfileEvent import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -31,18 +26,16 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject @HiltViewModel class OnboardingViewModel @Inject constructor( - @ApplicationContext private val context: Context, private val userRepository: UserRepository, private val searchRepository: SearchRepository, private val authRepository: AuthRepository, - private val storageRepository: StorageRepository, + private val profileImageUploader: ProfileImageUploader, private val termsRepository: TermsRepository, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -335,18 +328,20 @@ class OnboardingViewModel viewModelScope.launch { _signupUiState.update { it.copy(signupState = UiState.Loading) } - val profileImageUrl = uploadProfileImageIfNeeded() - + // 프로필 이미지 presigned URL 발급 API는 인증(Access Token)이 필요한데, + // 회원가입 전에는 Access Token이 없어 항상 403이 발생한다. + // 따라서 이미지 업로드는 회원가입으로 계정을 만들고 토큰을 발급받은 뒤에 진행한다. val signupRequest = SignupRequestModel( tempToken = tempToken, nickname = _uiState.value.nickname, favoriteContentIds = _contentUiState.value.selectedContents.map { it.id }, agreedTermsIds = _termsUiState.value.agreedTermsIds, - profileImageUrl = profileImageUrl, + profileImageUrl = null, ) authRepository.signup(signupRequest) .onSuccess { response -> + uploadProfileImageIfNeeded() _signupUiState.update { it.copy(signupState = UiState.Success(Unit)) } Timber.d("Signup success: userId=${response.userId}") } @@ -357,42 +352,10 @@ class OnboardingViewModel } } - private suspend fun uploadProfileImageIfNeeded(): String? { - val uri = _uiState.value.profileImageUri ?: return null - - val mimeType = withContext(Dispatchers.IO) { - context.contentResolver.getType(uri) - } ?: "image/jpeg" - val extension = mimeTypeToFileExtension(mimeType) - - val presignedUrl = storageRepository.getPresignedUrl( - pathType = StoragePathType.USER_PROFILE, - extension = extension, - ).getOrElse { error -> - Timber.e(error, "Failed to get presigned URL") - return null - } - - val imageBytes = withContext(Dispatchers.IO) { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - } ?: return null - - storageRepository.uploadToS3( - uploadUrl = presignedUrl.uploadUrl, - imageBytes = imageBytes, - mimeType = mimeType, - ).getOrElse { error -> - Timber.e(error, "Failed to upload profile image to S3") - return null - } - - return presignedUrl.key - } + private suspend fun uploadProfileImageIfNeeded() { + val uri = _uiState.value.profileImageUri ?: return - private fun mimeTypeToFileExtension(mimeType: String): FileExtension = when (mimeType) { - "image/png" -> FileExtension.PNG - "image/gif" -> FileExtension.GIF - "image/webp" -> FileExtension.WEBP - else -> FileExtension.JPEG + profileImageUploader.upload(uri) + .onFailure { error -> Timber.e(error, "Failed to update profile image after signup") } } } diff --git a/app/src/main/java/com/flint/android/presentation/profile/ProfileScreen.kt b/app/src/main/java/com/flint/android/presentation/profile/ProfileScreen.kt index d8f689dd..d6354025 100644 --- a/app/src/main/java/com/flint/android/presentation/profile/ProfileScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/profile/ProfileScreen.kt @@ -277,7 +277,7 @@ private fun ProfileScreen( Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_setting), contentDescription = "설정", - tint = FlintTheme.colors.white, + tint = FlintTheme.colors.gray200, modifier = Modifier.flintIconClickable { onSettingsClick() }, ) } diff --git a/app/src/main/java/com/flint/android/presentation/profile/ProfileViewModel.kt b/app/src/main/java/com/flint/android/presentation/profile/ProfileViewModel.kt index 9c0c21e0..95bc9c0b 100644 --- a/app/src/main/java/com/flint/android/presentation/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/profile/ProfileViewModel.kt @@ -10,6 +10,7 @@ import com.flint.android.core.navigation.Route import com.flint.android.domain.model.bookmark.BookmarkChange import com.flint.android.domain.model.user.KeywordListModel import com.flint.android.domain.repository.BookmarkRepository +import com.flint.android.domain.repository.CollectionRepository import com.flint.android.domain.repository.ContentRepository import com.flint.android.domain.repository.UserRepository import com.flint.android.presentation.profile.sideeffect.ProfileSideEffect @@ -34,6 +35,7 @@ class ProfileViewModel @Inject constructor( private val userRepository: UserRepository, private val contentRepository: ContentRepository, private val bookmarkRepository: BookmarkRepository, + private val collectionRepository: CollectionRepository, ) : ViewModel() { val userId = savedStateHandle.toRoute().userId @@ -47,47 +49,119 @@ class ProfileViewModel @Inject constructor( init { getProfile() observeBookmarkChanges() + observeCollectionDeletions() } - private fun observeBookmarkChanges() { + // 컬렉션 상세 등 다른 화면에서 컬렉션을 삭제해도 + // 내가 생성한 컬렉션 / 저장한 컬렉션 가로 리스트에 즉시 반영되도록 구독한다. + private fun observeCollectionDeletions() { viewModelScope.launch { - bookmarkRepository.bookmarkChanges.collect { change -> + collectionRepository.collectionDeletions.collect { deletedCollectionId -> _uiState.update { state -> val data = (state.sectionData as? UiState.Success)?.data ?: return@update state - val updated = when (change) { - is BookmarkChange.Content -> { - // 내 프로필 / 타 유저 공통: isBookmarked 토글만, 목록에서 제거하지 않음 - val updatedContents = data.savedContents.copy( - contents = data.savedContents.contents - .map { if (it.id == change.id) it.copy(isBookmarked = change.isBookmarked) else it } - .toPersistentList() - ) - data.copy(savedContents = updatedContents) + val updated = data.copy( + createCollections = data.createCollections.copy( + collections = data.createCollections.collections + .filter { it.id != deletedCollectionId } + .toPersistentList(), + ), + savedCollections = data.savedCollections.copy( + collections = data.savedCollections.collections + .filter { it.id != deletedCollectionId } + .toPersistentList(), + ), + ) + state.copy(sectionData = UiState.Success(updated)) + } + } + } + } + + private fun observeBookmarkChanges() { + viewModelScope.launch { + bookmarkRepository.bookmarkChanges.collect { change -> + when (change) { + is BookmarkChange.Content -> handleContentBookmarkChange(change) + is BookmarkChange.Collection -> handleCollectionBookmarkChange(change) + } + } + } + } + + private fun handleContentBookmarkChange(change: BookmarkChange.Content) { + if (userId == null && change.isBookmarked) { + // 내 프로필에서 재북마크된 경우: 취소되면서 목록에서 이미 제거된 아이템은 + // 로컬에 제목/이미지 등 정보가 남아있지 않아 그대로 되살릴 수 없다. + // 그래서 depth 0 가로 리스트에 정확한 데이터/순서로 다시 보이도록 목록 자체를 다시 불러온다. + // (다른 화면의 북마크 토글이 이 네트워크 호출 때문에 밀리지 않도록 별도 코루틴으로 처리한다.) + viewModelScope.launch { + userRepository.getUserBookmarkedContents(userId = null) + .onSuccess { refreshed -> + _uiState.update { state -> + val current = (state.sectionData as? UiState.Success)?.data ?: return@update state + state.copy(sectionData = UiState.Success(current.copy(savedContents = refreshed))) } - is BookmarkChange.Collection -> { - val updatedCollections = if (userId == null) { - // 내 프로필: 북마크 취소 시 목록에서 제거 - if (change.isBookmarked) data.savedCollections - else { - val filtered = data.savedCollections.collections - .filter { it.id != change.id } - .toPersistentList() - data.savedCollections.copy(collections = filtered) - } - } else { - // 타 유저 프로필: isBookmarked 토글만 (상대방 목록에서 제거 X) - data.savedCollections.copy( - collections = data.savedCollections.collections - .map { if (it.id == change.id) it.copy(isBookmarked = change.isBookmarked) else it } - .toPersistentList() - ) - } - data.copy(savedCollections = updatedCollections) + } + .onFailure { Timber.e(it, "Failed to reload saved contents after re-bookmark") } + } + return + } + + _uiState.update { state -> + val data = (state.sectionData as? UiState.Success)?.data ?: return@update state + val updatedContents = if (userId == null) { + // 내 프로필: 북마크 취소 시 목록에서 제거 + data.savedContents.copy( + totalCount = maxOf(0, data.savedContents.totalCount - 1), + contents = data.savedContents.contents + .filter { it.id != change.id } + .toPersistentList(), + ) + } else { + // 타 유저 프로필: isBookmarked 토글만 (상대방 목록에서 제거 X) + data.savedContents.copy( + contents = data.savedContents.contents + .map { if (it.id == change.id) it.copy(isBookmarked = change.isBookmarked) else it } + .toPersistentList() + ) + } + state.copy(sectionData = UiState.Success(data.copy(savedContents = updatedContents))) + } + } + + private fun handleCollectionBookmarkChange(change: BookmarkChange.Collection) { + if (userId == null && change.isBookmarked) { + // 콘텐츠와 동일한 이유로, 내 프로필에서 재북마크된 컬렉션도 목록을 다시 불러온다. + viewModelScope.launch { + userRepository.getUserBookmarkedCollections(userId = null) + .onSuccess { refreshed -> + _uiState.update { state -> + val current = (state.sectionData as? UiState.Success)?.data ?: return@update state + state.copy(sectionData = UiState.Success(current.copy(savedCollections = refreshed))) } } - state.copy(sectionData = UiState.Success(updated)) - } + .onFailure { Timber.e(it, "Failed to reload saved collections after re-bookmark") } + } + return + } + + _uiState.update { state -> + val data = (state.sectionData as? UiState.Success)?.data ?: return@update state + val updatedCollections = if (userId == null) { + // 내 프로필: 북마크 취소 시 목록에서 제거 + val filtered = data.savedCollections.collections + .filter { it.id != change.id } + .toPersistentList() + data.savedCollections.copy(collections = filtered) + } else { + // 타 유저 프로필: isBookmarked 토글만 (상대방 목록에서 제거 X) + data.savedCollections.copy( + collections = data.savedCollections.collections + .map { if (it.id == change.id) it.copy(isBookmarked = change.isBookmarked) else it } + .toPersistentList() + ) } + state.copy(sectionData = UiState.Success(data.copy(savedCollections = updatedCollections))) } } diff --git a/app/src/main/java/com/flint/android/presentation/profile/SavedContentScreen.kt b/app/src/main/java/com/flint/android/presentation/profile/SavedContentScreen.kt index d4916f7c..2cf552b5 100644 --- a/app/src/main/java/com/flint/android/presentation/profile/SavedContentScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/profile/SavedContentScreen.kt @@ -150,8 +150,14 @@ fun SavedContentScreen( if (uiState.contents is UiState.Success && uiState.filteredContents.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) + val displayCount = if (uiState.searchKeyword.isBlank()) { + uiState.totalCount + } else { + uiState.filteredContents.size + } + Text( - text = "총 ${uiState.filteredContents.size}개", + text = "총 ${displayCount}개", modifier = Modifier.padding(horizontal = 16.dp), color = FlintTheme.colors.gray100, style = FlintTheme.typography.body2R14, diff --git a/app/src/main/java/com/flint/android/presentation/profile/SavedContentViewModel.kt b/app/src/main/java/com/flint/android/presentation/profile/SavedContentViewModel.kt index b25b5a4c..1389669b 100644 --- a/app/src/main/java/com/flint/android/presentation/profile/SavedContentViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/profile/SavedContentViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.flint.android.core.common.util.UiState import com.flint.android.core.navigation.Route +import com.flint.android.domain.model.bookmark.BookmarkChange import com.flint.android.domain.model.bookmark.BookmarkException import com.flint.android.domain.repository.BookmarkRepository import com.flint.android.domain.repository.UserRepository @@ -40,6 +41,40 @@ class SavedContentViewModel @Inject constructor( init { loadBookmarkedContents() + observeBookmarkChanges() + } + + private fun observeBookmarkChanges() { + viewModelScope.launch { + bookmarkRepository.bookmarkChanges.collect { change -> + if (change !is BookmarkChange.Content) return@collect + + _uiState.update { state -> + val currentList = (state.contents as? UiState.Success)?.data ?: return@update state + val hasItem = currentList.contents.any { it.id == change.id } + + val updatedContents = if (hasItem) { + currentList.contents + .map { if (it.id == change.id) it.copy(isBookmarked = change.isBookmarked) else it } + .toPersistentList() + } else { + currentList.contents + } + + val updatedTotalCount = if (userId == null) { + maxOf(0, currentList.totalCount + if (change.isBookmarked) 1 else -1) + } else { + currentList.totalCount + } + + state.copy( + contents = UiState.Success( + currentList.copy(totalCount = updatedTotalCount, contents = updatedContents), + ), + ) + } + } + } } @@ -89,17 +124,6 @@ class SavedContentViewModel @Inject constructor( bookmarkRepository.toggleContentBookmark(contentId) .onSuccess { isBookmarked -> Timber.d("toggleBookmark success: isBookmarked=$isBookmarked") - _uiState.update { state -> - val currentList = (state.contents as? UiState.Success)?.data - ?: return@update state - val updated = currentList.copy( - totalCount = maxOf(0, currentList.totalCount + if (isBookmarked) 1 else -1), - contents = currentList.contents - .map { if (it.id == contentId) it.copy(isBookmarked = isBookmarked) else it } - .toPersistentList() - ) - state.copy(contents = UiState.Success(updated)) - } _sideEffect.emit(SavedContentSideEffect.ToggleBookmarkSuccess(isBookmarked)) } .onFailure { throwable -> diff --git a/app/src/main/java/com/flint/android/presentation/profile/component/CollectionCreateContentBookmark.kt b/app/src/main/java/com/flint/android/presentation/profile/component/CollectionCreateContentBookmark.kt index c66725df..08c685aa 100644 --- a/app/src/main/java/com/flint/android/presentation/profile/component/CollectionCreateContentBookmark.kt +++ b/app/src/main/java/com/flint/android/presentation/profile/component/CollectionCreateContentBookmark.kt @@ -69,6 +69,7 @@ fun CollectionCreateContentBookmark( title = title, director = director, createdYear = createdYear, + ottList = ottList, onMoreClick = onMoreClick, modifier = Modifier @@ -114,6 +115,7 @@ private fun CollectionCreateContentBookmarkInfo( title: String, director: String, createdYear: Int, + ottList: List, onMoreClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -147,9 +149,11 @@ private fun CollectionCreateContentBookmarkInfo( Spacer(modifier = Modifier.weight(1f)) - CollectionCreateContentBookmarkMore( - onClick = onMoreClick, - ) + if (ottList.isNotEmpty()) { + CollectionCreateContentBookmarkMore( + onClick = onMoreClick, + ) + } } } diff --git a/app/src/main/java/com/flint/android/presentation/profile/component/ProfileKeywordSection.kt b/app/src/main/java/com/flint/android/presentation/profile/component/ProfileKeywordSection.kt index efdf9416..13121501 100644 --- a/app/src/main/java/com/flint/android/presentation/profile/component/ProfileKeywordSection.kt +++ b/app/src/main/java/com/flint/android/presentation/profile/component/ProfileKeywordSection.kt @@ -94,10 +94,9 @@ fun ProfileKeywordSection( color = FlintTheme.colors.gray100, ) } - if (isMyProfile) { + if (isMyProfile && (isRecalculatable || isRecalculating)) { ProfileRefreshButton( onRefreshClick = onRefreshClick, - isEnabled = isRecalculatable, isRecalculating = isRecalculating, ) } @@ -129,11 +128,10 @@ fun ProfileKeywordSection( @Composable private fun ProfileRefreshButton( onRefreshClick: () -> Unit, - isEnabled: Boolean, isRecalculating: Boolean = false, modifier: Modifier = Modifier, ) { - val iconTint = if (isEnabled) FlintTheme.colors.secondary400 else FlintTheme.colors.gray400 + val iconTint = FlintTheme.colors.secondary400 val infiniteTransition = rememberInfiniteTransition(label = "refresh_rotation") val rotation by infiniteTransition.animateFloat( @@ -149,7 +147,8 @@ private fun ProfileRefreshButton( verticalArrangement = Arrangement.spacedBy(4.dp), horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.flintIconClickable( - onClick = { if (isEnabled && !isRecalculating) onRefreshClick() }, + enabled = !isRecalculating, + onClick = onRefreshClick, ), ) { Icon( diff --git a/app/src/main/java/com/flint/android/presentation/setting/editprofile/EditProfileScreen.kt b/app/src/main/java/com/flint/android/presentation/setting/editprofile/EditProfileScreen.kt index 51e51ac1..cb9f6c27 100644 --- a/app/src/main/java/com/flint/android/presentation/setting/editprofile/EditProfileScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/setting/editprofile/EditProfileScreen.kt @@ -8,14 +8,17 @@ import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets 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.imePadding +import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding @@ -27,6 +30,7 @@ import androidx.compose.runtime.LaunchedEffect 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 @@ -53,6 +57,7 @@ import com.flint.android.core.designsystem.component.topappbar.FlintBackTopAppba import com.flint.android.core.designsystem.interaction.pressClickable import com.flint.android.core.designsystem.interaction.pressScale import com.flint.android.core.designsystem.theme.FlintTheme +import kotlinx.coroutines.delay @Composable fun EditProfileRoute( @@ -83,7 +88,7 @@ fun EditProfileRoute( ) } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun EditProfileScreen( uiState: EditProfileUiState, @@ -98,7 +103,24 @@ private fun EditProfileScreen( var showToast by remember { mutableStateOf(false) } var toastMessage by remember { mutableStateOf("") } var isToastSuccess by remember { mutableStateOf(false) } - var showProfileBottomSheet by remember { mutableStateOf(false) } + var showProfileBottomSheet by rememberSaveable { mutableStateOf(false) } + var pendingBottomSheetToken by rememberSaveable { mutableStateOf(0) } + val imeVisible = WindowInsets.isImeVisible + + LaunchedEffect(pendingBottomSheetToken, imeVisible) { + val token = pendingBottomSheetToken + if (token == 0) return@LaunchedEffect + + if (imeVisible) { + // 하드웨어 키보드 등 IME 숨김 신호가 오지 않는 기기를 대비한 타임아웃. + delay(300) + } + + if (pendingBottomSheetToken == token) { + pendingBottomSheetToken = 0 + showProfileBottomSheet = true + } + } val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia() @@ -149,7 +171,14 @@ private fun EditProfileScreen( uiState.profileImageUri != null -> uiState.profileImageUri.toString() else -> uiState.profileImageUrl ?: "" }, - onEditClick = { showProfileBottomSheet = true }, + onEditClick = { + if (imeVisible) { + keyboardController?.hide() + pendingBottomSheetToken += 1 + } else { + showProfileBottomSheet = true + } + }, ) Spacer(modifier = Modifier.height(32.dp)) diff --git a/app/src/main/java/com/flint/android/presentation/setting/editprofile/EditProfileViewModel.kt b/app/src/main/java/com/flint/android/presentation/setting/editprofile/EditProfileViewModel.kt index 0cf66fd5..7d3715d0 100644 --- a/app/src/main/java/com/flint/android/presentation/setting/editprofile/EditProfileViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/setting/editprofile/EditProfileViewModel.kt @@ -1,18 +1,13 @@ package com.flint.android.presentation.setting.editprofile -import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flint.android.core.common.util.DataStoreKey.USER_NAME import com.flint.android.data.local.PreferencesManager -import com.flint.android.domain.repository.StorageRepository +import com.flint.android.domain.repository.ProfileImageUploader import com.flint.android.domain.repository.UserRepository -import com.flint.android.domain.type.FileExtension -import com.flint.android.domain.type.StoragePathType import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -21,16 +16,14 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject @HiltViewModel class EditProfileViewModel @Inject constructor( - @ApplicationContext private val context: Context, private val preferencesManager: PreferencesManager, private val userRepository: UserRepository, - private val storageRepository: StorageRepository, + private val profileImageUploader: ProfileImageUploader, ) : ViewModel() { private val _uiState = MutableStateFlow(EditProfileUiState()) @@ -119,7 +112,7 @@ class EditProfileViewModel @Inject constructor( } } state.profileImageUri != null -> { - uploadProfileImage(state.profileImageUri) + profileImageUploader.upload(state.profileImageUri) .onFailure { Timber.e(it, "Failed to save profile image") return@launch @@ -137,43 +130,4 @@ class EditProfileViewModel @Inject constructor( _navigateUp.emit(Unit) } } - - private suspend fun uploadProfileImage(uri: Uri): Result { - val mimeType = withContext(Dispatchers.IO) { - context.contentResolver.getType(uri) - } ?: "image/jpeg" - - val extension = mimeTypeToFileExtension(mimeType) - - val presignedUrl = storageRepository.getPresignedUrl( - pathType = StoragePathType.USER_PROFILE, - extension = extension, - ).getOrElse { error -> - Timber.e(error, "Failed to get presigned URL") - return Result.failure(error) - } - - val imageBytes = withContext(Dispatchers.IO) { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - } ?: return Result.failure(IllegalStateException("Failed to open image stream")) - - storageRepository.uploadToS3( - uploadUrl = presignedUrl.uploadUrl, - imageBytes = imageBytes, - mimeType = mimeType, - ).getOrElse { error -> - Timber.e(error, "Failed to upload profile image to S3") - return Result.failure(error) - } - - return userRepository.updateProfileImage(presignedUrl.key) - .map { } - } - - private fun mimeTypeToFileExtension(mimeType: String): FileExtension = when (mimeType) { - "image/png" -> FileExtension.PNG - "image/gif" -> FileExtension.GIF - "image/webp" -> FileExtension.WEBP - else -> FileExtension.JPEG - } } diff --git a/app/src/main/res/drawable/img_folder_fg.png b/app/src/main/res/drawable/img_folder_fg.png index dc732c91..c9ffa51b 100644 Binary files a/app/src/main/res/drawable/img_folder_fg.png and b/app/src/main/res/drawable/img_folder_fg.png differ