diff --git a/app/src/main/java/com/example/hangsha_android/ui/HangshaApp.kt b/app/src/main/java/com/example/hangsha_android/ui/HangshaApp.kt index 0850b81..cd410ce 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/HangshaApp.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/HangshaApp.kt @@ -16,8 +16,14 @@ fun HangshaApp() { val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination - val showBottomBar = + val isOnboardingInterestPriority = + currentDestination?.route == HangshaDestinations.InterestPriority.route && + navBackStackEntry?.arguments + ?.getString(HangshaDestinations.InterestPriority.sourceArg) == + HangshaDestinations.InterestPriority.sourceOnboarding + val isMainGraphDestination = currentDestination?.hierarchy?.any { it.route == HangshaDestinations.Main.route } == true + val showBottomBar = isMainGraphDestination && !isOnboardingInterestPriority Scaffold( containerColor = MaterialTheme.colorScheme.background, @@ -43,4 +49,4 @@ fun HangshaApp() { innerPadding = innerPadding ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt index 80e74ea..5842b1d 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt @@ -53,6 +53,7 @@ import com.example.hangsha_android.ui.view.mymemos.MyMemosScreen import com.example.hangsha_android.ui.view.mymemos.MyMemosViewModel import com.example.hangsha_android.ui.view.onboarding.OnboardingScreen import com.example.hangsha_android.ui.view.onboarding.OnboardingViewModel +import com.example.hangsha_android.ui.view.onboarding.OnboardingWelcomeScreen import com.example.hangsha_android.ui.view.signup.SignUpScreen import com.example.hangsha_android.ui.view.signup.SignUpViewModel import com.google.android.gms.auth.api.signin.GoogleSignIn @@ -64,8 +65,18 @@ sealed class HangshaDestinations(val route: String) { data object CredentialLogin : HangshaDestinations("credential_login") data object SignUp : HangshaDestinations("sign_up") data object Onboarding : HangshaDestinations("onboarding") + data object OnboardingWelcome : HangshaDestinations("onboarding_welcome") data object Main : HangshaDestinations("main") - data object InterestPriority : HangshaDestinations("interest_priority") + data object InterestPriority : HangshaDestinations("interest_priority?source={source}") { + const val baseRoute = "interest_priority" + const val sourceArg = "source" + const val sourceMyPage = "mypage" + const val sourceOnboarding = "onboarding" + + fun createRoute(source: String = sourceMyPage): String { + return "$baseRoute?$sourceArg=$source" + } + } data object MyBookmarks : HangshaDestinations("my_bookmarks") data object MyMemos : HangshaDestinations("my_memos") data object DailyEvents : HangshaDestinations("daily_events/{date}") { @@ -254,22 +265,50 @@ fun NavGraphBuilder.onboardingGraph(navController: NavHostController) { onboardingViewModel.onOnboardingMessageConsumed() } - LaunchedEffect(onboardingUiState.isUsernameSaved) { - if (!onboardingUiState.isUsernameSaved) { + LaunchedEffect(onboardingUiState.isProfileSaved) { + if (!onboardingUiState.isProfileSaved) { return@LaunchedEffect } - // TODO(ONBOARDING): Add the remaining onboarding steps after username setup. - navController.navigate(HangshaDestinations.Main.route) { - popUpTo(HangshaDestinations.Onboarding.route) { inclusive = true } - } - onboardingViewModel.onUsernameSavedConsumed() + navController.navigate( + HangshaDestinations.InterestPriority.createRoute( + source = HangshaDestinations.InterestPriority.sourceOnboarding + ) + ) + onboardingViewModel.onProfileSavedConsumed() } OnboardingScreen( uiState = onboardingUiState, onUsernameChanged = { value -> onboardingViewModel.onUsernameChanged(value) }, - onContinueClick = { onboardingViewModel.saveUsername() } + onProfileImageSelected = { uri -> + onboardingViewModel.onProfileImageSelected(uri) + }, + onProfileImageDeleted = { + onboardingViewModel.markProfileImageDeleted() + }, + onContinueClick = { onboardingViewModel.saveProfile() } + ) + } + + composable(HangshaDestinations.OnboardingWelcome.route) { + OnboardingWelcomeScreen( + onMyPageClick = { + navController.navigate(BottomTab.MyPage.route) { + popUpTo(HangshaDestinations.OnboardingWelcome.route) { + inclusive = true + } + launchSingleTop = true + } + }, + onCalendarClick = { + navController.navigate(HangshaDestinations.Main.route) { + popUpTo(HangshaDestinations.OnboardingWelcome.route) { + inclusive = true + } + launchSingleTop = true + } + } ) } } @@ -531,20 +570,41 @@ fun NavGraphBuilder.mainGraph(navController: NavHostController) { onRetryClick = { myMemosViewModel.loadMemos() } ) } - composable(HangshaDestinations.InterestPriority.route) { + composable( + route = HangshaDestinations.InterestPriority.route, + arguments = listOf( + navArgument(HangshaDestinations.InterestPriority.sourceArg) { + type = NavType.StringType + defaultValue = HangshaDestinations.InterestPriority.sourceMyPage + } + ) + ) { backStackEntry -> val interestPriorityViewModel: InterestPriorityViewModel = hiltViewModel() val interestPriorityUiState by interestPriorityViewModel.uiState.collectAsState() + val source = backStackEntry.arguments + ?.getString(HangshaDestinations.InterestPriority.sourceArg) + ?: HangshaDestinations.InterestPriority.sourceMyPage + val isOnboardingFlow = + source == HangshaDestinations.InterestPriority.sourceOnboarding - LaunchedEffect(interestPriorityUiState.isSaveSuccessful) { + LaunchedEffect(interestPriorityUiState.isSaveSuccessful, isOnboardingFlow) { if (!interestPriorityUiState.isSaveSuccessful) { return@LaunchedEffect } - navController.previousBackStackEntry?.savedStateHandle?.set( - InterestPriorityNavigationKeys.updatedKey, - true - ) - navController.popBackStack() + if (isOnboardingFlow) { + navController.navigate(HangshaDestinations.OnboardingWelcome.route) { + popUpTo(HangshaDestinations.Onboarding.route) { + inclusive = true + } + } + } else { + navController.previousBackStackEntry?.savedStateHandle?.set( + InterestPriorityNavigationKeys.updatedKey, + true + ) + navController.popBackStack() + } interestPriorityViewModel.onSaveSuccessConsumed() } @@ -627,7 +687,7 @@ fun NavGraphBuilder.mainGraph(navController: NavHostController) { }, onSaveProfileEdit = { myPageViewModel.saveProfileEdit() }, onInterestPriorityClick = { - navController.navigate(HangshaDestinations.InterestPriority.route) + navController.navigate(HangshaDestinations.InterestPriority.createRoute()) }, onTimetableClick = { navController.navigate(BottomTab.Timetable.route) { diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingScreen.kt index 0c00ba6..0c3a8a9 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingScreen.kt @@ -1,7 +1,11 @@ package com.example.hangsha_android.ui.view.onboarding +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -15,14 +19,20 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.PhotoCamera import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType @@ -30,8 +40,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.example.hangsha_android.R private val OnboardingContentWidth = 280.dp +private val OnboardingAvatarContainerSize = 190.dp +private val OnboardingAvatarImageSize = 184.dp +private val OnboardingDeleteButtonSize = 42.dp +private val OnboardingCameraButtonSize = 46.dp private val OnboardingFieldHeight = 37.dp private val OnboardingButtonHeight = 37.dp private val OnboardingRoundShape = CircleShape @@ -40,13 +56,26 @@ private val OnboardingWhite = Color(0xFFFFFFFF) private val OnboardingBorder = Color(0xFFE0E0E0) private val OnboardingPlaceholder = Color(0xFF8F8F8F) private val OnboardingError = Color(0xFFFF4058) +private val OnboardingAvatarBackground = Color(0xFFD9D9D9) +private val OnboardingCameraIcon = Color(0xFF8F8F8F) @Composable fun OnboardingScreen( uiState: OnboardingUiState, onUsernameChanged: (String) -> Unit, + onProfileImageSelected: (Uri) -> Unit, + onProfileImageDeleted: () -> Unit, onContinueClick: () -> Unit ) { + val profileImagePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + onProfileImageSelected(uri) + } + } + val profileImageModel = uiState.profileImageUri ?: uiState.profileImageUrl + Box( modifier = Modifier .fillMaxSize() @@ -60,7 +89,7 @@ fun OnboardingScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "사용자명 설정", + text = "프로필 설정", color = OnboardingBlack, fontSize = 23.sp, fontWeight = FontWeight.Bold, @@ -68,7 +97,7 @@ fun OnboardingScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "행샤에서 사용할 이름을 입력해주세요", + text = "프로필 사진과 이름을 설정해주세요", color = OnboardingBlack, fontSize = 14.sp, fontWeight = FontWeight.Normal, @@ -76,10 +105,16 @@ fun OnboardingScreen( textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(46.dp)) + OnboardingProfileImage( + profileImageModel = profileImageModel, + onPickImageClick = { profileImagePicker.launch("image/*") }, + onDeleteImageClick = onProfileImageDeleted + ) + Spacer(modifier = Modifier.height(18.dp)) OnboardingTextField( value = uiState.username, onValueChange = onUsernameChanged, - placeholder = "사용자명", + placeholder = "닉네임", isError = uiState.usernameErrorMessage != null ) uiState.usernameErrorMessage?.let { message -> @@ -97,7 +132,73 @@ fun OnboardingScreen( OnboardingSubmitButton( onClick = onContinueClick, enabled = uiState.isSubmitEnabled, - isLoading = uiState.isSavingUsername + isLoading = uiState.isSavingProfile + ) + } + } +} + +@Composable +private fun OnboardingProfileImage( + profileImageModel: Any?, + onPickImageClick: () -> Unit, + onDeleteImageClick: () -> Unit +) { + val hasProfileImage = profileImageModel.hasRealProfileImage() + val imageModel = if (hasProfileImage) profileImageModel else R.drawable.profile_null_img + val canDeleteProfileImage = profileImageModel.isUserProfileImage() + + Box(modifier = Modifier.size(OnboardingAvatarContainerSize)) { + Box( + modifier = Modifier + .align(Alignment.Center) + .size(OnboardingAvatarImageSize) + .clip(CircleShape) + .background(OnboardingAvatarBackground), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = imageModel, + contentDescription = "프로필 이미지", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + + if (canDeleteProfileImage) { + Box( + modifier = Modifier + .align(Alignment.TopStart) + .size(OnboardingDeleteButtonSize) + .clip(CircleShape) + .background(OnboardingError) + .clickable(onClick = onDeleteImageClick), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = "프로필 이미지 삭제", + tint = OnboardingWhite, + modifier = Modifier.size(30.dp) + ) + } + } + + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(OnboardingCameraButtonSize) + .clip(CircleShape) + .background(OnboardingWhite) + .border(2.dp, OnboardingBorder, CircleShape) + .clickable(onClick = onPickImageClick), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.PhotoCamera, + contentDescription = "프로필 이미지 선택", + tint = OnboardingCameraIcon, + modifier = Modifier.size(28.dp) ) } } @@ -174,7 +275,7 @@ private fun OnboardingSubmitButton( OnboardingProgressIndicator(size = 16.dp) } else { Text( - text = "계속", + text = "프로필 설정하기", color = OnboardingWhite, fontSize = 14.sp, fontWeight = FontWeight.Medium, @@ -193,3 +294,34 @@ private fun OnboardingProgressIndicator(size: Dp) { strokeWidth = 2.dp ) } + +private fun Any?.hasRealProfileImage(): Boolean { + return when (this) { + is Uri -> true + is String -> isRealProfileImageUrl() + else -> false + } +} + +private fun Any?.isUserProfileImage(): Boolean { + return when (this) { + is Uri -> true + is String -> isRealProfileImageUrl() && !isDefaultProfileImageUrl() + else -> false + } +} + +private fun String?.isRealProfileImageUrl(): Boolean { + val normalized = this?.trim().orEmpty() + return normalized.isNotBlank() && + !normalized.equals("null", ignoreCase = true) && + (normalized.startsWith("http://") || + normalized.startsWith("https://") || + normalized.startsWith("content://") || + normalized.startsWith("file://")) +} + +private fun String?.isDefaultProfileImageUrl(): Boolean { + val normalized = this?.trim().orEmpty() + return normalized.contains("/default/43513b43-2f84-4f0f-8de8-7d61120fe3aa.png") +} diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingUiState.kt b/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingUiState.kt index dcd35f5..5e17436 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingUiState.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingUiState.kt @@ -1,14 +1,19 @@ package com.example.hangsha_android.ui.view.onboarding +import android.net.Uri + data class OnboardingUiState( val username: String = "", + val profileImageUrl: String? = null, + val profileImageUri: Uri? = null, + val isProfileImageMarkedForDeletion: Boolean = false, val usernameErrorMessage: String? = null, val onboardingMessage: String? = null, - val isSavingUsername: Boolean = false, - val isUsernameSaved: Boolean = false + val isSavingProfile: Boolean = false, + val isProfileSaved: Boolean = false ) { val isSubmitEnabled: Boolean get() = username.isNotBlank() && usernameErrorMessage == null && - !isSavingUsername + !isSavingProfile } diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingViewModel.kt index b47876f..9ead613 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingViewModel.kt @@ -1,9 +1,13 @@ package com.example.hangsha_android.ui.view.onboarding +import android.content.Context +import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.hangsha_android.data.repository.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File import java.io.IOException import java.net.SocketTimeoutException import java.net.UnknownHostException @@ -17,7 +21,8 @@ import retrofit2.HttpException @HiltViewModel class OnboardingViewModel @Inject constructor( - private val userRepository: UserRepository + private val userRepository: UserRepository, + @param:ApplicationContext private val appContext: Context ) : ViewModel() { private val _uiState = MutableStateFlow(OnboardingUiState()) @@ -29,13 +34,40 @@ class OnboardingViewModel @Inject constructor( username = username, usernameErrorMessage = validateUsername(username), onboardingMessage = null, - isUsernameSaved = false + isProfileSaved = false ) } } - fun saveUsername() { + fun onProfileImageSelected(uri: Uri) { + _uiState.update { + it.copy( + profileImageUri = uri, + isProfileImageMarkedForDeletion = false, + onboardingMessage = null, + isProfileSaved = false + ) + } + } + + fun markProfileImageDeleted() { + _uiState.update { + it.copy( + profileImageUrl = null, + profileImageUri = null, + isProfileImageMarkedForDeletion = true, + onboardingMessage = null, + isProfileSaved = false + ) + } + } + + fun saveProfile() { val currentState = _uiState.value + if (currentState.isSavingProfile) { + return + } + val usernameError = validateUsername(currentState.username) if (usernameError != null) { _uiState.update { it.copy(usernameErrorMessage = usernameError) } @@ -45,33 +77,57 @@ class OnboardingViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy( - isSavingUsername = true, + isSavingProfile = true, onboardingMessage = null, - isUsernameSaved = false + isProfileSaved = false ) } runCatching { - val response = userRepository.updateMyUsername(currentState.username) + val shouldUpdateProfileImage = currentState.profileImageUri != null || + currentState.isProfileImageMarkedForDeletion + val profileImageUrl = when { + currentState.profileImageUri != null -> + uploadProfileImage(currentState.profileImageUri) + currentState.isProfileImageMarkedForDeletion -> null + else -> currentState.profileImageUrl + } + val response = if (shouldUpdateProfileImage) { + userRepository.updateMyProfile( + username = currentState.username, + profileImageUrl = profileImageUrl + ) + } else { + userRepository.updateMyUsername(currentState.username) + } if (!response.isSuccessful) { throw HttpException(response) } + val updatedProfile = response.body() + SavedOnboardingProfile( + username = updatedProfile?.username ?: currentState.username.trim(), + profileImageUrl = updatedProfile?.profileImageUrl ?: profileImageUrl + ) }.fold( - onSuccess = { + onSuccess = { profile -> _uiState.update { it.copy( - username = currentState.username.trim(), - isSavingUsername = false, - isUsernameSaved = true, - usernameErrorMessage = null + username = profile.username, + profileImageUrl = profile.profileImageUrl, + profileImageUri = null, + isProfileImageMarkedForDeletion = false, + isSavingProfile = false, + isProfileSaved = true, + usernameErrorMessage = null, + onboardingMessage = null ) } }, onFailure = { error -> _uiState.update { it.copy( - isSavingUsername = false, - isUsernameSaved = false, + isSavingProfile = false, + isProfileSaved = false, onboardingMessage = mapOnboardingErrorMessage(error) ) } @@ -86,10 +142,46 @@ class OnboardingViewModel @Inject constructor( } } - fun onUsernameSavedConsumed() { + fun onProfileSavedConsumed() { _uiState.update { - it.copy(isUsernameSaved = false) + it.copy(isProfileSaved = false) + } + } + + private suspend fun uploadProfileImage(uri: Uri): String { + val imageFile = copyUriToCacheFile(uri) + val mimeType = appContext.contentResolver.getType(uri) + val response = userRepository.uploadMyProfileImage( + imageFile = imageFile, + mimeType = mimeType + ) + if (!response.isSuccessful) { + throw HttpException(response) + } + + val url = response.body()?.url + require(!url.isNullOrBlank()) { + "Profile image upload response was empty." + } + return url + } + + private fun copyUriToCacheFile(uri: Uri): File { + val extension = when (appContext.contentResolver.getType(uri)) { + "image/png" -> ".png" + "image/webp" -> ".webp" + else -> ".jpg" } + val file = File.createTempFile("profile-image-", extension, appContext.cacheDir) + appContext.contentResolver.openInputStream(uri).use { input -> + requireNotNull(input) { + "Could not open selected image." + } + file.outputStream().use { output -> + input.copyTo(output) + } + } + return file } private fun validateUsername(username: String): String? { @@ -112,19 +204,19 @@ class OnboardingViewModel @Inject constructor( private fun mapOnboardingErrorMessage(error: Throwable): String { return when (error) { - is IllegalArgumentException -> error.message ?: "사용자 이름을 확인해 주세요." + is IllegalArgumentException -> error.message ?: "프로필 입력값을 확인해 주세요." is UnknownHostException -> "인터넷 연결을 확인해 주세요." is SocketTimeoutException -> "요청 시간이 초과되었습니다. 다시 시도해 주세요." is HttpException -> when (error.code()) { - 400 -> "사용자 이름을 확인해 주세요." + 400 -> "프로필 정보를 확인해 주세요." 401 -> "로그인이 필요합니다." - 403 -> "사용자 이름을 수정할 권한이 없습니다." + 403 -> "프로필을 수정할 권한이 없습니다." 409 -> "이미 사용 중인 사용자 이름입니다." in 500..599 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." - else -> "사용자 이름 저장에 실패했습니다. (${error.code()})" + else -> "프로필 저장에 실패했습니다. (${error.code()})" } is IOException -> "네트워크 오류가 발생했습니다. 다시 시도해 주세요." - else -> error.message ?: "사용자 이름 저장에 실패했습니다." + else -> error.message ?: "프로필 저장에 실패했습니다." } } } @@ -137,3 +229,8 @@ private fun Char.isKorean(): Boolean { private const val ENGLISH_USERNAME_MAX_LENGTH = 20 private const val KOREAN_USERNAME_MAX_LENGTH = 10 + +private data class SavedOnboardingProfile( + val username: String, + val profileImageUrl: String? +) diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingWelcomeScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingWelcomeScreen.kt new file mode 100644 index 0000000..cb11cf8 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingWelcomeScreen.kt @@ -0,0 +1,188 @@ +package com.example.hangsha_android.ui.view.onboarding + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +@Composable +fun OnboardingWelcomeScreen( + onMyPageClick: () -> Unit, + onCalendarClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color(0xFFBFEFF8), + Color(0xFFFFFFFF) + ) + ) + ) + ) { + WelcomeStars() + + Column( + modifier = Modifier + .align(Alignment.Center) + .offset(y = (-26).dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "환영합니다!", + color = Color(0xFF000000), + fontSize = 29.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.sp + ) + Spacer(modifier = Modifier.height(46.dp)) + WelcomeActionButton( + text = "마이페이지", + width = 138.dp, + onClick = onMyPageClick + ) + Spacer(modifier = Modifier.height(9.dp)) + WelcomeActionButton( + text = "캘린더로 가기", + width = 166.dp, + onClick = onCalendarClick + ) + } + } +} + +@Composable +private fun WelcomeStars() { + val stars = listOf( + WelcomeStar(x = 0.25f, y = 0.28f, radiusDp = 30f, filled = true, alpha = 0.92f), + WelcomeStar(x = 0.77f, y = 0.49f, radiusDp = 40f, filled = false, alpha = 0.55f), + WelcomeStar(x = 0.77f, y = 0.33f, radiusDp = 21f, filled = false, alpha = 0.75f), + WelcomeStar(x = 0.49f, y = 0.56f, radiusDp = 22f, filled = true, alpha = 0.86f), + WelcomeStar(x = 0.57f, y = 0.20f, radiusDp = 12f, filled = true, alpha = 0.82f), + WelcomeStar(x = 0.27f, y = 0.42f, radiusDp = 13f, filled = false, alpha = 0.40f), + WelcomeStar(x = 0.06f, y = 0.46f, radiusDp = 20f, filled = false, alpha = 0.78f), + WelcomeStar(x = 0.86f, y = 0.28f, radiusDp = 10f, filled = true, alpha = 0.95f), + WelcomeStar(x = 0.16f, y = 0.67f, radiusDp = 12f, filled = false, alpha = 0.56f), + WelcomeStar(x = 0.72f, y = 0.70f, radiusDp = 11f, filled = true, alpha = 0.82f) + ) + + Canvas(modifier = Modifier.fillMaxSize()) { + stars.forEach { star -> + drawWelcomeStar(star) + } + } +} + +@Composable +private fun WelcomeActionButton( + text: String, + width: Dp, + onClick: () -> Unit +) { + Surface( + onClick = onClick, + modifier = Modifier + .width(width) + .height(35.dp), + shape = CircleShape, + color = Color(0xFFFFFFFF), + contentColor = Color(0xFF6B6B6B), + shadowElevation = 5.dp + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = text, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.sp + ) + } + } +} + +private fun DrawScope.drawWelcomeStar(star: WelcomeStar) { + val center = Offset(size.width * star.x, size.height * star.y) + val outerRadius = star.radiusDp.dp.toPx() + val innerRadius = outerRadius * 0.42f + val path = starPath( + center = center, + outerRadius = outerRadius, + innerRadius = innerRadius, + rotationDegrees = star.rotationDegrees + ) + val color = Color.White.copy(alpha = star.alpha) + + if (star.filled) { + drawPath(path = path, color = color, style = Fill) + } else { + drawPath( + path = path, + color = color, + style = Stroke(width = 3.dp.toPx()) + ) + } +} + +private fun starPath( + center: Offset, + outerRadius: Float, + innerRadius: Float, + rotationDegrees: Float +): Path { + val path = Path() + val rotationRadians = rotationDegrees * PI / 180.0 + + repeat(STAR_POINT_COUNT * 2) { index -> + val radius = if (index % 2 == 0) outerRadius else innerRadius + val angle = -PI / 2.0 + rotationRadians + index * PI / STAR_POINT_COUNT + val x = center.x + cos(angle).toFloat() * radius + val y = center.y + sin(angle).toFloat() * radius + + if (index == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + + path.close() + return path +} + +private data class WelcomeStar( + val x: Float, + val y: Float, + val radiusDp: Float, + val filled: Boolean, + val alpha: Float, + val rotationDegrees: Float = 0f +) + +private const val STAR_POINT_COUNT = 5