diff --git a/app/src/main/java/com/flint/core/designsystem/component/toast/ShowToast.kt b/app/src/main/java/com/flint/core/designsystem/component/toast/ShowToast.kt index 63b1dee8..8810d988 100644 --- a/app/src/main/java/com/flint/core/designsystem/component/toast/ShowToast.kt +++ b/app/src/main/java/com/flint/core/designsystem/component/toast/ShowToast.kt @@ -3,6 +3,7 @@ package com.flint.core.designsystem.component.toast import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -36,7 +37,9 @@ fun ShowToast( } Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .imePadding(), contentAlignment = Alignment.BottomCenter, ) { FlintToast( diff --git a/app/src/main/java/com/flint/core/navigation/Route.kt b/app/src/main/java/com/flint/core/navigation/Route.kt index 0eebba20..48a191ff 100644 --- a/app/src/main/java/com/flint/core/navigation/Route.kt +++ b/app/src/main/java/com/flint/core/navigation/Route.kt @@ -11,9 +11,7 @@ interface Route { data object Login : Route @Serializable - data class OnboardingProfile( - val tempToken: String - ) : Route + data object OnboardingProfile : Route @Serializable data class OnboardingGraph( diff --git a/app/src/main/java/com/flint/data/api/AuthApi.kt b/app/src/main/java/com/flint/data/api/AuthApi.kt index 14db04d7..acf48dd9 100644 --- a/app/src/main/java/com/flint/data/api/AuthApi.kt +++ b/app/src/main/java/com/flint/data/api/AuthApi.kt @@ -21,6 +21,6 @@ interface AuthApi { @Body requestDto: SocialVerifyRequestDto, ): BaseResponse - @DELETE("/api/v1/auth/withdraw") + @POST("/api/v1/auth/withdraw") suspend fun withdraw(): WithdrawResponseDto } diff --git a/app/src/main/java/com/flint/data/api/SearchApi.kt b/app/src/main/java/com/flint/data/api/SearchApi.kt index 1973e130..42e04c8c 100644 --- a/app/src/main/java/com/flint/data/api/SearchApi.kt +++ b/app/src/main/java/com/flint/data/api/SearchApi.kt @@ -17,8 +17,9 @@ interface SearchApi { @GET("/api/v1/contents/search") suspend fun getSearchContentList( @Query("keyword") keyword: String? = null, - @Query("genre") genre: String? = null, - @Query("cursor") cursor: Int = 1, + @Query("genre") genre: List? = null, + @Query("mediaType") mediaType: String? = null, + @Query("cursor") cursor: String? = null, @Query("size") size: Int = 20, ): BaseResponse } diff --git a/app/src/main/java/com/flint/data/api/StorageApi.kt b/app/src/main/java/com/flint/data/api/StorageApi.kt index ba2f88ff..a49d6151 100644 --- a/app/src/main/java/com/flint/data/api/StorageApi.kt +++ b/app/src/main/java/com/flint/data/api/StorageApi.kt @@ -1,6 +1,5 @@ package com.flint.data.api -import com.flint.data.dto.base.BaseResponse import com.flint.data.dto.storage.response.PresignedUrlResponseDto import retrofit2.http.GET import retrofit2.http.Query @@ -11,5 +10,5 @@ interface StorageApi { suspend fun getPresignedUrl( @Query("pathType") pathType: String, @Query("extension") extension: String, - ): BaseResponse + ): PresignedUrlResponseDto } diff --git a/app/src/main/java/com/flint/data/di/NetworkModule.kt b/app/src/main/java/com/flint/data/di/NetworkModule.kt index e59983c7..7aa28a74 100644 --- a/app/src/main/java/com/flint/data/di/NetworkModule.kt +++ b/app/src/main/java/com/flint/data/di/NetworkModule.kt @@ -3,6 +3,7 @@ package com.flint.data.di import com.flint.BuildConfig import com.flint.data.di.interceptor.NetworkErrorInterceptor import com.flint.data.di.interceptor.TokenInterceptor +import com.flint.data.di.qualifier.S3OkHttpClient import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import dagger.Module import dagger.Provides @@ -59,6 +60,19 @@ object NetworkModule { addInterceptor(loggingInterceptor) }.build() + @Provides + @Singleton + @S3OkHttpClient + fun provideS3OkHttpClient(loggingInterceptor: HttpLoggingInterceptor): OkHttpClient = + OkHttpClient + .Builder() + .apply { + connectTimeout(20, TimeUnit.SECONDS) + writeTimeout(60, TimeUnit.SECONDS) + readTimeout(20, TimeUnit.SECONDS) + addInterceptor(loggingInterceptor) + }.build() + @Provides @Singleton fun provideRetrofit( diff --git a/app/src/main/java/com/flint/data/di/interceptor/TokenInterceptor.kt b/app/src/main/java/com/flint/data/di/interceptor/TokenInterceptor.kt index dcd107f0..a367efcf 100644 --- a/app/src/main/java/com/flint/data/di/interceptor/TokenInterceptor.kt +++ b/app/src/main/java/com/flint/data/di/interceptor/TokenInterceptor.kt @@ -23,7 +23,8 @@ class TokenInterceptor val requestBuilder = originalRequest.newBuilder() - if (accessToken.isNotEmpty()) { + val isSearchRequest = originalRequest.url.encodedPath.endsWith("/contents/search") + if (accessToken.isNotEmpty() && !isSearchRequest) { requestBuilder.header("Authorization", "Bearer $accessToken") } diff --git a/app/src/main/java/com/flint/data/di/qualifier/S3OkHttpClient.kt b/app/src/main/java/com/flint/data/di/qualifier/S3OkHttpClient.kt new file mode 100644 index 00000000..d573f2ec --- /dev/null +++ b/app/src/main/java/com/flint/data/di/qualifier/S3OkHttpClient.kt @@ -0,0 +1,7 @@ +package com.flint.data.di.qualifier + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class S3OkHttpClient diff --git a/app/src/main/java/com/flint/data/dto/auth/request/SignupRequestDto.kt b/app/src/main/java/com/flint/data/dto/auth/request/SignupRequestDto.kt index baa840e6..0e791d13 100644 --- a/app/src/main/java/com/flint/data/dto/auth/request/SignupRequestDto.kt +++ b/app/src/main/java/com/flint/data/dto/auth/request/SignupRequestDto.kt @@ -13,4 +13,6 @@ data class SignupRequestDto( val favoriteContentIds: List, @SerialName("agreedTermsIds") val agreedTermsIds: List, + @SerialName("profileImageUrl") + val profileImageUrl: String? = null, ) diff --git a/app/src/main/java/com/flint/data/dto/search/SearchContentsResponseDto.kt b/app/src/main/java/com/flint/data/dto/search/SearchContentsResponseDto.kt index 858d6eff..e6f8acc8 100644 --- a/app/src/main/java/com/flint/data/dto/search/SearchContentsResponseDto.kt +++ b/app/src/main/java/com/flint/data/dto/search/SearchContentsResponseDto.kt @@ -32,5 +32,13 @@ data class SearchContentsResponseDto( val returned: Int? = null, @SerialName("nextCursor") val nextCursor: String? = null, + @SerialName("page") + val page: Int? = null, + @SerialName("size") + val size: Int? = null, + @SerialName("totalElements") + val totalElements: String? = null, + @SerialName("totalPages") + val totalPages: Int? = null, ) } diff --git a/app/src/main/java/com/flint/domain/mapper/auth/SignupMapper.kt b/app/src/main/java/com/flint/domain/mapper/auth/SignupMapper.kt index c7c47841..a6b0b325 100644 --- a/app/src/main/java/com/flint/domain/mapper/auth/SignupMapper.kt +++ b/app/src/main/java/com/flint/domain/mapper/auth/SignupMapper.kt @@ -11,6 +11,7 @@ fun SignupRequestModel.toDto(): SignupRequestDto = nickname = nickname, favoriteContentIds = favoriteContentIds, agreedTermsIds = agreedTermsIds, + profileImageUrl = profileImageUrl, ) fun SignupResponseDto.toModel(): SignupResponseModel = diff --git a/app/src/main/java/com/flint/domain/model/auth/SignupModel.kt b/app/src/main/java/com/flint/domain/model/auth/SignupModel.kt index 31667447..ba9b65f2 100644 --- a/app/src/main/java/com/flint/domain/model/auth/SignupModel.kt +++ b/app/src/main/java/com/flint/domain/model/auth/SignupModel.kt @@ -5,6 +5,7 @@ data class SignupRequestModel( val nickname: String, val favoriteContentIds: List, val agreedTermsIds: List, + val profileImageUrl: String? = null, ) data class SignupResponseModel( diff --git a/app/src/main/java/com/flint/domain/repository/SearchRepository.kt b/app/src/main/java/com/flint/domain/repository/SearchRepository.kt index 56c81e07..42d36b25 100644 --- a/app/src/main/java/com/flint/domain/repository/SearchRepository.kt +++ b/app/src/main/java/com/flint/domain/repository/SearchRepository.kt @@ -16,14 +16,16 @@ class SearchRepository @Inject constructor( suspend fun getSearchContentList( keyword: String? = null, - genre: String? = null, - cursor: Int = 1, + genre: List? = null, + mediaType: String? = null, + cursor: String? = null, size: Int = 20, ): Result = suspendRunCatching { apiService.getSearchContentList( keyword = keyword, genre = genre, + mediaType = mediaType, cursor = cursor, size = size, ).data.toModel() diff --git a/app/src/main/java/com/flint/domain/repository/StorageRepository.kt b/app/src/main/java/com/flint/domain/repository/StorageRepository.kt index 206636ce..ffd6988d 100644 --- a/app/src/main/java/com/flint/domain/repository/StorageRepository.kt +++ b/app/src/main/java/com/flint/domain/repository/StorageRepository.kt @@ -2,16 +2,23 @@ package com.flint.domain.repository import com.flint.core.common.util.suspendRunCatching import com.flint.data.api.StorageApi +import com.flint.data.di.qualifier.S3OkHttpClient import com.flint.domain.mapper.storage.toModel import com.flint.domain.model.storage.PresignedUrlModel import com.flint.domain.type.FileExtension import com.flint.domain.type.StoragePathType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import javax.inject.Inject class StorageRepository @Inject constructor( private val api: StorageApi, + @S3OkHttpClient private val s3Client: OkHttpClient, ) { - // Presigned URL 발급 suspend fun getPresignedUrl( pathType: StoragePathType, extension: FileExtension, @@ -20,6 +27,23 @@ class StorageRepository @Inject constructor( api.getPresignedUrl( pathType = pathType.name, extension = extension.name, - ).data.toModel() + ).toModel() } + + suspend fun uploadToS3( + uploadUrl: String, + imageBytes: ByteArray, + mimeType: String, + ): Result = suspendRunCatching { + withContext(Dispatchers.IO) { + val requestBody = imageBytes.toRequestBody(mimeType.toMediaTypeOrNull()) + val request = Request.Builder() + .url(uploadUrl) + .put(requestBody) + .build() + s3Client.newCall(request).execute().use { response -> + if (!response.isSuccessful) error("S3 upload failed: ${response.code}") + } + } + } } diff --git a/app/src/main/java/com/flint/presentation/onboarding/OnboardingProfileScreen.kt b/app/src/main/java/com/flint/presentation/onboarding/OnboardingProfileScreen.kt index 0a5bf0b5..8103c73a 100644 --- a/app/src/main/java/com/flint/presentation/onboarding/OnboardingProfileScreen.kt +++ b/app/src/main/java/com/flint/presentation/onboarding/OnboardingProfileScreen.kt @@ -16,6 +16,9 @@ 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.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -65,14 +68,18 @@ fun OnboardingProfileRoute( isFormatValid = uiState.isFormatValid, isNicknameAvailable = uiState.isNicknameAvailable, canProceed = uiState.canProceed, + canCheckNickname = uiState.canCheckNickname, hasError = uiState.hasError, errorMessage = uiState.errorMessage, + profileImageUri = uiState.profileImageUri, onNicknameChange = viewModel::updateNickname, onCheckNickname = viewModel::checkNicknameDuplication, - onClearError = viewModel::clearNicknameError, + onProfileImageSelected = viewModel::updateProfileImage, onBackClick = navigateUp, onNextClick = navigateToOnboardingContent, - modifier = Modifier.padding(paddingValues), + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues), ) } @@ -84,11 +91,13 @@ fun OnboardingProfileScreen( isFormatValid: Boolean, isNicknameAvailable: Boolean?, canProceed: Boolean, + canCheckNickname: Boolean, hasError: Boolean, errorMessage: String?, + profileImageUri: Uri?, onNicknameChange: (String) -> Unit, onCheckNickname: () -> Unit, - onClearError: () -> Unit, + onProfileImageSelected: (Uri?) -> Unit, onBackClick: () -> Unit, onNextClick: () -> Unit, modifier: Modifier = Modifier, @@ -98,12 +107,11 @@ fun OnboardingProfileScreen( var toastMessage by remember { mutableStateOf("") } var isToastSuccess by remember { mutableStateOf(false) } var showProfileBottomSheet by remember { mutableStateOf(false) } - var selectedImageUri by remember { mutableStateOf(null) } val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia() ) { uri -> - if (uri != null) selectedImageUri = uri + if (uri != null) onProfileImageSelected(uri) } LaunchedEffect(hasError, errorMessage) { @@ -127,6 +135,7 @@ fun OnboardingProfileScreen( modifier = Modifier .fillMaxSize() .background(color = FlintTheme.colors.background) + .imePadding() ) { FlintBackTopAppbar( onClick = onBackClick, @@ -140,7 +149,7 @@ fun OnboardingProfileScreen( ) { EditProfileImage( - imageUrl = selectedImageUri?.toString() ?: "", + imageUrl = profileImageUri?.toString() ?: "", onEditClick = { showProfileBottomSheet = true } ) @@ -191,10 +200,10 @@ fun OnboardingProfileScreen( .fillMaxHeight() .clip(RoundedCornerShape(8.dp)) .background( - if (isValid && isFormatValid) FlintTheme.colors.primary400 + if (canCheckNickname) FlintTheme.colors.primary400 else FlintTheme.colors.gray700 ) - .clickable(enabled = isValid && isFormatValid) { + .clickable(enabled = canCheckNickname) { keyboardController?.hide() onCheckNickname() } @@ -203,8 +212,8 @@ fun OnboardingProfileScreen( ) { Text( text = "확인", - color = if (isValid && isFormatValid) FlintTheme.colors.white else FlintTheme.colors.gray400, - style = if (isValid && isFormatValid) FlintTheme.typography.body1Sb16 else FlintTheme.typography.body1M16, + color = if (canCheckNickname) FlintTheme.colors.white else FlintTheme.colors.gray400, + style = if (canCheckNickname) FlintTheme.typography.body1Sb16 else FlintTheme.typography.body1M16, ) } } @@ -235,7 +244,7 @@ fun OnboardingProfileScreen( MenuBottomSheetData( label = "프로필 사진 삭제", color = FlintTheme.colors.error500, - clickAction = { selectedImageUri = null } + clickAction = { onProfileImageSelected(null) } ), ), onDismiss = { showProfileBottomSheet = false } @@ -250,12 +259,7 @@ fun OnboardingProfileScreen( ), paddingValues = PaddingValues.Zero, yOffset = 100.dp, - hide = { - showToast = false - if (!isToastSuccess) { - onClearError() - } - }, + hide = { showToast = false }, ) } } @@ -271,11 +275,13 @@ private fun OnboardingProfileScreenPreview() { isFormatValid = true, isNicknameAvailable = true, canProceed = true, + canCheckNickname = true, hasError = false, errorMessage = null, + profileImageUri = null, onNicknameChange = {}, onCheckNickname = {}, - onClearError = {}, + onProfileImageSelected = {}, onBackClick = {}, onNextClick = {}, ) @@ -292,11 +298,13 @@ private fun OnboardingProfileScreenDuplicateErrorPreview() { isFormatValid = true, isNicknameAvailable = false, canProceed = false, + canCheckNickname = true, hasError = true, errorMessage = "이미 사용 중인 닉네임입니다", + profileImageUri = null, onNicknameChange = {}, onCheckNickname = {}, - onClearError = {}, + onProfileImageSelected = {}, onBackClick = {}, onNextClick = {}, ) @@ -315,11 +323,13 @@ private fun OnboardingProfileScreenFormatErrorPreview() { isFormatValid = false, isNicknameAvailable = null, canProceed = false, + canCheckNickname = false, hasError = true, errorMessage = "사용할 수 없는 닉네임입니다", + profileImageUri = null, onNicknameChange = { text = it }, onCheckNickname = {}, - onClearError = {}, + onProfileImageSelected = {}, onBackClick = {}, onNextClick = {}, ) diff --git a/app/src/main/java/com/flint/presentation/onboarding/OnboardingTermsScreen.kt b/app/src/main/java/com/flint/presentation/onboarding/OnboardingTermsScreen.kt index b08b675b..2abb274a 100644 --- a/app/src/main/java/com/flint/presentation/onboarding/OnboardingTermsScreen.kt +++ b/app/src/main/java/com/flint/presentation/onboarding/OnboardingTermsScreen.kt @@ -2,6 +2,8 @@ package com.flint.presentation.onboarding import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -46,7 +48,7 @@ import com.flint.domain.model.terms.TermModel fun OnboardingTermsRoute( paddingValues: PaddingValues, navigateUp: () -> Unit, - navigateToOnboardingContent: () -> Unit, + navigateToOnboardingProfile: () -> Unit, viewModel: OnboardingViewModel, ) { val termsUiState by viewModel.termsUiState.collectAsStateWithLifecycle() @@ -60,7 +62,7 @@ fun OnboardingTermsRoute( onBackClick = navigateUp, onAgreeClick = { agreedIds -> viewModel.agreeToTerms(agreedIds) - navigateToOnboardingContent() + navigateToOnboardingProfile() }, modifier = Modifier.padding(paddingValues), ) @@ -161,7 +163,11 @@ fun OnboardingTermsScreen( } else -> { - Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { Spacer(Modifier.height(16.dp)) terms.forEachIndexed { index, term -> diff --git a/app/src/main/java/com/flint/presentation/onboarding/OnboardingUiState.kt b/app/src/main/java/com/flint/presentation/onboarding/OnboardingUiState.kt index b19cbbb8..e66dddb7 100644 --- a/app/src/main/java/com/flint/presentation/onboarding/OnboardingUiState.kt +++ b/app/src/main/java/com/flint/presentation/onboarding/OnboardingUiState.kt @@ -1,5 +1,6 @@ package com.flint.presentation.onboarding +import android.net.Uri import com.flint.core.common.util.UiState import com.flint.domain.model.search.SearchContentItemModel import com.flint.domain.model.terms.TermModel @@ -23,17 +24,22 @@ data class OnboardingProfileUiState( val isFormatValid: Boolean = true, val isNicknameAvailable: Boolean? = null, val nicknameErrorType: NicknameErrorType? = null, + val profileImageUri: Uri? = null, ) { companion object { const val MAX_LENGTH = 8 const val MIN_LENGTH = 2 - private val NICKNAME_REGEX = Regex("^[가-힣a-zA-Z]+$") + private val NICKNAME_REGEX = Regex("^[가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z]+$") + private val STANDALONE_KOREAN_REGEX = Regex("[ㄱ-ㅎㅏ-ㅣ]") fun isValidFormat(nickname: String): Boolean { return nickname.isEmpty() || NICKNAME_REGEX.matches(nickname) } } + val hasStandaloneKorean: Boolean + get() = STANDALONE_KOREAN_REGEX.containsMatchIn(nickname) + val hasError: Boolean get() = nicknameErrorType != null @@ -44,9 +50,12 @@ data class OnboardingProfileUiState( null -> null } + val canCheckNickname: Boolean + get() = isValid && isFormatValid && !hasStandaloneKorean + //다음단계 활성화 val canProceed: Boolean - get() = isValid && isFormatValid && isNicknameAvailable == true + get() = isValid && isFormatValid && isNicknameAvailable == true && !hasStandaloneKorean } data class OnboardingContentUiState( diff --git a/app/src/main/java/com/flint/presentation/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/flint/presentation/onboarding/OnboardingViewModel.kt index e0146f71..5f1206f1 100644 --- a/app/src/main/java/com/flint/presentation/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/com/flint/presentation/onboarding/OnboardingViewModel.kt @@ -1,5 +1,7 @@ package com.flint.presentation.onboarding +import android.content.Context +import android.net.Uri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -7,29 +9,37 @@ import androidx.navigation.toRoute import com.flint.core.common.util.UiState import com.flint.core.navigation.Route import com.flint.domain.model.auth.SignupRequestModel +import com.flint.domain.model.search.SearchContentItemModel import com.flint.domain.repository.AuthRepository import com.flint.domain.repository.SearchRepository +import com.flint.domain.repository.StorageRepository import com.flint.domain.repository.TermsRepository import com.flint.domain.repository.UserRepository +import com.flint.domain.type.FileExtension import com.flint.domain.type.OttType +import com.flint.domain.type.StoragePathType 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.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject -import com.flint.domain.model.search.SearchContentItemModel @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 termsRepository: TermsRepository, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -86,11 +96,7 @@ class OnboardingViewModel isValid = nickname.length >= OnboardingProfileUiState.MIN_LENGTH, isFormatValid = isFormatValid, isNicknameAvailable = null, - nicknameErrorType = when { - !isFormatValid && nickname.isNotEmpty() -> NicknameErrorType.INVALID_FORMAT - currentState.nicknameErrorType == NicknameErrorType.DUPLICATE -> NicknameErrorType.DUPLICATE - else -> null - }, + nicknameErrorType = if (!isFormatValid && nickname.isNotEmpty()) NicknameErrorType.INVALID_FORMAT else null, ) } } @@ -126,6 +132,10 @@ class OnboardingViewModel } } + fun updateProfileImage(uri: Uri?) { + _uiState.update { it.copy(profileImageUri = uri) } + } + // ---------- onboarding content ---------- fun updateSearchKeyword(keyword: String) { _contentUiState.update { currentState -> @@ -159,7 +169,7 @@ class OnboardingViewModel searchJob = viewModelScope.launch { _contentUiState.update { it.copy(searchResults = UiState.Loading) } - val genreApiValue = genre?.let { OnboardingContentUiState.GENRES[it] } + val genreApiValue = genre?.let { key -> OnboardingContentUiState.GENRES[key]?.let { listOf(it) } } searchRepository.getSearchContentList( keyword = keyword, @@ -218,11 +228,14 @@ class OnboardingViewModel viewModelScope.launch { _signupUiState.update { it.copy(signupState = UiState.Loading) } + val profileImageUrl = uploadProfileImageIfNeeded() + val signupRequest = SignupRequestModel( tempToken = tempToken, nickname = _uiState.value.nickname, favoriteContentIds = _contentUiState.value.selectedContents.map { it.id }, agreedTermsIds = _termsUiState.value.agreedTermsIds, + profileImageUrl = profileImageUrl, ) authRepository.signup(signupRequest) @@ -236,4 +249,43 @@ 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 fun mimeTypeToFileExtension(mimeType: String): FileExtension = when (mimeType) { + "image/png" -> FileExtension.PNG + "image/gif" -> FileExtension.GIF + "image/webp" -> FileExtension.WEBP + else -> FileExtension.JPEG + } } \ No newline at end of file diff --git a/app/src/main/java/com/flint/presentation/onboarding/navigation/OnboardingNavigation.kt b/app/src/main/java/com/flint/presentation/onboarding/navigation/OnboardingNavigation.kt index c5533b7f..37dcc886 100644 --- a/app/src/main/java/com/flint/presentation/onboarding/navigation/OnboardingNavigation.kt +++ b/app/src/main/java/com/flint/presentation/onboarding/navigation/OnboardingNavigation.kt @@ -27,6 +27,10 @@ fun NavController.navigateToOnboardingTerms() { navigate(Route.OnboardingTerms) } +fun NavController.navigateToOnboardingProfile() { + navigate(Route.OnboardingProfile) +} + fun NavController.navigateToOnboardingContent() { navigate(Route.OnboardingContent) @@ -46,7 +50,7 @@ fun NavGraphBuilder.onBoardingNavGraph( navController: NavHostController, ) { navigation( - startDestination = Route.OnboardingProfile::class, + startDestination = Route.OnboardingTerms::class, ) { composable { backStackEntry -> val sharedViewModel = backStackEntry.sharedViewModel(navController) @@ -54,7 +58,7 @@ fun NavGraphBuilder.onBoardingNavGraph( OnboardingTermsRoute( paddingValues = paddingValues, navigateUp = navController::navigateUp, - navigateToOnboardingContent = navController::navigateToOnboardingContent, + navigateToOnboardingProfile = navController::navigateToOnboardingProfile, viewModel = sharedViewModel, ) } @@ -65,9 +69,9 @@ fun NavGraphBuilder.onBoardingNavGraph( OnboardingProfileRoute( paddingValues = paddingValues, navigateUp = navController::navigateUp, - navigateToOnboardingContent = navController::navigateToOnboardingTerms, + navigateToOnboardingContent = navController::navigateToOnboardingContent, viewModel = sharedViewModel, - ) + ) } composable { backStackEntry -> diff --git a/app/src/main/java/com/flint/presentation/splash/SplashScreen.kt b/app/src/main/java/com/flint/presentation/splash/SplashScreen.kt index cfbf7f8d..21f9c441 100644 --- a/app/src/main/java/com/flint/presentation/splash/SplashScreen.kt +++ b/app/src/main/java/com/flint/presentation/splash/SplashScreen.kt @@ -18,7 +18,11 @@ import com.airbnb.lottie.compose.rememberLottieComposition import com.flint.R import com.flint.core.designsystem.theme.FlintTheme import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.delay +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue @Composable fun SplashRoute( @@ -27,12 +31,27 @@ fun SplashRoute( navigateToHome: () -> Unit, viewModel: SplashViewModel = hiltViewModel(), ) { + val autoLoginState by viewModel.autoLoginState.collectAsStateWithLifecycle() + var isAnimationFinished by remember { mutableStateOf(false) } + SplashScreen( - onAnimationFinished = navigateToLogin, + onAnimationFinished = { isAnimationFinished = true }, ) + + // 애니메이션이 완료되지 않을 경우를 대비한 fallback LaunchedEffect(Unit) { delay(2000) - navigateToLogin() + isAnimationFinished = true + } + + LaunchedEffect(isAnimationFinished, autoLoginState) { + if (isAnimationFinished && autoLoginState != AutoLoginState.Loading) { + when (autoLoginState) { + AutoLoginState.NavigateToHome -> navigateToHome() + AutoLoginState.NavigateToLogin -> navigateToLogin() + AutoLoginState.Loading -> Unit + } + } } } diff --git a/app/src/main/java/com/flint/presentation/splash/SplashViewModel.kt b/app/src/main/java/com/flint/presentation/splash/SplashViewModel.kt index 9245b0d1..f2b803eb 100644 --- a/app/src/main/java/com/flint/presentation/splash/SplashViewModel.kt +++ b/app/src/main/java/com/flint/presentation/splash/SplashViewModel.kt @@ -3,24 +3,43 @@ package com.flint.presentation.splash import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flint.core.common.util.DataStoreKey.ACCESS_TOKEN -import com.flint.core.common.util.DataStoreKey.USER_ID -import com.flint.core.common.util.DataStoreKey.USER_NAME import com.flint.data.local.PreferencesManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject +sealed interface AutoLoginState { + data object Loading : AutoLoginState + data object NavigateToHome : AutoLoginState + data object NavigateToLogin : AutoLoginState +} + @HiltViewModel class SplashViewModel @Inject constructor( - private val preferencesManager: PreferencesManager, + private val preferencesManager: PreferencesManager, ) : ViewModel() { + + private val _autoLoginState = MutableStateFlow(AutoLoginState.Loading) + val autoLoginState: StateFlow = _autoLoginState.asStateFlow() + init { -// saveTempUserData() + checkAutoLogin() } - fun saveTempUserData() = viewModelScope.launch { - preferencesManager.saveString(ACCESS_TOKEN, "eyJhbGciOiJIUzUxMiJ9.eyJ1c2VySWQiOjgwMDAyMTcxMTM1NzExODE5MSwicm9sZSI6IkZMSU5HIiwidHlwZSI6IkFDQ0VTUyIsImlhdCI6MTc2ODgzODU0MCwiZXhwIjoxNzcwMDQ4MTQwfQ.A2XYyu24IoGAXNSQHJ1S-iudWmg8II2_ivI4EdyyWw9KS9oJlxHKOAhcKrsLpLkc9kllZyxwaTJO1t4vI7oZlg") - preferencesManager.saveString(USER_ID, "800021711357118191") - preferencesManager.saveString(USER_NAME, "hojoo") + private fun checkAutoLogin() = viewModelScope.launch { + _autoLoginState.value = try { + val accessToken = preferencesManager.getString(ACCESS_TOKEN).first() + if (accessToken.isNotEmpty()) { + AutoLoginState.NavigateToHome + } else { + AutoLoginState.NavigateToLogin + } + } catch (e: Exception) { + AutoLoginState.NavigateToLogin + } } }