From 9ecb3f30ff40c2a131058b430b906a968bca095f Mon Sep 17 00:00:00 2001 From: princehw03 Date: Fri, 13 Feb 2026 13:55:19 +0900 Subject: [PATCH 01/11] =?UTF-8?q?fix:=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/egobook/app/ui/diary/view/CalenderFragment.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/egobook/app/ui/diary/view/CalenderFragment.kt b/app/src/main/java/com/egobook/app/ui/diary/view/CalenderFragment.kt index a13bfe1..a050e6f 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/view/CalenderFragment.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/view/CalenderFragment.kt @@ -236,6 +236,8 @@ class CalenderFragment : Fragment() { binding.calendarView.setup(startMonth, endMonth, firstDayOfWeek) binding.calendarView.scrollToMonth(initialMonth) + + } /** From 8528115aabd3763157e140e343848faeac6d0299 Mon Sep 17 00:00:00 2001 From: princehw03 Date: Mon, 16 Feb 2026 15:30:38 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8/?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=ED=81=B4=EB=9E=98=EC=8A=A4=EC=99=80=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=ED=95=A8=EC=88=98=20=EC=8B=A0=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/auth/AuthRepositoryImpl.kt | 2 +- .../egobook/app/data/util/ApiResponseExt.kt | 50 +++++++++++++++++-- .../app/domain/model/auth/AuthError.kt | 24 +++++++++ .../app/ui/login/view/LoginActivity.kt | 13 +++++ .../app/ui/login/viewmodel/LoginViewModel.kt | 11 +++- 5 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/egobook/app/domain/model/auth/AuthError.kt diff --git a/app/src/main/java/com/egobook/app/data/repository/auth/AuthRepositoryImpl.kt b/app/src/main/java/com/egobook/app/data/repository/auth/AuthRepositoryImpl.kt index 709aaab..38c3b61 100644 --- a/app/src/main/java/com/egobook/app/data/repository/auth/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/egobook/app/data/repository/auth/AuthRepositoryImpl.kt @@ -112,7 +112,7 @@ class AuthRepositoryImpl @Inject constructor( } } - //구글 로그인 시 사용 + // 구글 로그인 시 사용 override suspend fun refreshTokens(idToken: String): Result { // 액세스 토큰 가져오기 (없으면 null) val accessToken = userInfoStorage.getAccessToken().first() diff --git a/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt b/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt index 7d215d6..7516013 100644 --- a/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt +++ b/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt @@ -2,6 +2,33 @@ package com.egobook.app.data.util import com.egobook.app.data.model.ApiResponse import com.egobook.app.data.model.ApiResponseEmpty +import com.egobook.app.domain.model.auth.AuthError +import java.io.IOException + + +/** + * 로그인/회원가입 전용 에러 매핑 함수 + */ +fun ApiResponse<*>.toAuthError(): AuthError { + return when (this.status) { + 400 -> AuthError.BadRequest() + 401 -> AuthError.InvalidCredentials() + 404 -> AuthError.UserNotFound() + 409 -> AuthError.UserAlreadyExists() + else -> AuthError.Unknown(this.message) + } +} + +/** + * 로그인/회원가입 전용 Result로 변환 확장 함수 + */ +fun ApiResponse.toAuthResult(): Result { + return if (this.status == 200) { + Result.success(this.data) + } else { + Result.failure(this.toAuthError()) + } +} /** @@ -10,7 +37,7 @@ import com.egobook.app.data.model.ApiResponseEmpty inline fun ApiResponse.toResult( transform: (T) -> R ): Result { - return if (this.code == "SUCCESS") { + return if (this.status == 200) { Result.success(transform(this.data)) } else { Result.failure(Exception(this.message)) @@ -21,13 +48,28 @@ inline fun ApiResponse.toResult( * ApiResponse를 Result로 변환 (변환 없이) */ fun ApiResponse.toResult(): Result { - return if (this.code == "SUCCESS") { + return if (this.status == 200) { Result.success(this.data) } else { Result.failure(Exception(this.message)) } } +/** + * (로그인/회원가입 전용) API 호출을 안전하게 실행하는 헬퍼 함수 + */ +suspend fun safeAuthApiCall( + apiCall: suspend () -> ApiResponse +): Result { + return try { + apiCall().toAuthResult() + } catch (e: IOException) { + Result.failure(AuthError.NetworkError()) + } catch (e: Exception) { + Result.failure(AuthError.Unknown(e.message)) + } +} + /** * API 호출을 안전하게 실행하는 헬퍼 함수 */ @@ -66,7 +108,7 @@ suspend inline fun safeApiCallWithSuspendTransform( ): Result { return try { val response = apiCall() - if (response.code == "SUCCESS") { + if (response.status == 200) { Result.success(transform(response.data)) } else { Result.failure(Exception(response.message)) @@ -81,7 +123,7 @@ suspend inline fun safeApiCallWithSuspendTransform( */ fun ApiResponseEmpty.toResult(): Result { - return if (this.code == "SUCCESS") { + return if (this.status == 200) { Result.success(Unit) } else { Result.failure(Exception(this.message)) diff --git a/app/src/main/java/com/egobook/app/domain/model/auth/AuthError.kt b/app/src/main/java/com/egobook/app/domain/model/auth/AuthError.kt new file mode 100644 index 0000000..6bb4885 --- /dev/null +++ b/app/src/main/java/com/egobook/app/domain/model/auth/AuthError.kt @@ -0,0 +1,24 @@ +package com.egobook.app.domain.model.auth + +sealed class AuthError( + override val message: String? = null +) : Throwable(message) { + + class BadRequest : + AuthError("잘못된 요청입니다.") + + class InvalidCredentials : + AuthError("구글 로그인을 시도할 수 없습니다. 조금 뒤에 시도해주세요") + + class UserAlreadyExists : + AuthError("이미 가입된 계정입니다.") + + class UserNotFound : + AuthError("존재하지 않는 사용자입니다.") + + class NetworkError : + AuthError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.") + + class Unknown(message: String?) : + AuthError(message) +} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/ui/login/view/LoginActivity.kt b/app/src/main/java/com/egobook/app/ui/login/view/LoginActivity.kt index a79200c..65cfab8 100644 --- a/app/src/main/java/com/egobook/app/ui/login/view/LoginActivity.kt +++ b/app/src/main/java/com/egobook/app/ui/login/view/LoginActivity.kt @@ -72,6 +72,7 @@ class LoginActivity : AppCompatActivity() { observeFirstSignUp() observeGuestSignUp() observeSignUpError() + observeAlreadyRegistered() setupGuideText() //폰트 커스텀 적용 setupBlur() //블러뷰 setupClickListeners() //클릭리스너 설정 @@ -237,6 +238,18 @@ class LoginActivity : AppCompatActivity() { } } + private fun observeAlreadyRegistered() { + lifecycleScope.launch { + viewModel.alreadyRegistered.collect { + Toast.makeText( + this@LoginActivity, + "이미 가입되어 있는 계정입니다", + Toast.LENGTH_SHORT + ).show() + } + } + } + private fun navigateToMain() { val intent = Intent(this, MainActivity::class.java) startActivity(intent) diff --git a/app/src/main/java/com/egobook/app/ui/login/viewmodel/LoginViewModel.kt b/app/src/main/java/com/egobook/app/ui/login/viewmodel/LoginViewModel.kt index 0f4fc10..bc3861b 100644 --- a/app/src/main/java/com/egobook/app/ui/login/viewmodel/LoginViewModel.kt +++ b/app/src/main/java/com/egobook/app/ui/login/viewmodel/LoginViewModel.kt @@ -28,6 +28,9 @@ class LoginViewModel @Inject constructor( private val _signUpError = MutableSharedFlow() val signUpError = _signUpError.asSharedFlow() + private val _alreadyRegistered = MutableSharedFlow() + val alreadyRegistered = _alreadyRegistered.asSharedFlow() + private val _loginState = MutableStateFlow(LoginState.Idle) val loginState = _loginState.asStateFlow() @@ -49,7 +52,13 @@ class LoginViewModel @Inject constructor( _loginState.value = LoginState.Idle // 로딩 해제 }, onFailure = { error -> - _signUpError.emit(error.message ?: "알 수 없는 오류") + val errorMessage = error.message ?: "알 수 없는 오류" + // 이미 가입된 계정인 경우 + if (errorMessage.contains("이미") || errorMessage.contains("already", ignoreCase = true)) { + _alreadyRegistered.emit(Unit) + } else { + _signUpError.emit(errorMessage) + } _loginState.value = LoginState.Idle // 로딩 해제 } ) From 2daec644422c2c477e79fbc8f788c90208298260 Mon Sep 17 00:00:00 2001 From: princehw03 Date: Mon, 16 Feb 2026 16:29:21 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8/?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=EB=B3=84=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A9=94=EC=84=B8=EC=A7=80=20=EC=B6=9C=EB=A0=A5=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/auth/AuthRepositoryImpl.kt | 57 +- .../egobook/app/data/util/ApiResponseExt.kt | 24 +- .../app/domain/model/auth/AuthError.kt | 5 +- .../app/ui/login/view/LoginActivity.kt | 493 +++++++++--------- .../app/ui/login/viewmodel/LoginViewModel.kt | 52 +- 5 files changed, 309 insertions(+), 322 deletions(-) diff --git a/app/src/main/java/com/egobook/app/data/repository/auth/AuthRepositoryImpl.kt b/app/src/main/java/com/egobook/app/data/repository/auth/AuthRepositoryImpl.kt index 38c3b61..f74c46b 100644 --- a/app/src/main/java/com/egobook/app/data/repository/auth/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/egobook/app/data/repository/auth/AuthRepositoryImpl.kt @@ -8,6 +8,7 @@ import com.egobook.app.data.model.auth.TokenRequestByGuest import com.egobook.app.data.model.auth.TokensRequest import com.egobook.app.data.model.auth.TokensRequestAgainByGuest import com.egobook.app.data.util.safeApiCallWithSuspendTransform +import com.egobook.app.data.util.safeAuthApiCall import com.egobook.app.domain.repository.auth.AuthRepository import kotlinx.coroutines.flow.first import timber.log.Timber @@ -20,26 +21,24 @@ class AuthRepositoryImpl @Inject constructor( ) : AuthRepository { override suspend fun googleSignUp(idToken: String): Result { - return safeApiCallWithSuspendTransform( + return safeAuthApiCall( apiCall = { apiService.googleSignUp( TokenRequestByGoogle(idToken = idToken) ) - }, - transform = { tokenData -> - userInfoStorage.saveAllTokens( - accessToken = tokenData.accessToken, - refreshToken = tokenData.refreshToken - ) - val loginType = UserInfoStorage.LoginType.GOOGLE - userInfoStorage.saveLoginType(loginType) - - //유저 이메일 저장 - userInfoStorage.saveUserEmail(tokenData.email) - Timber.d("구글 로그인 성공, loginType=$loginType, email=${tokenData.email}") - Unit } - ) + ).map { tokenData -> + userInfoStorage.saveAllTokens( + accessToken = tokenData.accessToken, + refreshToken = tokenData.refreshToken + ) + val loginType = UserInfoStorage.LoginType.GOOGLE + userInfoStorage.saveLoginType(loginType) + + //유저 이메일 저장 + userInfoStorage.saveUserEmail(tokenData.email) + Timber.d("구글 로그인 성공, loginType=$loginType, email=${tokenData.email}") + } } override suspend fun guestLogin(): Result { @@ -117,7 +116,7 @@ class AuthRepositoryImpl @Inject constructor( // 액세스 토큰 가져오기 (없으면 null) val accessToken = userInfoStorage.getAccessToken().first() - return safeApiCallWithSuspendTransform( + return safeAuthApiCall( apiCall = { apiService.reGetTokens( TokensRequest( @@ -125,21 +124,19 @@ class AuthRepositoryImpl @Inject constructor( accessToken = accessToken ) ) - }, - transform = { tokenData -> - userInfoStorage.saveAllTokens( - accessToken = tokenData.accessToken, - refreshToken = tokenData.refreshToken - ) - //로그인 타입 저장 - val loginType = UserInfoStorage.LoginType.GOOGLE - userInfoStorage.saveLoginType(loginType) - //유저 이메일 저장 - userInfoStorage.saveUserEmail(tokenData.email) - Timber.d("구글 로그인 성공, loginType=$loginType, email=${tokenData.email}") - Unit } - ) + ).map { tokenData -> + userInfoStorage.saveAllTokens( + accessToken = tokenData.accessToken, + refreshToken = tokenData.refreshToken + ) + //로그인 타입 저장 + val loginType = UserInfoStorage.LoginType.GOOGLE + userInfoStorage.saveLoginType(loginType) + //유저 이메일 저장 + userInfoStorage.saveUserEmail(tokenData.email) + Timber.d("구글 로그인 성공, loginType=$loginType, email=${tokenData.email}") + } } override suspend fun refreshGuestTokens(): Result { diff --git a/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt b/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt index 7516013..dc19bd1 100644 --- a/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt +++ b/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt @@ -3,6 +3,8 @@ package com.egobook.app.data.util import com.egobook.app.data.model.ApiResponse import com.egobook.app.data.model.ApiResponseEmpty import com.egobook.app.domain.model.auth.AuthError +import retrofit2.HttpException +import timber.log.Timber import java.io.IOException @@ -10,9 +12,11 @@ import java.io.IOException * 로그인/회원가입 전용 에러 매핑 함수 */ fun ApiResponse<*>.toAuthError(): AuthError { + Timber.d("toAuthError called with status: $status, message: $message") return when (this.status) { 400 -> AuthError.BadRequest() 401 -> AuthError.InvalidCredentials() + 403 -> AuthError.WaitDelete() 404 -> AuthError.UserNotFound() 409 -> AuthError.UserAlreadyExists() else -> AuthError.Unknown(this.message) @@ -63,6 +67,18 @@ suspend fun safeAuthApiCall( ): Result { return try { apiCall().toAuthResult() + } catch (e: HttpException) { + val statusCode = e.code() + Timber.d("HttpException caught with code: $statusCode") + val authError = when (statusCode) { + 400 -> AuthError.BadRequest() + 401 -> AuthError.InvalidCredentials() + 403 -> AuthError.WaitDelete() + 404 -> AuthError.UserNotFound() + 409 -> AuthError.UserAlreadyExists() + else -> AuthError.Unknown(e.message) + } + Result.failure(authError) } catch (e: IOException) { Result.failure(AuthError.NetworkError()) } catch (e: Exception) { @@ -111,10 +127,12 @@ suspend inline fun safeApiCallWithSuspendTransform( if (response.status == 200) { Result.success(transform(response.data)) } else { - Result.failure(Exception(response.message)) + Result.failure(response.toAuthError()) } + } catch (e: IOException) { + Result.failure(AuthError.NetworkError()) } catch (e: Exception) { - Result.failure(e) + Result.failure(AuthError.Unknown(e.message)) } } @@ -141,4 +159,4 @@ suspend fun safeApiCallEmpty( } catch (e: Exception) { Result.failure(e) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/egobook/app/domain/model/auth/AuthError.kt b/app/src/main/java/com/egobook/app/domain/model/auth/AuthError.kt index 6bb4885..5c9dbc3 100644 --- a/app/src/main/java/com/egobook/app/domain/model/auth/AuthError.kt +++ b/app/src/main/java/com/egobook/app/domain/model/auth/AuthError.kt @@ -8,7 +8,10 @@ sealed class AuthError( AuthError("잘못된 요청입니다.") class InvalidCredentials : - AuthError("구글 로그인을 시도할 수 없습니다. 조금 뒤에 시도해주세요") + AuthError("유효하지 않은 구글 계정입니다.") + + class WaitDelete : + AuthError("탈퇴 처리 중인 계정입니다. 관리자에게 문의하세요.") class UserAlreadyExists : AuthError("이미 가입된 계정입니다.") diff --git a/app/src/main/java/com/egobook/app/ui/login/view/LoginActivity.kt b/app/src/main/java/com/egobook/app/ui/login/view/LoginActivity.kt index 65cfab8..2bd7c9b 100644 --- a/app/src/main/java/com/egobook/app/ui/login/view/LoginActivity.kt +++ b/app/src/main/java/com/egobook/app/ui/login/view/LoginActivity.kt @@ -1,13 +1,14 @@ -package com.egobook.app.ui.login.view + package com.egobook.app.ui.login.view -import android.content.Intent -import androidx.credentials.GetCredentialResponse -import androidx.credentials.GetCredentialRequest + import android.content.Intent +import android.graphics.Color import android.graphics.Typeface import android.os.Bundle import android.text.SpannableString import android.text.Spanned import android.text.TextPaint +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan import android.text.style.MetricAffectingSpan import android.view.View import android.widget.Toast @@ -15,9 +16,16 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.content.res.ResourcesCompat +import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.exceptions.GetCredentialException import androidx.lifecycle.lifecycleScope +import com.egobook.app.BuildConfig import com.egobook.app.MainActivity import com.egobook.app.R import com.egobook.app.data.local.UserInfoStorage @@ -25,323 +33,290 @@ import com.egobook.app.databinding.ActivityLoginBinding import com.egobook.app.ui.login.viewmodel.LoginViewModel import com.egobook.app.ui.login.viewmodel.LoginViewModel.LoginEvent as LoginEvent import com.egobook.app.ui.login.viewmodel.LoginViewModel.LoginState as LoginState -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch -import javax.inject.Inject -import androidx.credentials.CredentialManager -import androidx.credentials.CustomCredential -import androidx.credentials.exceptions.GetCredentialException -import com.egobook.app.BuildConfig +import com.egobook.app.ui.onboarding.view.OnboardingActivity import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential -import com.egobook.app.ui.onboarding.view.OnboardingActivity +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import timber.log.Timber -import androidx.core.net.toUri -import android.text.method.LinkMovementMethod -import android.text.style.ClickableSpan -import android.graphics.Color +import javax.inject.Inject -@AndroidEntryPoint -class LoginActivity : AppCompatActivity() { + @AndroidEntryPoint + class LoginActivity : AppCompatActivity() { - @Inject lateinit var userInfoStorage: UserInfoStorage - private lateinit var request: GetCredentialRequest - private val binding by lazy { ActivityLoginBinding.inflate(layoutInflater) } - private val viewModel: LoginViewModel by viewModels() - private val blurRadius = 5f + @Inject lateinit var userInfoStorage: UserInfoStorage + private lateinit var request: GetCredentialRequest + private val binding by lazy { ActivityLoginBinding.inflate(layoutInflater) } + private val viewModel: LoginViewModel by viewModels() + private val blurRadius = 5f - private val credentialManager by lazy { - CredentialManager.create(this) - } + private val credentialManager by lazy { + CredentialManager.create(this) + } - override fun onCreate(savedInstanceState: Bundle?) { + override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContentView(binding.root) + enableEdgeToEdge() + setContentView(binding.root) - // 시스템 바를 고려한 패딩 - ViewCompat.setOnApplyWindowInsetsListener(binding.login) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets - } + // 시스템 바를 고려한 패딩 + ViewCompat.setOnApplyWindowInsetsListener(binding.login) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } - observeLoginState() - observeFirstSignUp() - observeGuestSignUp() - observeSignUpError() - observeAlreadyRegistered() - setupGuideText() //폰트 커스텀 적용 - setupBlur() //블러뷰 - setupClickListeners() //클릭리스너 설정 + observeLoginState() + observeFirstSignUp() + observeGuestSignUp() + setupGuideText() //폰트 커스텀 적용 + setupBlur() //블러뷰 + setupClickListeners() //클릭리스너 설정 - } - private fun setupBlur() { - binding.blurView.setupWith(binding.blurTarget) - .setBlurRadius(blurRadius) - .setBlurAutoUpdate(true) - } + } + private fun setupBlur() { + binding.blurView.setupWith(binding.blurTarget) + .setBlurRadius(blurRadius) + .setBlurAutoUpdate(true) + } - fun clearBlur() { - binding.blurView.visibility = View.GONE - } - private fun setupClickListeners() { - // 상단 로그인 버튼 - 콜백 인터페이스 세팅 & 바텀시트 띄우기 - binding.btnLogin.setOnClickListener { - binding.blurView.visibility = View.VISIBLE - - val loginBottomSheet = LoginBottomSheetFragment() - loginBottomSheet.setOnLoginConfirmListener(object : LoginBottomSheetFragment.OnLoginConfirmListener { - override fun onLoginConfirmed() { - request = getGoogleRequest() - - lifecycleScope.launch { - try { - val result = credentialManager.getCredential( - request = request, - context = this@LoginActivity - ) - handleSignIn(result, isLogin = true) - } catch (e: GetCredentialException) { - Timber.d("로그인 실패: ${e.message}") + fun clearBlur() { + binding.blurView.visibility = View.GONE + } + private fun setupClickListeners() { + // 상단 로그인 버튼 - 콜백 인터페이스 세팅 & 바텀시트 띄우기 + binding.btnLogin.setOnClickListener { + binding.blurView.visibility = View.VISIBLE + + val loginBottomSheet = LoginBottomSheetFragment() + loginBottomSheet.setOnLoginConfirmListener(object : LoginBottomSheetFragment.OnLoginConfirmListener { + override fun onLoginConfirmed() { + request = getGoogleRequest() + + lifecycleScope.launch { + try { + val result = credentialManager.getCredential( + request = request, + context = this@LoginActivity + ) + handleSignIn(result, isLogin = true) + } catch (e: GetCredentialException) { + Timber.d("로그인 실패: ${e.message}") + } } } - } - }) - loginBottomSheet.show(supportFragmentManager, LoginBottomSheetFragment.TAG) - } + }) + loginBottomSheet.show(supportFragmentManager, LoginBottomSheetFragment.TAG) + } - //게스트 로그인 버튼 클릭이벤트 - binding.btnGuestLogin.setOnClickListener { - viewModel.onEvent(LoginEvent.TryGuestLogin) - } + //게스트 로그인 버튼 클릭이벤트 + binding.btnGuestLogin.setOnClickListener { + viewModel.onEvent(LoginEvent.TryGuestLogin) + } - // Google 계정으로 회원가입 버튼 - 구글 로그인 창 띄우기 - binding.btnGoogleLogin.setOnClickListener { - request = getGoogleRequest() + // Google 계정으로 회원가입 버튼 - 구글 로그인 창 띄우기 + binding.btnGoogleLogin.setOnClickListener { + request = getGoogleRequest() + + lifecycleScope.launch { + try { + val result = credentialManager.getCredential( + request = request, + context = this@LoginActivity + ) + handleSignIn(result, isLogin = false) + } catch (e: GetCredentialException) { + Timber.d("회원가입 실패: ${e.message}") + } - lifecycleScope.launch { - try { - val result = credentialManager.getCredential( - request = request, - context = this@LoginActivity - ) - handleSignIn(result, isLogin = false) - } catch (e: GetCredentialException) { - Timber.d("회원가입 실패: ${e.message}") } } + //약관 + binding.tvStartGuide.setOnClickListener { + val intent = Intent(Intent.ACTION_VIEW, "https://bevel-beetle-a49.notion.site/2f638a539ac58059b9a1c883ad7d7164".toUri()) + startActivity(intent) + } } - //약관 - binding.tvStartGuide.setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, "https://bevel-beetle-a49.notion.site/2f638a539ac58059b9a1c883ad7d7164".toUri()) - startActivity(intent) - } - } + private fun getGoogleRequest(): GetCredentialRequest { + val googleIdOption = GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(false) // 모든 구글 계정 표시 + .setServerClientId(BuildConfig.GOOGLE_WEB_CLIENT_ID) + .setAutoSelectEnabled(false) // 사용자가 직접 선택 + .build() - private fun getGoogleRequest(): GetCredentialRequest { - val googleIdOption = GetGoogleIdOption.Builder() - .setFilterByAuthorizedAccounts(false) // 모든 구글 계정 표시 - .setServerClientId(BuildConfig.GOOGLE_WEB_CLIENT_ID) - .setAutoSelectEnabled(false) // 사용자가 직접 선택 - .build() + return GetCredentialRequest.Builder() + .addCredentialOption(googleIdOption) + .build() + } - return GetCredentialRequest.Builder() - .addCredentialOption(googleIdOption) - .build() - } + private fun handleSignIn(result: GetCredentialResponse, isLogin: Boolean = false) { + val credential = result.credential - private fun handleSignIn(result: GetCredentialResponse, isLogin: Boolean = false) { - val credential = result.credential + if (credential is CustomCredential && + credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL + ) { + try { + val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data) + val idToken = googleIdTokenCredential.idToken - if (credential is CustomCredential && - credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL - ) { - try { - val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data) - val idToken = googleIdTokenCredential.idToken + Timber.d("Google ID Token 받음") - Timber.d("Google ID Token 받음") + // 회원가입 vs 로그인 분기 + if (isLogin) { + viewModel.onEvent(LoginEvent.TryLoginByGoogle(idToken)) // 로그인 + } else { + viewModel.onEvent(LoginEvent.TrySignInByGoogle(idToken)) // 회원가입 + } - // 회원가입 vs 로그인 분기 - if (isLogin) { - viewModel.onEvent(LoginEvent.TryLoginByGoogle(idToken)) // 로그인 - } else { - viewModel.onEvent(LoginEvent.TrySignInByGoogle(idToken)) // 회원가입 + } catch (e: GetCredentialException) { + Timber.e(e, "구글 토큰 파싱 실패") } - } catch (e: GetCredentialException) { - Timber.e(e, "구글 토큰 파싱 실패") + } else { + Timber.e("구글 로그인 credential 아님") } - - } else { - Timber.e("구글 로그인 credential 아님") } - } - private fun observeLoginState() { - lifecycleScope.launch { - viewModel.loginState.collect { state -> - when (state) { - is LoginState.Success -> { - Toast.makeText( - this@LoginActivity, - "로그인 성공!", - Toast.LENGTH_SHORT - ).show() - navigateToMain() - } - is LoginState.Error -> { - Toast.makeText( - this@LoginActivity, - "로그인 실패: ${state.message}", - Toast.LENGTH_SHORT - ).show() + private fun observeLoginState() { + lifecycleScope.launch { + viewModel.loginState.collect { state -> + when (state) { + is LoginState.Success -> { + Toast.makeText( + this@LoginActivity, + "로그인 성공!", + Toast.LENGTH_SHORT + ).show() + navigateToMain() + } + is LoginState.Error -> { + val message = state.error.message ?: "알 수 없는 오류가 발생했습니다" + Toast.makeText( + this@LoginActivity, + message, + Toast.LENGTH_SHORT + ).show() + } + else -> {} } - else -> {} } } } - } - private fun observeFirstSignUp() { - lifecycleScope.launch { - viewModel.isFirstSignUp.collect { - Toast.makeText(this@LoginActivity, "에고북에 오신 걸 환영합니다!", Toast.LENGTH_SHORT).show() - navigateToOnboarding() //온보딩 화면 이동 + private fun observeFirstSignUp() { + lifecycleScope.launch { + viewModel.isFirstSignUp.collect { + Toast.makeText(this@LoginActivity, "에고북에 오신 걸 환영합니다!", Toast.LENGTH_SHORT).show() + navigateToOnboarding() //온보딩 화면 이동 + } } } - } - private fun observeGuestSignUp() { - lifecycleScope.launch { - viewModel.isGuestSignUp.collect { - Toast.makeText(this@LoginActivity, "에고북에 오신 걸 환영합니다!", Toast.LENGTH_SHORT).show() - navigateToOnboarding() //온보딩 화면 이동 + private fun observeGuestSignUp() { + lifecycleScope.launch { + viewModel.isGuestSignUp.collect { + Toast.makeText(this@LoginActivity, "에고북에 오신 걸 환영합니다!", Toast.LENGTH_SHORT).show() + navigateToOnboarding() //온보딩 화면 이동 + } } } - } - private fun observeSignUpError() { - lifecycleScope.launch { - viewModel.signUpError.collect { errorMessage -> - Toast.makeText( - this@LoginActivity, - "회원가입 실패: $errorMessage", - Toast.LENGTH_SHORT - ).show() - } + private fun navigateToMain() { + val intent = Intent(this, MainActivity::class.java) + startActivity(intent) + finish() } - } - private fun observeAlreadyRegistered() { - lifecycleScope.launch { - viewModel.alreadyRegistered.collect { - Toast.makeText( - this@LoginActivity, - "이미 가입되어 있는 계정입니다", - Toast.LENGTH_SHORT - ).show() - } + private fun navigateToOnboarding() { + val intent = Intent(this, OnboardingActivity::class.java) + startActivity(intent) + finish() } - } - private fun navigateToMain() { - val intent = Intent(this, MainActivity::class.java) - startActivity(intent) - finish() - } - private fun navigateToOnboarding() { - val intent = Intent(this, OnboardingActivity::class.java) - startActivity(intent) - finish() - } + //========================================특정 char 폰트 커스텀=========================================================== + private fun setupGuideText() { + val fullText = "시작 시 이용약관 및\n개인정보 수집 및 이용에 동의하게 됩니다" + val spannableString = SpannableString(fullText) + val semiBoldTypeface = ResourcesCompat.getFont(this, R.font.arita_semibold) ?: return -//========================================특정 char 폰트 커스텀=========================================================== -private fun setupGuideText() { - val fullText = "시작 시 이용약관 및\n개인정보 수집 및 이용에 동의하게 됩니다" - val spannableString = SpannableString(fullText) + // ================= 이용약관 ================= + val termText = "이용약관" + val termStart = fullText.indexOf(termText) + if (termStart >= 0) { + val termEnd = termStart + termText.length - val semiBoldTypeface = ResourcesCompat.getFont(this, R.font.arita_semibold) ?: return + spannableString.setSpan( + CustomTypefaceSpan(semiBoldTypeface), + termStart, termEnd, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) - // ================= 이용약관 ================= - val termText = "이용약관" - val termStart = fullText.indexOf(termText) - if (termStart >= 0) { - val termEnd = termStart + termText.length + spannableString.setSpan(object : ClickableSpan() { + override fun onClick(widget: View) { + val intent = Intent( + Intent.ACTION_VIEW, + "https://bevel-beetle-a49.notion.site/2f638a539ac5801aa872e99ec4282f28".toUri() // ← 약관 URL + ) + startActivity(intent) + } - spannableString.setSpan( - CustomTypefaceSpan(semiBoldTypeface), - termStart, termEnd, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) + override fun updateDrawState(ds: TextPaint) { + ds.isUnderlineText = false + } + }, termStart, termEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } - spannableString.setSpan(object : ClickableSpan() { - override fun onClick(widget: View) { - val intent = Intent( - Intent.ACTION_VIEW, - "https://bevel-beetle-a49.notion.site/2f638a539ac5801aa872e99ec4282f28".toUri() // ← 약관 URL - ) - startActivity(intent) - } + // ================= 개인정보 ================= + val privacyText = "개인정보 수집 및 이용" + val privacyStart = fullText.indexOf(privacyText) + if (privacyStart >= 0) { + val privacyEnd = privacyStart + privacyText.length + + spannableString.setSpan( + CustomTypefaceSpan(semiBoldTypeface), + privacyStart, privacyEnd, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + + spannableString.setSpan(object : ClickableSpan() { + override fun onClick(widget: View) { + val intent = Intent( + Intent.ACTION_VIEW, + "https://bevel-beetle-a49.notion.site/2f638a539ac58059b9a1c883ad7d7164".toUri() // ← 개인정보 URL + ) + startActivity(intent) + } - override fun updateDrawState(ds: TextPaint) { - ds.isUnderlineText = false - } - }, termStart, termEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - } + override fun updateDrawState(ds: TextPaint) { + ds.isUnderlineText = false + } + }, privacyStart, privacyEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } - // ================= 개인정보 ================= - val privacyText = "개인정보 수집 및 이용" - val privacyStart = fullText.indexOf(privacyText) - if (privacyStart >= 0) { - val privacyEnd = privacyStart + privacyText.length - - spannableString.setSpan( - CustomTypefaceSpan(semiBoldTypeface), - privacyStart, privacyEnd, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - - spannableString.setSpan(object : ClickableSpan() { - override fun onClick(widget: View) { - val intent = Intent( - Intent.ACTION_VIEW, - "https://bevel-beetle-a49.notion.site/2f638a539ac58059b9a1c883ad7d7164".toUri() // ← 개인정보 URL - ) - startActivity(intent) - } + binding.tvStartGuide.text = spannableString + binding.tvStartGuide.movementMethod = LinkMovementMethod.getInstance() + binding.tvStartGuide.highlightColor = Color.TRANSPARENT + } + //모든 API 레벨에서 커스텀 폰트를 적용하기 위한 Span 클래스 + private class CustomTypefaceSpan(private val typeface: Typeface) : MetricAffectingSpan() { override fun updateDrawState(ds: TextPaint) { - ds.isUnderlineText = false + applyCustomTypeface(ds) } - }, privacyStart, privacyEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - } - - binding.tvStartGuide.text = spannableString - binding.tvStartGuide.movementMethod = LinkMovementMethod.getInstance() - binding.tvStartGuide.highlightColor = Color.TRANSPARENT -} - //모든 API 레벨에서 커스텀 폰트를 적용하기 위한 Span 클래스 - private class CustomTypefaceSpan(private val typeface: Typeface) : MetricAffectingSpan() { - override fun updateDrawState(ds: TextPaint) { - applyCustomTypeface(ds) - } + override fun updateMeasureState(p: TextPaint) { + applyCustomTypeface(p) + } - override fun updateMeasureState(p: TextPaint) { - applyCustomTypeface(p) - } + private fun applyCustomTypeface(paint: TextPaint) { + paint.typeface = typeface + } - private fun applyCustomTypeface(paint: TextPaint) { - paint.typeface = typeface } - - } -} \ No newline at end of file + } \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/ui/login/viewmodel/LoginViewModel.kt b/app/src/main/java/com/egobook/app/ui/login/viewmodel/LoginViewModel.kt index bc3861b..4de4f69 100644 --- a/app/src/main/java/com/egobook/app/ui/login/viewmodel/LoginViewModel.kt +++ b/app/src/main/java/com/egobook/app/ui/login/viewmodel/LoginViewModel.kt @@ -3,6 +3,7 @@ package com.egobook.app.ui.login.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.egobook.app.data.local.UserInfoStorage +import com.egobook.app.domain.model.auth.AuthError import com.egobook.app.domain.usecase.authusecase.AuthUseCases import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow @@ -25,12 +26,6 @@ class LoginViewModel @Inject constructor( private val _isGuestSignUp = MutableSharedFlow() val isGuestSignUp = _isGuestSignUp.asSharedFlow() - private val _signUpError = MutableSharedFlow() - val signUpError = _signUpError.asSharedFlow() - - private val _alreadyRegistered = MutableSharedFlow() - val alreadyRegistered = _alreadyRegistered.asSharedFlow() - private val _loginState = MutableStateFlow(LoginState.Idle) val loginState = _loginState.asStateFlow() @@ -45,23 +40,19 @@ class LoginViewModel @Inject constructor( is LoginEvent.TrySignInByGoogle -> { viewModelScope.launch { _loginState.value = LoginState.Loading - val result = authUseCases.googleSignUp(event.idToken) - result.fold( - onSuccess = { - _isFirstSignUp.emit(Unit) - _loginState.value = LoginState.Idle // 로딩 해제 - }, - onFailure = { error -> - val errorMessage = error.message ?: "알 수 없는 오류" - // 이미 가입된 계정인 경우 - if (errorMessage.contains("이미") || errorMessage.contains("already", ignoreCase = true)) { - _alreadyRegistered.emit(Unit) - } else { - _signUpError.emit(errorMessage) + + authUseCases.googleSignUp(event.idToken) + .fold( + onSuccess = { + _isFirstSignUp.emit(Unit) + _loginState.value = LoginState.Idle + }, + onFailure = { throwable -> + val authError = throwable as? AuthError + ?: AuthError.Unknown(throwable.message) + _loginState.value = LoginState.Error(authError) } - _loginState.value = LoginState.Idle // 로딩 해제 - } - ) + ) } } //구글 로그인 시도 -> 토큰 재발급 & 로그인 타입 저장 @@ -76,9 +67,11 @@ class LoginViewModel @Inject constructor( _loginState.value = LoginState.Success }, - onFailure = { error -> - _loginState.value = - LoginState.Error(error.message ?: "알 수 없는 오류") + onFailure = { throwable -> + val authError = throwable as? AuthError + ?: AuthError.Unknown(throwable.message) + + _loginState.value = LoginState.Error(authError) } ) } @@ -110,9 +103,10 @@ class LoginViewModel @Inject constructor( Timber.d("회원가입 성공, 토큰 발급") } }, - onFailure = { error -> - _signUpError.emit(error.message ?: "알 수 없는 오류") - _loginState.value = LoginState.Idle + onFailure = { throwable -> + val authError = throwable as? AuthError + ?: AuthError.Unknown(throwable.message) + _loginState.value = LoginState.Error(authError) } ) } @@ -132,7 +126,7 @@ class LoginViewModel @Inject constructor( data object Idle : LoginState() data object Loading : LoginState() data object Success : LoginState() //성공시 메인 화면으로 - data class Error(val message: String) : LoginState() + data class Error(val error: AuthError) : LoginState() } } \ No newline at end of file From f226611520f6e93b372d9b347f76e910f2778402 Mon Sep 17 00:00:00 2001 From: princehw03 Date: Tue, 17 Feb 2026 22:21:58 +0900 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20authError=20=EB=A9=94?= =?UTF-8?q?=EC=84=B8=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/egobook/app/domain/model/auth/AuthError.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/egobook/app/domain/model/auth/AuthError.kt b/app/src/main/java/com/egobook/app/domain/model/auth/AuthError.kt index 5c9dbc3..d8d96cf 100644 --- a/app/src/main/java/com/egobook/app/domain/model/auth/AuthError.kt +++ b/app/src/main/java/com/egobook/app/domain/model/auth/AuthError.kt @@ -4,24 +4,31 @@ sealed class AuthError( override val message: String? = null ) : Throwable(message) { + //400 class BadRequest : AuthError("잘못된 요청입니다.") + //401 class InvalidCredentials : - AuthError("유효하지 않은 구글 계정입니다.") + AuthError("유효하지 않은 계정입니다.") + //403 class WaitDelete : AuthError("탈퇴 처리 중인 계정입니다. 관리자에게 문의하세요.") + //409 class UserAlreadyExists : - AuthError("이미 가입된 계정입니다.") + AuthError("이미 가입된 구글 계정입니다.") + //404 class UserNotFound : AuthError("존재하지 않는 사용자입니다.") + //미정 class NetworkError : AuthError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.") + //else class Unknown(message: String?) : AuthError(message) } \ No newline at end of file From 99227359181d71f73f0ab1b18922a6e0f39f069c Mon Sep 17 00:00:00 2001 From: princehw03 Date: Tue, 17 Feb 2026 22:46:39 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=97=B0=EB=8F=99=20=EC=98=A4=EB=A5=98=20=EC=9C=A0?= =?UTF-8?q?=ED=98=95=EB=B3=84=20Toast=EB=A9=94=EC=84=B8=EC=A7=80=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../account/AccountRepositoryImpl.kt | 39 +++++++++---------- .../ui/account/viewmodel/AccountViewModel.kt | 14 +++++-- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/egobook/app/data/repository/account/AccountRepositoryImpl.kt b/app/src/main/java/com/egobook/app/data/repository/account/AccountRepositoryImpl.kt index beeb784..0c3c6bd 100644 --- a/app/src/main/java/com/egobook/app/data/repository/account/AccountRepositoryImpl.kt +++ b/app/src/main/java/com/egobook/app/data/repository/account/AccountRepositoryImpl.kt @@ -4,7 +4,7 @@ import com.egobook.app.data.api.AccountApiService import com.egobook.app.data.local.UserInfoStorage import com.egobook.app.data.model.account.LinkRequest import com.egobook.app.data.util.safeApiCall -import com.egobook.app.data.util.safeApiCallWithSuspendTransform +import com.egobook.app.data.util.safeAuthApiCall import com.egobook.app.domain.repository.account.AccountRepository import com.egobook.app.domain.repository.account.LinkedAccountInfo import javax.inject.Inject @@ -50,29 +50,26 @@ class AccountRepositoryImpl @Inject constructor( } // idToken 전송 (API 호출) 및 토큰 저장 - return safeApiCallWithSuspendTransform( + return safeAuthApiCall( apiCall = { apiService.linkToGoogle(LinkRequest(idToken = idToken)) - }, - transform = { tokenData -> - // Access, Refresh Token 저장 (Recover Token은 null로 설정하여 저장하지 않음) - userInfoStorage.saveAllTokens( - accessToken = tokenData.accessToken, - refreshToken = tokenData.refreshToken, - recoverToken = null - ) - - // 로그인 타입을 GOOGLE로 변경 - val loginType = UserInfoStorage.LoginType.GOOGLE - userInfoStorage.saveLoginType(loginType) - - // 이메일 저장 - userInfoStorage.saveUserEmail(tokenData.email) - Timber.d("구글 로그인 성공, loginType=$loginType, email=${tokenData.email}") - - Unit } - ) + ).map { tokenData -> + // Access, Refresh Token 저장 (Recover Token은 null로 설정하여 저장하지 않음) + userInfoStorage.saveAllTokens( + accessToken = tokenData.accessToken, + refreshToken = tokenData.refreshToken, + recoverToken = null + ) + + // 로그인 타입을 GOOGLE로 변경 + val loginType = UserInfoStorage.LoginType.GOOGLE + userInfoStorage.saveLoginType(loginType) + + // 이메일 저장 + userInfoStorage.saveUserEmail(tokenData.email) + Timber.d("구글 로그인 성공, loginType=$loginType, email=${tokenData.email}") + } } override suspend fun getLinkedAccountInfo(): Result { diff --git a/app/src/main/java/com/egobook/app/ui/account/viewmodel/AccountViewModel.kt b/app/src/main/java/com/egobook/app/ui/account/viewmodel/AccountViewModel.kt index 05e9937..7ad51c7 100644 --- a/app/src/main/java/com/egobook/app/ui/account/viewmodel/AccountViewModel.kt +++ b/app/src/main/java/com/egobook/app/ui/account/viewmodel/AccountViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject +import com.egobook.app.domain.model.auth.AuthError @HiltViewModel class AccountViewModel @Inject constructor( @@ -84,9 +85,16 @@ class AccountViewModel @Inject constructor( } } .onFailure { e -> - _linkState.value = - UiState.Failure(e.message ?: "구글 계정 연동에 실패했습니다") - _linkToastEvent.emit("구글 계정 연동에 실패했습니다.") + val errorMessage = when (e) { + is AuthError.BadRequest -> "잘못된 요청입니다. 다시 시도해주세요." + is AuthError.InvalidCredentials -> "GUEST 로그인이 되어 있지 않습니다. 다시 로그인해주세요." + is AuthError.UserNotFound -> "Guest 계정 정보를 찾을 수 없습니다." + is AuthError.UserAlreadyExists -> "이미 연동된 Google 계정입니다." + is AuthError.NetworkError -> "네트워크 연결을 확인해주세요." + else -> e.message ?: "구글 계정 연동에 실패했습니다. 잠시 후 다시 시도해주세요" + } + _linkState.value = UiState.Failure(errorMessage) + _linkToastEvent.emit(errorMessage) } } } From b6d1fe9cca9c46583a16095b9d3e6b7d121e4ded Mon Sep 17 00:00:00 2001 From: princehw03 Date: Wed, 18 Feb 2026 00:47:13 +0900 Subject: [PATCH 06/11] =?UTF-8?q?design:=20guideline=EC=9D=84=20=ED=86=B5?= =?UTF-8?q?=ED=95=9C=20Z-Flip=20=ED=99=94=EB=A9=B4=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/res/layout/activity_intro.xml | 2 +- app/src/main/res/layout/activity_login.xml | 90 +++++++++++-------- .../layout/fragment_onboarding_container.xml | 12 ++- .../res/layout/fragment_onboarding_fifth.xml | 24 +++-- .../res/layout/fragment_onboarding_first.xml | 24 +++-- .../res/layout/fragment_onboarding_fourth.xml | 24 +++-- .../res/layout/fragment_onboarding_second.xml | 24 +++-- .../res/layout/fragment_onboarding_third.xml | 24 +++-- 8 files changed, 157 insertions(+), 67 deletions(-) diff --git a/app/src/main/res/layout/activity_intro.xml b/app/src/main/res/layout/activity_intro.xml index c7e348c..abde9ad 100644 --- a/app/src/main/res/layout/activity_intro.xml +++ b/app/src/main/res/layout/activity_intro.xml @@ -12,9 +12,9 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" - android:layout_marginTop="229dp" android:orientation="vertical" android:gravity="center"> + + - + android:layout_height="wrap_content" + android:orientation="horizontal" + app:layout_constraintGuide_percent="0.975"/> - + app:layout_constraintEnd_toEndOf="parent"> - + + + + + + + diff --git a/app/src/main/res/layout/fragment_onboarding_container.xml b/app/src/main/res/layout/fragment_onboarding_container.xml index 1392f5d..6be55f9 100644 --- a/app/src/main/res/layout/fragment_onboarding_container.xml +++ b/app/src/main/res/layout/fragment_onboarding_container.xml @@ -7,6 +7,13 @@ android:layout_height="match_parent" tools:context=".ui.onboarding.view.OnboardingContainerFragment"> + + + app:layout_constraintStart_toStartOf="parent"/> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_onboarding_fifth.xml b/app/src/main/res/layout/fragment_onboarding_fifth.xml index 7333aee..f365d98 100644 --- a/app/src/main/res/layout/fragment_onboarding_fifth.xml +++ b/app/src/main/res/layout/fragment_onboarding_fifth.xml @@ -18,7 +18,21 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="horizontal" - app:layout_constraintGuide_begin="64dp"/> + app:layout_constraintGuide_percent="0.1"/> + + + + + app:layout_constraintTop_toTopOf="@id/gd_center_horizontal" + app:layout_constraintBottom_toBottomOf="@id/gd_center_horizontal" + app:layout_constraintStart_toStartOf="@id/gd_center_vertical" + app:layout_constraintEnd_toEndOf="@id/gd_center_vertical"> + app:layout_constraintGuide_percent="0.1"/> + + + + + app:layout_constraintTop_toTopOf="@id/gd_center_horizontal" + app:layout_constraintBottom_toBottomOf="@id/gd_center_horizontal" + app:layout_constraintStart_toStartOf="@id/gd_center_vertical" + app:layout_constraintEnd_toEndOf="@id/gd_center_vertical"> + app:layout_constraintGuide_percent="0.1"/> + + + + + app:layout_constraintTop_toTopOf="@id/gd_center_horizontal" + app:layout_constraintBottom_toBottomOf="@id/gd_center_horizontal" + app:layout_constraintStart_toStartOf="@id/gd_center_vertical" + app:layout_constraintEnd_toEndOf="@id/gd_center_vertical"> + app:layout_constraintGuide_percent="0.1"/> + + + + + app:layout_constraintGuide_percent="0.1"/> + + + + From edec25f2b381cd6428023b1becc80b77a2bd0577 Mon Sep 17 00:00:00 2001 From: princehw03 Date: Thu, 19 Feb 2026 02:39:27 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20=EC=9D=BC=EA=B8=B0=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=ED=86=A0=EC=8A=A4=ED=8A=B8=201=EC=B0=A8?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/diary/DiaryRepositoryImpl.kt | 6 +- .../app/domain/model/diary/entity/Diary.kt | 24 ---- .../domain/model/diary/entity/DiaryReward.kt | 12 ++ .../domain/model/diary/entity/DiaryType.kt | 26 ++++ .../domain/model/diary/entity/RewardType.kt | 17 +++ .../domain/model/diary/mapper/DiaryMapper.kt | 33 +++-- .../repository/diary/DiaryRepository.kt | 3 +- .../usecase/diaryusecase/DiaryUseCases.kt | 3 +- .../app/ui/diary/mapper/DiaryEntityMapper.kt | 48 +++++++- .../app/ui/diary/model/ToastMessage.kt | 7 ++ .../app/ui/diary/view/DiaryWriteFragment.kt | 107 ++++++++++++++-- .../ui/diary/viewmodel/DiaryWriteViewModel.kt | 114 +++++++++++------- app/src/main/res/layout/toast_ink.xml | 36 ++++++ app/src/main/res/layout/toast_reward.xml | 44 +++++++ app/src/main/res/values/strings.xml | 2 + 15 files changed, 385 insertions(+), 97 deletions(-) create mode 100644 app/src/main/java/com/egobook/app/domain/model/diary/entity/DiaryReward.kt create mode 100644 app/src/main/java/com/egobook/app/domain/model/diary/entity/DiaryType.kt create mode 100644 app/src/main/java/com/egobook/app/domain/model/diary/entity/RewardType.kt create mode 100644 app/src/main/java/com/egobook/app/ui/diary/model/ToastMessage.kt create mode 100644 app/src/main/res/layout/toast_ink.xml create mode 100644 app/src/main/res/layout/toast_reward.xml diff --git a/app/src/main/java/com/egobook/app/data/repository/diary/DiaryRepositoryImpl.kt b/app/src/main/java/com/egobook/app/data/repository/diary/DiaryRepositoryImpl.kt index 68208d3..17ee980 100644 --- a/app/src/main/java/com/egobook/app/data/repository/diary/DiaryRepositoryImpl.kt +++ b/app/src/main/java/com/egobook/app/data/repository/diary/DiaryRepositoryImpl.kt @@ -8,9 +8,11 @@ import com.egobook.app.data.repository.diary.paging.DiariesPagingSource import com.egobook.app.data.util.safeApiCall import com.egobook.app.domain.model.diary.entity.Diary import com.egobook.app.domain.model.diary.entity.DiaryFilter +import com.egobook.app.domain.model.diary.entity.DiaryRewards import com.egobook.app.domain.model.diary.entity.DiarySummary import com.egobook.app.domain.model.diary.mapper.DiaryMapper.toDiaryCreateRequest import com.egobook.app.domain.model.diary.mapper.DiaryMapper.toDiaryEntity +import com.egobook.app.domain.model.diary.mapper.DiaryMapper.toDiaryRewardsEntity import com.egobook.app.domain.model.diary.mapper.DiaryMapper.toDiaryUpdateRequest import com.egobook.app.domain.model.diary.mapper.DiaryMapper.toRequestParams import com.egobook.app.domain.repository.diary.DiaryRepository @@ -61,14 +63,14 @@ class DiaryRepositoryImpl @Inject constructor( ) } - override suspend fun addDiary(diary: Diary): Result { + override suspend fun addDiary(diary: Diary): Result { return safeApiCall( apiCall = { apiService.addDiary( diary.toDiaryCreateRequest() ) }, - transform = { Unit } + transform = { it.toDiaryRewardsEntity() } ) } diff --git a/app/src/main/java/com/egobook/app/domain/model/diary/entity/Diary.kt b/app/src/main/java/com/egobook/app/domain/model/diary/entity/Diary.kt index 27c3877..8f8992a 100644 --- a/app/src/main/java/com/egobook/app/domain/model/diary/entity/Diary.kt +++ b/app/src/main/java/com/egobook/app/domain/model/diary/entity/Diary.kt @@ -46,27 +46,3 @@ data class Diary( } -enum class DiaryType(val value: String, val displayType: String) { - EMOTION("EMOTION", "감정"), - CONCERN("CONCERN", "고민"), - PRAISE("PRAISE", "칭찬"), - GRATITUDE("GRATITUDE", "감사"); - companion object { - /** - * API value로 일기 타입 찾기 (예: "EMOTION", "CONCERN") - */ - fun from(value: String): DiaryType { - return entries.find { it.value == value } - ?: throw IllegalArgumentException("Unknown diary type: $value") - } - - /** - * 한글 displayType으로 DiaryType 찾기 (예: "감정", "고민") - */ - fun fromDisplayType(displayType: String): DiaryType { - return entries.find { it.displayType == displayType } - ?: throw IllegalArgumentException("Unknown display type: $displayType") - } - - } -} diff --git a/app/src/main/java/com/egobook/app/domain/model/diary/entity/DiaryReward.kt b/app/src/main/java/com/egobook/app/domain/model/diary/entity/DiaryReward.kt new file mode 100644 index 0000000..2894d7a --- /dev/null +++ b/app/src/main/java/com/egobook/app/domain/model/diary/entity/DiaryReward.kt @@ -0,0 +1,12 @@ +package com.egobook.app.domain.model.diary.entity + +data class DiaryRewards( + val type: List, + val rewards: List +) + +data class DiaryReward( + val rewardType: RewardType, + val amount: Int, + val message: String +) diff --git a/app/src/main/java/com/egobook/app/domain/model/diary/entity/DiaryType.kt b/app/src/main/java/com/egobook/app/domain/model/diary/entity/DiaryType.kt new file mode 100644 index 0000000..7be3155 --- /dev/null +++ b/app/src/main/java/com/egobook/app/domain/model/diary/entity/DiaryType.kt @@ -0,0 +1,26 @@ +package com.egobook.app.domain.model.diary.entity + +enum class DiaryType(val value: String, val displayType: String) { + EMOTION("EMOTION", "감정"), + CONCERN("CONCERN", "고민"), + PRAISE("PRAISE", "칭찬"), + GRATITUDE("GRATITUDE", "감사"); + companion object { + /** + * API value로 일기 타입 찾기 (예: "EMOTION", "CONCERN") + */ + fun from(value: String): DiaryType { + return entries.find { it.value == value } + ?: throw IllegalArgumentException("Unknown diary type: $value") + } + + /** + * 한글 displayType으로 DiaryType 찾기 (예: "감정", "고민") + */ + fun fromDisplayType(displayType: String): DiaryType { + return entries.find { it.displayType == displayType } + ?: throw IllegalArgumentException("Unknown display type: $displayType") + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/domain/model/diary/entity/RewardType.kt b/app/src/main/java/com/egobook/app/domain/model/diary/entity/RewardType.kt new file mode 100644 index 0000000..3452a3a --- /dev/null +++ b/app/src/main/java/com/egobook/app/domain/model/diary/entity/RewardType.kt @@ -0,0 +1,17 @@ +package com.egobook.app.domain.model.diary.entity + +enum class RewardType(val value: String, val displayType: String) { + INK("INK", "잉크"), + EMOTION_REGULATION("EMOTION_REGULATION", "감정조절"), + POSITIVE_THINKING("POSITIVE_THINKING", "긍정사고"); + + companion object { + /** + * API value로 보상 타입 찾기 + */ + fun from(value: String): RewardType { + return entries.find { it.value == value } + ?: throw IllegalArgumentException("Unknown reward type: $value") + } + } +} diff --git a/app/src/main/java/com/egobook/app/domain/model/diary/mapper/DiaryMapper.kt b/app/src/main/java/com/egobook/app/domain/model/diary/mapper/DiaryMapper.kt index 188dff6..518ef61 100644 --- a/app/src/main/java/com/egobook/app/domain/model/diary/mapper/DiaryMapper.kt +++ b/app/src/main/java/com/egobook/app/domain/model/diary/mapper/DiaryMapper.kt @@ -3,14 +3,19 @@ package com.egobook.app.domain.model.diary.mapper import com.egobook.app.data.model.diary.request.DiaryCreateRequest import com.egobook.app.data.model.diary.request.DiaryUpdateRequest import com.egobook.app.data.model.diary.response.DiariesResponse +import com.egobook.app.data.model.diary.response.DiaryCreateResponse import com.egobook.app.data.model.diary.response.DiaryEntryResponse import com.egobook.app.data.model.diary.response.DiarySlice +import com.egobook.app.data.model.diary.response.Reward import com.egobook.app.domain.model.diary.entity.DayDiaries import com.egobook.app.domain.model.diary.entity.Diary import com.egobook.app.domain.model.diary.entity.DiaryFilter import com.egobook.app.domain.model.diary.entity.DiaryList +import com.egobook.app.domain.model.diary.entity.DiaryReward +import com.egobook.app.domain.model.diary.entity.DiaryRewards import com.egobook.app.domain.model.diary.entity.DiarySummary import com.egobook.app.domain.model.diary.entity.DiaryType +import com.egobook.app.domain.model.diary.entity.RewardType import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime @@ -20,8 +25,6 @@ import java.time.ZoneId * Data Layer ↔ Domain Layer 변환 Mapper */ object DiaryMapper { - - private val KST: ZoneId = ZoneId.of("Asia/Seoul") // ========== Response → Domain Entity ========== @@ -49,17 +52,23 @@ object DiaryMapper { createdAt = LocalDateTime.parse(createdAt) ) } - + /** - * UTC 시간 문자열을 KST LocalDateTime으로 변환 - * @param utcString ISO 8601 UTC 형식 (예: "2026-02-08T16:03:07.148Z") - * @return KST LocalDateTime + * DiaryCreateResponse → DiaryRewards */ - private fun parseUtcToKst(utcString: String): LocalDateTime { - val withZ = if (utcString.endsWith("Z")) utcString else "${utcString}Z" - return Instant.parse(withZ) - .atZone(KST) - .toLocalDateTime() + fun DiaryCreateResponse.toDiaryRewardsEntity(): DiaryRewards { + return DiaryRewards( + type = entry.type.map { DiaryType.from(it) }, + rewards = rewards.map { it.toDiaryRewardEntity() } + ) + } + + fun Reward.toDiaryRewardEntity(): DiaryReward { + return DiaryReward( + rewardType = RewardType.from(rewardType), + amount = amount, + message = message + ) } @@ -75,6 +84,8 @@ object DiaryMapper { ) } + + // ========== Domain Entity → Domain Entity ========== /** diff --git a/app/src/main/java/com/egobook/app/domain/repository/diary/DiaryRepository.kt b/app/src/main/java/com/egobook/app/domain/repository/diary/DiaryRepository.kt index d580712..47b1173 100644 --- a/app/src/main/java/com/egobook/app/domain/repository/diary/DiaryRepository.kt +++ b/app/src/main/java/com/egobook/app/domain/repository/diary/DiaryRepository.kt @@ -3,6 +3,7 @@ package com.egobook.app.domain.repository.diary import androidx.paging.PagingData import com.egobook.app.domain.model.diary.entity.Diary import com.egobook.app.domain.model.diary.entity.DiaryFilter +import com.egobook.app.domain.model.diary.entity.DiaryRewards import com.egobook.app.domain.model.diary.entity.DiarySummary import com.egobook.app.domain.model.diary.entity.DiaryType import kotlinx.coroutines.flow.Flow @@ -24,7 +25,7 @@ interface DiaryRepository { /** * 일기 생성 */ - suspend fun addDiary(diary: Diary): Result + suspend fun addDiary(diary: Diary): Result /** * 일기 수정 diff --git a/app/src/main/java/com/egobook/app/domain/usecase/diaryusecase/DiaryUseCases.kt b/app/src/main/java/com/egobook/app/domain/usecase/diaryusecase/DiaryUseCases.kt index 7658940..a45c87c 100644 --- a/app/src/main/java/com/egobook/app/domain/usecase/diaryusecase/DiaryUseCases.kt +++ b/app/src/main/java/com/egobook/app/domain/usecase/diaryusecase/DiaryUseCases.kt @@ -3,6 +3,7 @@ package com.egobook.app.domain.usecase.diaryusecase import androidx.paging.PagingData import com.egobook.app.domain.model.diary.entity.Diary import com.egobook.app.domain.model.diary.entity.DiaryFilter +import com.egobook.app.domain.model.diary.entity.DiaryRewards import com.egobook.app.domain.model.diary.entity.DiarySummary import com.egobook.app.domain.model.diary.entity.DiaryType import com.egobook.app.domain.repository.diary.DiaryRepository @@ -42,7 +43,7 @@ class GetDiary @Inject constructor( class AddDiary @Inject constructor( private val repository: DiaryRepository ) { - suspend operator fun invoke(diary: Diary): Result { + suspend operator fun invoke(diary: Diary): Result { return repository.addDiary(diary) } } diff --git a/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt b/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt index 63e7be6..f7354f6 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt @@ -1,9 +1,12 @@ package com.egobook.app.ui.diary.mapper import com.egobook.app.domain.model.diary.entity.Diary +import com.egobook.app.domain.model.diary.entity.DiaryRewards import com.egobook.app.domain.model.diary.entity.DiaryType +import com.egobook.app.domain.model.diary.entity.RewardType import java.time.LocalDate import java.time.LocalDateTime +import com.egobook.app.ui.diary.model.ToastMessage /** * Domain 모델과 UI 레이어 간의 데이터 변환을 담당하는 매퍼 @@ -28,8 +31,51 @@ object DiaryEntityMapper { } /** - * Domain Diary -> DiaryCheckFragment에 표시될 값..? 필요하면 정의하는 게 좋을 것 같은데.. + * Domain RewardType -> UI displayType("잉크", "감정조절", "긍정사고") */ + fun domainRewardTypeToUiDisplayType(rewardType: RewardType): String { + return rewardType.displayType + } + + fun domainRewardTypesToUiDisplayTypes(types: List): List { + return types.map { it.displayType } + } + + fun createToastMessages(rewards: DiaryRewards): List { + // 작성한 일기 타입 중 첫 번째를 대표로 사용 + val primaryDiaryType = rewards.type.firstOrNull() ?: DiaryType.EMOTION + + return rewards.rewards.map { reward -> + when (reward.rewardType) { + RewardType.INK -> ToastMessage( + rewardType = "INK", + message = "잉크를 ${reward.amount} 획득했어요", + amount = reward.amount + ) + else -> ToastMessage( + rewardType = "REWARD", + message = createRewardMessage(primaryDiaryType, reward.rewardType), + amount = reward.amount + ) + } + } + } + + /** + * STAT 보상 메시지 생성 + * "{일기타입} 일기를 작성하여\n{보상타입}가 상승했어요" + */ + private fun createRewardMessage( + diaryType: DiaryType, + rewardType: RewardType + ): String { + val diaryDisplay = diaryType.displayType // "감정", "고민", "칭찬", "감사" + val rewardDisplay = rewardType.displayType // "감정조절", "긍정사고" + + return "${diaryDisplay} 일기를 작성하여\n${rewardDisplay}가 상승했어요" + } + + // ========== UI -> Domain Entity ========== diff --git a/app/src/main/java/com/egobook/app/ui/diary/model/ToastMessage.kt b/app/src/main/java/com/egobook/app/ui/diary/model/ToastMessage.kt new file mode 100644 index 0000000..7e957d3 --- /dev/null +++ b/app/src/main/java/com/egobook/app/ui/diary/model/ToastMessage.kt @@ -0,0 +1,7 @@ +package com.egobook.app.ui.diary.model + +data class ToastMessage( + val rewardType: String, + val message: String, + val amount: Int, +) diff --git a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryWriteFragment.kt b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryWriteFragment.kt index ad8703e..9cce67f 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryWriteFragment.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryWriteFragment.kt @@ -1,5 +1,6 @@ package com.egobook.app.ui.diary.view +import android.graphics.Color import android.os.Bundle import android.text.Editable import android.text.InputFilter @@ -7,6 +8,7 @@ import android.text.TextWatcher import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.TextView import android.widget.Toast import androidx.annotation.DrawableRes import androidx.core.view.ViewCompat @@ -19,12 +21,14 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import com.egobook.app.R import com.egobook.app.databinding.FragmentDiaryWriteBinding +import com.egobook.app.ui.diary.model.ToastMessage +import com.egobook.app.ui.diary.viewmodel.DiaryWriteViewModel import com.egobook.app.ui.util.toDateTimeString import com.egobook.app.ui.util.toDayOfMonthString import com.egobook.app.ui.util.toMonthString import com.egobook.app.ui.util.toYearString -import com.egobook.app.ui.diary.viewmodel.DiaryWriteViewModel import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -86,7 +90,7 @@ class DiaryWriteFragment : Fragment() { setupDiaryContentEditText() // 일기 내용 입력 필드 설정 (글자수 제한, TextWatcher) observeSelectedDate() // 선택된 날짜 관찰 및 UI 업데이트 observeContentState() // 컨텐츠 상태 관찰 (글자수, 감정 섹션, 저장 버튼 활성화) - observeSaveSuccess() // 저장 성공/실패 관찰 + observeSaveResult() // 저장 성공/실패 관찰 } private fun setupDiaryTypeCards() { @@ -246,24 +250,105 @@ class DiaryWriteFragment : Fragment() { else -> R.drawable.img_emotion_neutral_unselected } } + - private fun observeSaveSuccess() { + private fun observeSaveResult() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.saveSuccess.collectLatest { success -> - if (success) { - // 저장 성공 - Toast.makeText(requireContext(), "일기가 저장되었습니다", Toast.LENGTH_SHORT).show() - findNavController().popBackStack() // 이전 화면으로 이동 - } else { - // 저장 실패 - Toast.makeText(requireContext(), "일기 저장에 실패했습니다", Toast.LENGTH_SHORT).show() + viewModel.saveResult.collectLatest { result -> + when (result) { + is DiaryWriteViewModel.SaveResult.Success -> { + // 저장 성공 -> 리워드 토스트 메시지 순차 표시 + showToastMessages(result.toastMessages) + findNavController().popBackStack() // 이전 화면으로 이동 + } + is DiaryWriteViewModel.SaveResult.Error -> { + // 저장 실패 + Toast.makeText(requireContext(), "일기 저장에 실패했습니다", Toast.LENGTH_SHORT).show() + } } } } } } + /** + * 토스트 메시지 리스트를 순차적으로 표시 + */ + private fun showToastMessages(messages: List) { + if (messages.isEmpty()) return + + messages.forEachIndexed { index, message -> + when (message.rewardType) { + "INK" -> showInkToast(message.amount) + "REWARD" -> showRewardToast(message.message) + } + + // 연속 토스트 사이에 딜레이 (마지막 제외) + if (index < messages.size - 1) { + Thread.sleep(2500) // 2.5초 딜레이 + } + } + } + + /** + * 잉크 토스트 표시 (toast_ink.xml) + */ + private fun showInkToast(amount: Int) { + val snackBar = Snackbar.make(requireView(), "", Snackbar.LENGTH_LONG) + val customView = layoutInflater.inflate(R.layout.toast_ink, null) + + // 메시지 설정 + val tvMessage = customView.findViewById(R.id.tv_message) + tvMessage.text = "잉크를 ${amount} 획득했어요" + + val layout = snackBar.view as ViewGroup + layout.setPadding(0, 0, 0, 0) + layout.setBackgroundColor(Color.TRANSPARENT) + layout.addView(customView, 0) + + // BottomNav에 붙이기 + val bottomNav = requireActivity().findViewById(R.id.bottom_navigation) + snackBar.anchorView = bottomNav + + // margin으로 띄우기 + val extra = (9 * resources.displayMetrics.density).toInt() + val params = snackBar.view.layoutParams as ViewGroup.MarginLayoutParams + params.bottomMargin += extra + snackBar.view.layoutParams = params + + snackBar.show() + } + + /** + * 리워드 토스트 표시 (toast_reward.xml) + */ + private fun showRewardToast(message: String) { + val snackBar = Snackbar.make(requireView(), "", Snackbar.LENGTH_LONG) + val customView = layoutInflater.inflate(R.layout.toast_reward, null) + + // 메시지 설정 + val tvMessage = customView.findViewById(R.id.tv_message) + tvMessage.text = message + + val layout = snackBar.view as ViewGroup + layout.setPadding(0, 0, 0, 0) + layout.setBackgroundColor(Color.TRANSPARENT) + layout.addView(customView, 0) + + // BottomNav에 붙이기 + val bottomNav = requireActivity().findViewById(R.id.bottom_navigation) + snackBar.anchorView = bottomNav + + // margin으로 띄우기 + val extra = (9 * resources.displayMetrics.density).toInt() + val params = snackBar.view.layoutParams as ViewGroup.MarginLayoutParams + params.bottomMargin += extra + snackBar.view.layoutParams = params + + snackBar.show() + } + override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiaryWriteViewModel.kt b/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiaryWriteViewModel.kt index b068a9b..09d7b92 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiaryWriteViewModel.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/viewmodel/DiaryWriteViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.egobook.app.domain.usecase.diaryusecase.DiaryUseCases import com.egobook.app.ui.diary.mapper.DiaryEntityMapper +import com.egobook.app.ui.diary.model.ToastMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -15,6 +16,8 @@ import kotlinx.coroutines.launch import java.time.LocalDate import java.time.LocalDateTime import javax.inject.Inject +import com.egobook.app.domain.model.diary.entity.DiaryRewards + @HiltViewModel class DiaryWriteViewModel @Inject constructor( @@ -28,9 +31,9 @@ class DiaryWriteViewModel @Inject constructor( private val _contentState = MutableStateFlow(ContentState()) val contentState = _contentState.asStateFlow() - // 저장 성공 여부를 전달하는 이벤트 Flow (일회성 이벤트) - private val _saveSuccess = MutableSharedFlow() - val saveSuccess = _saveSuccess.asSharedFlow() + // 저장 성공 여부 및 토스트 메시지를 전달하는 이벤트 Flow (일회성 이벤트) + private val _saveResult = MutableSharedFlow() + val saveResult = _saveResult.asSharedFlow() // 수정 모드인지 확인 (diaryId가 -1이 아니면 수정 모드) private val diaryId: Long = savedStateHandle.get("diaryId") ?: -1L @@ -143,54 +146,68 @@ class DiaryWriteViewModel @Inject constructor( viewModelScope.launch { val state = _contentState.value - // 수정 모드 vs 생성 모드 분기 - val result = if (isEditMode) { - // 수정 모드: updateDiary 호출 - val diaryTypes = DiaryEntityMapper.uiDisplayTypesToDomain(state.selectedTypes) - val emotionLevel = if (state.selectedTypes.contains("감정")) { - state.selectedEmotionLevel - } else { - null - } - val now = LocalDateTime.now() - - val updatedDiary = DiaryEntityMapper.createUpdatedDiary( - diaryId = diaryId, - selectedTypes = state.selectedTypes, - content = state.content, - emotionLevel = emotionLevel, - writtenAt = now // 실제 현재 시간으로(임시 삽입) - ) - - diaryUseCases.updateDiary( - diaryId = diaryId, - diary = updatedDiary - ) + if (isEditMode) { + // 수정 모드 처리 + saveEditMode(state) } else { - // 생성 모드: UI 상태를 Diary 엔티티로 변환 - val now = LocalDateTime.now() - - val newDiary = DiaryEntityMapper.createNewDiary( - selectedTypes = state.selectedTypes, - content = state.content, - emotionLevel = state.selectedEmotionLevel, - date = _selectedDate.value, // 선택된 날짜의 일기로 - writtenAt = now // 실제 현재 시간으로(임시 삽입) - ) - - // addDiary 호출 - diaryUseCases.addDiary(newDiary) + // 생성 모드 처리 + saveCreateMode(state) } + } + } - // 저장 결과 전달 - result.onSuccess { - _saveSuccess.emit(true) // 저장 성공 - }.onFailure { - _saveSuccess.emit(false) // 저장 실패 - } + /** + * 수정 모드 저장 처리 + */ + private suspend fun saveEditMode(state: ContentState) { + val emotionLevel = if (state.selectedTypes.contains("감정")) { + state.selectedEmotionLevel + } else { + null + } + val now = LocalDateTime.now() + + val updatedDiary = DiaryEntityMapper.createUpdatedDiary( + diaryId = diaryId, + selectedTypes = state.selectedTypes, + content = state.content, + emotionLevel = emotionLevel, + writtenAt = now + ) + + diaryUseCases.updateDiary( + diaryId = diaryId, + diary = updatedDiary + ).onSuccess { + _saveResult.emit(SaveResult.Success(emptyList())) + }.onFailure { error -> + _saveResult.emit(SaveResult.Error(error.message)) } } - + + /** + * 생성 모드 저장 처리 (토스트 메시지 포함) + */ + private suspend fun saveCreateMode(state: ContentState) { + val now = LocalDateTime.now() + + val newDiary = DiaryEntityMapper.createNewDiary( + selectedTypes = state.selectedTypes, + content = state.content, + emotionLevel = state.selectedEmotionLevel, + date = _selectedDate.value, + writtenAt = now + ) + + diaryUseCases.addDiary(newDiary) + .onSuccess { rewards -> + val messages = DiaryEntityMapper.createToastMessages(rewards) + _saveResult.emit(SaveResult.Success(messages)) + }.onFailure { error -> + _saveResult.emit(SaveResult.Error(error.message)) + } + } + /** * 저장 버튼 활성화 조건 체크 * 조건: 1) 하나 이상의 일기 타입 선택 && 2) 텍스트가 조금이라도 있음 @@ -199,6 +216,11 @@ class DiaryWriteViewModel @Inject constructor( return selectedTypes.isNotEmpty() && content.isNotBlank() } + sealed class SaveResult { + data class Success(val toastMessages: List) : SaveResult() + data class Error(val message: String?) : SaveResult() + } + sealed class ContentEvent { data class ToggleDiaryType(val value: String): ContentEvent() data class EnteredContent(val value: String): ContentEvent() diff --git a/app/src/main/res/layout/toast_ink.xml b/app/src/main/res/layout/toast_ink.xml new file mode 100644 index 0000000..9ec8f10 --- /dev/null +++ b/app/src/main/res/layout/toast_ink.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/toast_reward.xml b/app/src/main/res/layout/toast_reward.xml new file mode 100644 index 0000000..b11adf8 --- /dev/null +++ b/app/src/main/res/layout/toast_reward.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 792f913..cabebe2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,4 +29,6 @@ 하루에 최대 48번 기록 가능해요 + 잉크를 1 획득했어요 + 칭찬 일기를 작성하여\n긍정사고가 상승했어요 From aa58ebea795d99465190e1bed8f117746114f784 Mon Sep 17 00:00:00 2001 From: princehw03 Date: Thu, 19 Feb 2026 02:54:35 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20=EC=9E=89=ED=81=AC=20=ED=86=A0?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EC=95=88=20=EB=9C=A8=EB=8A=94=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/ui/diary/view/DiaryFragment.kt | 107 +++++++++++++++++- .../app/ui/diary/view/DiaryWriteFragment.kt | 90 ++------------- 2 files changed, 114 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt index 59782a6..2517fd9 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt @@ -5,6 +5,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.graphics.Color +import android.widget.TextView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle @@ -24,6 +25,10 @@ import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.egobook.app.ui.diary.model.ToastMessage import kotlin.getValue class DiaryFragment : Fragment() { private var _binding: FragmentDiaryBinding? = null @@ -51,10 +56,13 @@ import kotlin.getValue // 초기에는 GoToTop 버튼 숨김 binding.btnGoToTop.visibility = View.GONE - + // 캘린더에서 선택한 날짜가 있으면 적용 (없으면 마지막 선택 날짜 유지) applySelectedDateFromArgs() - + + // SavedStateHandle로부터 토스트 메시지 확인 및 표시 + checkAndShowToastMessages() + initViewPager() setupClickListener() observeViewModel() @@ -252,6 +260,101 @@ import kotlin.getValue snackBar.show() } + /** + * SavedStateHandle로부터 토스트 메시지 확인 및 표시 + */ + private fun checkAndShowToastMessages() { + val savedStateHandle = findNavController().currentBackStackEntry?.savedStateHandle + val jsonMessages = savedStateHandle?.get("toast_messages") + + if (!jsonMessages.isNullOrEmpty()) { + val type = object : TypeToken>() {}.type + val messages = Gson().fromJson>(jsonMessages, type) + showToastMessages(messages) + // 사용 후 삭제 (중복 표시 방지) + savedStateHandle.remove("toast_messages") + } + } + + /** + * 토스트 메시지 리스트를 순차적으로 표시 + */ + private fun showToastMessages(messages: List) { + if (messages.isEmpty()) return + + viewLifecycleOwner.lifecycleScope.launch { + messages.forEachIndexed { index, message -> + when (message.rewardType) { + "INK" -> showInkToast(message.amount) + "REWARD" -> showRewardToast(message.message) + } + + // 연속 토스트 사이에 딜레이 (마지막 제외) + if (index < messages.size - 1) { + delay(2500) // 2.5초 딜레이 + } + } + } + } + + /** + * 잉크 토스트 표시 (toast_ink.xml) + */ + private fun showInkToast(amount: Int) { + val snackBar = Snackbar.make(requireView(), "", Snackbar.LENGTH_LONG) + val customView = layoutInflater.inflate(R.layout.toast_ink, null) + + // 메시지 설정 + val tvMessage = customView.findViewById(R.id.tv_message) + tvMessage.text = "잉크를 $amount 획득했어요" + + val layout = snackBar.view as ViewGroup + layout.setPadding(0, 0, 0, 0) + layout.setBackgroundColor(Color.TRANSPARENT) + layout.addView(customView, 0) + + // BottomNav에 붙이기 + val bottomNav = requireActivity().findViewById(R.id.bottom_navigation) + snackBar.anchorView = bottomNav + + // margin으로 띄우기 + val extra = (9 * resources.displayMetrics.density).toInt() + val params = snackBar.view.layoutParams as ViewGroup.MarginLayoutParams + params.bottomMargin += extra + snackBar.view.layoutParams = params + + snackBar.show() + } + + /** + * 리워드 토스트 표시 (toast_reward.xml) + */ + private fun showRewardToast(message: String) { + val snackBar = Snackbar.make(requireView(), "", Snackbar.LENGTH_LONG) + val customView = layoutInflater.inflate(R.layout.toast_reward, null) + + // 메시지 설정 + val tvMessage = customView.findViewById(R.id.tv_message) + tvMessage.text = message + + val layout = snackBar.view as ViewGroup + layout.setPadding(0, 0, 0, 0) + layout.setBackgroundColor(Color.TRANSPARENT) + layout.addView(customView, 0) + + // BottomNav에 붙이기 + val bottomNav = requireActivity().findViewById(R.id.bottom_navigation) + snackBar.anchorView = bottomNav + + // margin으로 띄우기 + val extra = (9 * resources.displayMetrics.density).toInt() + val params = snackBar.view.layoutParams as ViewGroup.MarginLayoutParams + params.bottomMargin += extra + snackBar.view.layoutParams = params + + snackBar.show() + } + override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryWriteFragment.kt b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryWriteFragment.kt index 9cce67f..b87a5fc 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryWriteFragment.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryWriteFragment.kt @@ -28,7 +28,7 @@ import com.egobook.app.ui.util.toDayOfMonthString import com.egobook.app.ui.util.toMonthString import com.egobook.app.ui.util.toYearString import com.google.android.material.imageview.ShapeableImageView -import com.google.android.material.snackbar.Snackbar +import com.google.gson.Gson import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -258,9 +258,14 @@ class DiaryWriteFragment : Fragment() { viewModel.saveResult.collectLatest { result -> when (result) { is DiaryWriteViewModel.SaveResult.Success -> { - // 저장 성공 -> 리워드 토스트 메시지 순차 표시 - showToastMessages(result.toastMessages) - findNavController().popBackStack() // 이전 화면으로 이동 + // 저장 성공 -> 결과를 이전 화면(DiaryFragment)에 전달하고 이동 + val messages = result.toastMessages + if (messages.isNotEmpty()) { + // SavedStateHandle로 토스트 메시지 전달 (더 안정적) + val jsonMessages = Gson().toJson(messages) + findNavController().previousBackStackEntry?.savedStateHandle?.set("toast_messages", jsonMessages) + } + findNavController().popBackStack() } is DiaryWriteViewModel.SaveResult.Error -> { // 저장 실패 @@ -272,83 +277,6 @@ class DiaryWriteFragment : Fragment() { } } - /** - * 토스트 메시지 리스트를 순차적으로 표시 - */ - private fun showToastMessages(messages: List) { - if (messages.isEmpty()) return - - messages.forEachIndexed { index, message -> - when (message.rewardType) { - "INK" -> showInkToast(message.amount) - "REWARD" -> showRewardToast(message.message) - } - - // 연속 토스트 사이에 딜레이 (마지막 제외) - if (index < messages.size - 1) { - Thread.sleep(2500) // 2.5초 딜레이 - } - } - } - - /** - * 잉크 토스트 표시 (toast_ink.xml) - */ - private fun showInkToast(amount: Int) { - val snackBar = Snackbar.make(requireView(), "", Snackbar.LENGTH_LONG) - val customView = layoutInflater.inflate(R.layout.toast_ink, null) - - // 메시지 설정 - val tvMessage = customView.findViewById(R.id.tv_message) - tvMessage.text = "잉크를 ${amount} 획득했어요" - - val layout = snackBar.view as ViewGroup - layout.setPadding(0, 0, 0, 0) - layout.setBackgroundColor(Color.TRANSPARENT) - layout.addView(customView, 0) - - // BottomNav에 붙이기 - val bottomNav = requireActivity().findViewById(R.id.bottom_navigation) - snackBar.anchorView = bottomNav - - // margin으로 띄우기 - val extra = (9 * resources.displayMetrics.density).toInt() - val params = snackBar.view.layoutParams as ViewGroup.MarginLayoutParams - params.bottomMargin += extra - snackBar.view.layoutParams = params - - snackBar.show() - } - - /** - * 리워드 토스트 표시 (toast_reward.xml) - */ - private fun showRewardToast(message: String) { - val snackBar = Snackbar.make(requireView(), "", Snackbar.LENGTH_LONG) - val customView = layoutInflater.inflate(R.layout.toast_reward, null) - - // 메시지 설정 - val tvMessage = customView.findViewById(R.id.tv_message) - tvMessage.text = message - - val layout = snackBar.view as ViewGroup - layout.setPadding(0, 0, 0, 0) - layout.setBackgroundColor(Color.TRANSPARENT) - layout.addView(customView, 0) - - // BottomNav에 붙이기 - val bottomNav = requireActivity().findViewById(R.id.bottom_navigation) - snackBar.anchorView = bottomNav - - // margin으로 띄우기 - val extra = (9 * resources.displayMetrics.density).toInt() - val params = snackBar.view.layoutParams as ViewGroup.MarginLayoutParams - params.bottomMargin += extra - snackBar.view.layoutParams = params - - snackBar.show() - } - override fun onDestroyView() { super.onDestroyView() _binding = null From 8053efb7047385a73ab0d37f7192f36eddcbae4b Mon Sep 17 00:00:00 2001 From: princehw03 Date: Thu, 19 Feb 2026 03:16:36 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat:=20=EC=9D=BC=EA=B8=B0=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=94=EC=84=B8=EC=A7=80=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/ui/diary/mapper/DiaryEntityMapper.kt | 24 +++++++++++++++++-- .../app/ui/diary/model/ToastMessage.kt | 1 + .../app/ui/diary/view/DiaryFragment.kt | 17 +++++++++---- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt b/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt index f7354f6..c23030e 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt @@ -1,5 +1,6 @@ package com.egobook.app.ui.diary.mapper +import com.egobook.app.R import com.egobook.app.domain.model.diary.entity.Diary import com.egobook.app.domain.model.diary.entity.DiaryRewards import com.egobook.app.domain.model.diary.entity.DiaryType @@ -45,22 +46,41 @@ object DiaryEntityMapper { // 작성한 일기 타입 중 첫 번째를 대표로 사용 val primaryDiaryType = rewards.type.firstOrNull() ?: DiaryType.EMOTION + // 일기 타입에 따른 이미지 결정 + val rewardImageRes = getRewardImageResForDiaryType(primaryDiaryType) + return rewards.rewards.map { reward -> when (reward.rewardType) { RewardType.INK -> ToastMessage( rewardType = "INK", message = "잉크를 ${reward.amount} 획득했어요", - amount = reward.amount + amount = reward.amount, + imageRes = R.drawable.ink_icon // 잉크는 기본 잉크 아이콘 ) else -> ToastMessage( rewardType = "REWARD", message = createRewardMessage(primaryDiaryType, reward.rewardType), - amount = reward.amount + amount = reward.amount, + imageRes = rewardImageRes // 일기 타입에 따른 이미지 ) } } } + /** + * 일기 타입에 따른 리워드 토스트 이미지 결정 + * - 칭찬, 감사 → ic_radar_sun + * - 고민 → ic_radar_star + * - 감정 → ic_radar_sun (기본값) + */ + private fun getRewardImageResForDiaryType(diaryType: DiaryType): Int { + return when (diaryType) { + DiaryType.PRAISE, DiaryType.GRATITUDE -> R.drawable.ic_radar_sun + DiaryType.CONCERN -> R.drawable.ic_radar_star + DiaryType.EMOTION -> R.drawable.ic_radar_sun + } + } + /** * STAT 보상 메시지 생성 * "{일기타입} 일기를 작성하여\n{보상타입}가 상승했어요" diff --git a/app/src/main/java/com/egobook/app/ui/diary/model/ToastMessage.kt b/app/src/main/java/com/egobook/app/ui/diary/model/ToastMessage.kt index 7e957d3..cd1cb6d 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/model/ToastMessage.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/model/ToastMessage.kt @@ -4,4 +4,5 @@ data class ToastMessage( val rewardType: String, val message: String, val amount: Int, + val imageRes: Int, // 토스트에 표시될 이미지 리소스 ID ) diff --git a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt index 2517fd9..2b55d85 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt @@ -5,6 +5,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.graphics.Color +import android.widget.ImageView import android.widget.TextView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -285,8 +286,8 @@ import kotlin.getValue viewLifecycleOwner.lifecycleScope.launch { messages.forEachIndexed { index, message -> when (message.rewardType) { - "INK" -> showInkToast(message.amount) - "REWARD" -> showRewardToast(message.message) + "INK" -> showInkToast(message.amount, message.imageRes) + "REWARD" -> showRewardToast(message.message, message.imageRes) } // 연속 토스트 사이에 딜레이 (마지막 제외) @@ -300,7 +301,7 @@ import kotlin.getValue /** * 잉크 토스트 표시 (toast_ink.xml) */ - private fun showInkToast(amount: Int) { + private fun showInkToast(amount: Int, imageRes: Int) { val snackBar = Snackbar.make(requireView(), "", Snackbar.LENGTH_LONG) val customView = layoutInflater.inflate(R.layout.toast_ink, null) @@ -308,6 +309,10 @@ import kotlin.getValue val tvMessage = customView.findViewById(R.id.tv_message) tvMessage.text = "잉크를 $amount 획득했어요" + // 이미지 설정 + val ivInk = customView.findViewById(R.id.iv_ink) + ivInk.setImageResource(imageRes) + val layout = snackBar.view as ViewGroup layout.setPadding(0, 0, 0, 0) layout.setBackgroundColor(Color.TRANSPARENT) @@ -329,7 +334,7 @@ import kotlin.getValue /** * 리워드 토스트 표시 (toast_reward.xml) */ - private fun showRewardToast(message: String) { + private fun showRewardToast(message: String, imageRes: Int) { val snackBar = Snackbar.make(requireView(), "", Snackbar.LENGTH_LONG) val customView = layoutInflater.inflate(R.layout.toast_reward, null) @@ -337,6 +342,10 @@ import kotlin.getValue val tvMessage = customView.findViewById(R.id.tv_message) tvMessage.text = message + // 이미지 설정 + val ivSun = customView.findViewById(R.id.iv_sun) + ivSun.setImageResource(imageRes) + val layout = snackBar.view as ViewGroup layout.setPadding(0, 0, 0, 0) layout.setBackgroundColor(Color.TRANSPARENT) From 0042eb155439434777b7f0e8b2e71b3a05031bf3 Mon Sep 17 00:00:00 2001 From: princehw03 Date: Thu, 19 Feb 2026 03:21:51 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat:=20=EB=B0=9B=EC=B9=A8=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=A1=B0=EC=82=AC=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/ui/diary/mapper/DiaryEntityMapper.kt | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt b/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt index c23030e..31acb8d 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt @@ -83,7 +83,9 @@ object DiaryEntityMapper { /** * STAT 보상 메시지 생성 - * "{일기타입} 일기를 작성하여\n{보상타입}가 상승했어요" + * "{일기타입} 일기를 작성하여\n{보상타입}[이/가] 상승했어요" + * - 받침 있음(감정조절) → "이" + * - 받침 없음(긍정사고) → "가" */ private fun createRewardMessage( diaryType: DiaryType, @@ -91,12 +93,23 @@ object DiaryEntityMapper { ): String { val diaryDisplay = diaryType.displayType // "감정", "고민", "칭찬", "감사" val rewardDisplay = rewardType.displayType // "감정조절", "긍정사고" + + // 받침 여부에 따라 조사 결정 + val particle = if (hasFinalConsonant(rewardDisplay)) "이" else "가" - return "${diaryDisplay} 일기를 작성하여\n${rewardDisplay}가 상승했어요" + return "${diaryDisplay} 일기를 작성하여\n${rewardDisplay}${particle} 상승했어요" + } + + /** + * 한글 받침(종성) 여부 확인 + */ + private fun hasFinalConsonant(text: String): Boolean { + if (text.isEmpty()) return false + val lastChar = text.last() + // 한글 완성형 범위: 0xAC00 ~ 0xD7A3 + // 받침 있음: (code - 0xAC00) % 28 != 0 + return lastChar.code in 0xAC00..0xD7A3 && (lastChar.code - 0xAC00) % 28 != 0 } - - - // ========== UI -> Domain Entity ========== From 621c6a981bccd746ea2b9383b7aa17c25a7a78f3 Mon Sep 17 00:00:00 2001 From: princehw03 Date: Thu, 19 Feb 2026 03:39:38 +0900 Subject: [PATCH 11/11] =?UTF-8?q?refactor:=20=ED=86=A0=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=A9=94=EC=84=B8=EC=A7=80=EB=A5=BC=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=9D=98=20=EC=9D=91=EB=8B=B5=20=EA=B0=92=EC=9D=84=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=EC=9C=BC=EB=A1=9C=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../egobook/app/ui/diary/mapper/DiaryEntityMapper.kt | 12 +++++------- .../com/egobook/app/ui/diary/view/DiaryFragment.kt | 10 +++++----- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt b/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt index 31acb8d..6aa0e39 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/mapper/DiaryEntityMapper.kt @@ -43,25 +43,23 @@ object DiaryEntityMapper { } fun createToastMessages(rewards: DiaryRewards): List { - // 작성한 일기 타입 중 첫 번째를 대표로 사용 + // 작성한 일기 타입 중 첫 번째를 대표로 사용 (이미지 매핑용) val primaryDiaryType = rewards.type.firstOrNull() ?: DiaryType.EMOTION - - // 일기 타입에 따른 이미지 결정 val rewardImageRes = getRewardImageResForDiaryType(primaryDiaryType) return rewards.rewards.map { reward -> when (reward.rewardType) { RewardType.INK -> ToastMessage( rewardType = "INK", - message = "잉크를 ${reward.amount} 획득했어요", + message = reward.message, // 서버에서 내려준 메시지 그대로 사용 amount = reward.amount, - imageRes = R.drawable.ink_icon // 잉크는 기본 잉크 아이콘 + imageRes = R.drawable.ink_icon ) else -> ToastMessage( rewardType = "REWARD", - message = createRewardMessage(primaryDiaryType, reward.rewardType), + message = reward.message, // 서버에서 내려준 메시지 그대로 사용 amount = reward.amount, - imageRes = rewardImageRes // 일기 타입에 따른 이미지 + imageRes = rewardImageRes ) } } diff --git a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt index 2b55d85..9048f58 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt @@ -1,5 +1,5 @@ package com.egobook.app.ui.diary.view - + import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -286,7 +286,7 @@ import kotlin.getValue viewLifecycleOwner.lifecycleScope.launch { messages.forEachIndexed { index, message -> when (message.rewardType) { - "INK" -> showInkToast(message.amount, message.imageRes) + "INK" -> showInkToast(message.message, message.imageRes) "REWARD" -> showRewardToast(message.message, message.imageRes) } @@ -301,13 +301,13 @@ import kotlin.getValue /** * 잉크 토스트 표시 (toast_ink.xml) */ - private fun showInkToast(amount: Int, imageRes: Int) { + private fun showInkToast(message: String, imageRes: Int) { val snackBar = Snackbar.make(requireView(), "", Snackbar.LENGTH_LONG) val customView = layoutInflater.inflate(R.layout.toast_ink, null) - // 메시지 설정 + // 메시지 설정 (서버에서 내려준 메시지 그대로 사용) val tvMessage = customView.findViewById(R.id.tv_message) - tvMessage.text = "잉크를 $amount 획득했어요" + tvMessage.text = message // 이미지 설정 val ivInk = customView.findViewById(R.id.iv_ink)