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 beeb7843..0c3c6bd9 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/data/repository/auth/AuthRepositoryImpl.kt b/app/src/main/java/com/egobook/app/data/repository/auth/AuthRepositoryImpl.kt index 709aaabb..f74c46b7 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 { @@ -112,12 +111,12 @@ class AuthRepositoryImpl @Inject constructor( } } - //구글 로그인 시 사용 + // 구글 로그인 시 사용 override suspend fun refreshTokens(idToken: String): Result { // 액세스 토큰 가져오기 (없으면 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/repository/diary/DiaryRepositoryImpl.kt b/app/src/main/java/com/egobook/app/data/repository/diary/DiaryRepositoryImpl.kt index 68208d32..17ee9808 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/data/util/ApiResponseExt.kt b/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt index 7d215d6a..dc19bd18 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,37 @@ 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 + + +/** + * 로그인/회원가입 전용 에러 매핑 함수 + */ +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) + } +} + +/** + * 로그인/회원가입 전용 Result로 변환 확장 함수 + */ +fun ApiResponse.toAuthResult(): Result { + return if (this.status == 200) { + Result.success(this.data) + } else { + Result.failure(this.toAuthError()) + } +} /** @@ -10,7 +41,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 +52,40 @@ 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: 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) { + Result.failure(AuthError.Unknown(e.message)) + } +} + /** * API 호출을 안전하게 실행하는 헬퍼 함수 */ @@ -66,13 +124,15 @@ 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)) + Result.failure(response.toAuthError()) } + } catch (e: IOException) { + Result.failure(AuthError.NetworkError()) } catch (e: Exception) { - Result.failure(e) + Result.failure(AuthError.Unknown(e.message)) } } @@ -81,7 +141,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)) @@ -99,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 new file mode 100644 index 00000000..d8d96cf9 --- /dev/null +++ b/app/src/main/java/com/egobook/app/domain/model/auth/AuthError.kt @@ -0,0 +1,34 @@ +package com.egobook.app.domain.model.auth + +sealed class AuthError( + override val message: String? = null +) : Throwable(message) { + + //400 + class BadRequest : + AuthError("잘못된 요청입니다.") + + //401 + class InvalidCredentials : + AuthError("유효하지 않은 계정입니다.") + + //403 + class WaitDelete : + AuthError("탈퇴 처리 중인 계정입니다. 관리자에게 문의하세요.") + + //409 + class UserAlreadyExists : + AuthError("이미 가입된 구글 계정입니다.") + + //404 + class UserNotFound : + AuthError("존재하지 않는 사용자입니다.") + + //미정 + class NetworkError : + AuthError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.") + + //else + class Unknown(message: String?) : + AuthError(message) +} \ No newline at end of file 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 27c38778..8f8992a9 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 00000000..2894d7a2 --- /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 00000000..7be31555 --- /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 00000000..3452a3a2 --- /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 188dff63..518ef615 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 d5807126..47b11732 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 76589403..a45c87cf 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/account/viewmodel/AccountViewModel.kt b/app/src/main/java/com/egobook/app/ui/account/viewmodel/AccountViewModel.kt index 05e9937a..7ad51c7c 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) } } } 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 63e7be6a..6aa0e396 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,13 @@ 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 +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,9 +32,82 @@ 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 + val rewardImageRes = getRewardImageResForDiaryType(primaryDiaryType) + + return rewards.rewards.map { reward -> + when (reward.rewardType) { + RewardType.INK -> ToastMessage( + rewardType = "INK", + message = reward.message, // 서버에서 내려준 메시지 그대로 사용 + amount = reward.amount, + imageRes = R.drawable.ink_icon + ) + else -> ToastMessage( + rewardType = "REWARD", + message = reward.message, // 서버에서 내려준 메시지 그대로 사용 + 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{보상타입}[이/가] 상승했어요" + * - 받침 있음(감정조절) → "이" + * - 받침 없음(긍정사고) → "가" + */ + private fun createRewardMessage( + diaryType: DiaryType, + rewardType: RewardType + ): String { + val diaryDisplay = diaryType.displayType // "감정", "고민", "칭찬", "감사" + val rewardDisplay = rewardType.displayType // "감정조절", "긍정사고" + + // 받침 여부에 따라 조사 결정 + val particle = if (hasFinalConsonant(rewardDisplay)) "이" else "가" + 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 ========== 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 00000000..cd1cb6d2 --- /dev/null +++ b/app/src/main/java/com/egobook/app/ui/diary/model/ToastMessage.kt @@ -0,0 +1,8 @@ +package com.egobook.app.ui.diary.model + +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/CalenderFragment.kt b/app/src/main/java/com/egobook/app/ui/diary/view/CalenderFragment.kt index a13bfe15..a050e6f2 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) + + } /** 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 59782a64..9048f58b 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,10 +1,12 @@ package com.egobook.app.ui.diary.view - + import android.os.Bundle 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 import androidx.lifecycle.Lifecycle @@ -24,6 +26,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 +57,13 @@ import kotlin.getValue // 초기에는 GoToTop 버튼 숨김 binding.btnGoToTop.visibility = View.GONE - + // 캘린더에서 선택한 날짜가 있으면 적용 (없으면 마지막 선택 날짜 유지) applySelectedDateFromArgs() - + + // SavedStateHandle로부터 토스트 메시지 확인 및 표시 + checkAndShowToastMessages() + initViewPager() setupClickListener() observeViewModel() @@ -252,6 +261,109 @@ 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.message, message.imageRes) + "REWARD" -> showRewardToast(message.message, message.imageRes) + } + + // 연속 토스트 사이에 딜레이 (마지막 제외) + if (index < messages.size - 1) { + delay(2500) // 2.5초 딜레이 + } + } + } + } + + /** + * 잉크 토스트 표시 (toast_ink.xml) + */ + 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 = message + + // 이미지 설정 + 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) + 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, imageRes: Int) { + 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 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) + 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 ad8703ee..b87a5fca 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.gson.Gson 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,18 +250,27 @@ 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 -> { + // 저장 성공 -> 결과를 이전 화면(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 -> { + // 저장 실패 + Toast.makeText(requireContext(), "일기 저장에 실패했습니다", Toast.LENGTH_SHORT).show() + } } } } 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 b068a9b2..09d7b92c 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/java/com/egobook/app/ui/login/view/LoginActivity.kt b/app/src/main/java/com/egobook/app/ui/login/view/LoginActivity.kt index a79200c0..2bd7c9b0 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,310 +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() - 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 navigateToMain() { - val intent = Intent(this, MainActivity::class.java) - startActivity(intent) - finish() - } + private fun navigateToOnboarding() { + val intent = Intent(this, OnboardingActivity::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) -//========================================특정 char 폰트 커스텀=========================================================== -private fun setupGuideText() { - val fullText = "시작 시 이용약관 및\n개인정보 수집 및 이용에 동의하게 됩니다" - val spannableString = SpannableString(fullText) + val semiBoldTypeface = ResourcesCompat.getFont(this, R.font.arita_semibold) ?: return - val semiBoldTypeface = ResourcesCompat.getFont(this, R.font.arita_semibold) ?: return + // ================= 이용약관 ================= + val termText = "이용약관" + val termStart = fullText.indexOf(termText) + if (termStart >= 0) { + val termEnd = termStart + termText.length - // ================= 이용약관 ================= - val termText = "이용약관" - val termStart = fullText.indexOf(termText) - if (termStart >= 0) { - val termEnd = termStart + termText.length + spannableString.setSpan( + CustomTypefaceSpan(semiBoldTypeface), + termStart, termEnd, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) - spannableString.setSpan( - CustomTypefaceSpan(semiBoldTypeface), - 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) + } - 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) - } + override fun updateDrawState(ds: TextPaint) { + ds.isUnderlineText = false + } + }, termStart, termEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } - override fun updateDrawState(ds: TextPaint) { - ds.isUnderlineText = false - } - }, termStart, termEnd, 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) + } - // ================= 개인정보 ================= - 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 + } + }, privacyStart, privacyEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } - override fun updateDrawState(ds: TextPaint) { - ds.isUnderlineText = false - } - }, privacyStart, privacyEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + binding.tvStartGuide.text = spannableString + binding.tvStartGuide.movementMethod = LinkMovementMethod.getInstance() + binding.tvStartGuide.highlightColor = Color.TRANSPARENT } - 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) + } - //모든 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 0f4fc102..4de4f690 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,9 +26,6 @@ class LoginViewModel @Inject constructor( private val _isGuestSignUp = MutableSharedFlow() val isGuestSignUp = _isGuestSignUp.asSharedFlow() - private val _signUpError = MutableSharedFlow() - val signUpError = _signUpError.asSharedFlow() - private val _loginState = MutableStateFlow(LoginState.Idle) val loginState = _loginState.asStateFlow() @@ -42,17 +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 -> - _signUpError.emit(error.message ?: "알 수 없는 오류") - _loginState.value = LoginState.Idle // 로딩 해제 - } - ) + + 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) + } + ) } } //구글 로그인 시도 -> 토큰 재발급 & 로그인 타입 저장 @@ -67,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) } ) } @@ -101,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) } ) } @@ -123,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 diff --git a/app/src/main/res/layout/activity_intro.xml b/app/src/main/res/layout/activity_intro.xml index c7e348c7..abde9ad3 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 1392f5d7..6be55f94 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 7333aee2..f365d983 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"/> + + + + 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 00000000..9ec8f107 --- /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 00000000..b11adf80 --- /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 792f913a..cabebe25 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,4 +29,6 @@ 하루에 최대 48번 기록 가능해요 + 잉크를 1 획득했어요 + 칭찬 일기를 작성하여\n긍정사고가 상승했어요