diff --git a/core/common/src/main/res/values/strings.xml b/core/common/src/main/res/values/strings.xml index 18eef152..d80588ea 100644 --- a/core/common/src/main/res/values/strings.xml +++ b/core/common/src/main/res/values/strings.xml @@ -15,4 +15,8 @@ LCK testtesttesttesttesttesttesttesttesttesttesttesttesttest + + + 네트워크 연결이 원활하지 않습니다 + 알 수 없는 오류가 발생하였습니다 diff --git a/core/designsystem/src/main/java/com/teamwable/designsystem/component/indicator/WableCircularIndicator.kt b/core/designsystem/src/main/java/com/teamwable/designsystem/component/indicator/WableCircularIndicator.kt new file mode 100644 index 00000000..83825bf4 --- /dev/null +++ b/core/designsystem/src/main/java/com/teamwable/designsystem/component/indicator/WableCircularIndicator.kt @@ -0,0 +1,31 @@ +package com.teamwable.designsystem.component.indicator + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.teamwable.designsystem.theme.WableTheme + +@Composable +fun WableCircularIndicator( + modifier: Modifier = Modifier, + size: Dp = 32.dp, + color: Color = WableTheme.colors.info, + strokeWidth: Dp = 2.dp, + trackColor: Color = WableTheme.colors.progressBackground, +) { + CircularProgressIndicator( + modifier = modifier + .size(size) + .padding(5.dp), + color = color, + strokeWidth = strokeWidth, + strokeCap = StrokeCap.Round, + trackColor = trackColor, + ) +} diff --git a/core/designsystem/src/main/java/com/teamwable/designsystem/component/snackbar/WableSnackBar.kt b/core/designsystem/src/main/java/com/teamwable/designsystem/component/snackbar/WableSnackBar.kt new file mode 100644 index 00000000..acbb9751 --- /dev/null +++ b/core/designsystem/src/main/java/com/teamwable/designsystem/component/snackbar/WableSnackBar.kt @@ -0,0 +1,112 @@ +package com.teamwable.designsystem.component.snackbar + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.teamwable.designsystem.R +import com.teamwable.designsystem.component.indicator.WableCircularIndicator +import com.teamwable.designsystem.extension.modifier.dropShadow +import com.teamwable.designsystem.theme.WableTheme +import com.teamwable.designsystem.type.SnackBarType + +const val SNACK_BAR_DURATION = 2000L + +@Composable +fun WableSnackBar( + modifier: Modifier = Modifier, + message: String = "", + snackBarType: SnackBarType = SnackBarType.ERROR, + onClick: () -> Unit = {}, +) { + Box( + modifier = modifier + .padding(horizontal = 18.dp) + .fillMaxWidth() + .border( + width = 1.dp, + color = WableTheme.colors.gray200, + shape = RoundedCornerShape(dimensionResource(id = R.dimen.radius_8)), + ) + .dropShadow( + shape = RoundedCornerShape(dimensionResource(id = R.dimen.radius_8)), + color = Color.Black.copy(alpha = 0.12f), + blur = 4.dp, + offsetX = 0.dp, + offsetY = 2.dp, + spread = 0.dp, + ) + .background( + color = WableTheme.colors.gray100.copy(alpha = 0.9f), + shape = RoundedCornerShape(dimensionResource(id = R.dimen.radius_8)), + ), + contentAlignment = Alignment.CenterStart, + ) { + WableSnackBarContent( + snackBarType = snackBarType, + message = message, + ) + } +} + +@Composable +fun WableSnackBarContent( + snackBarType: SnackBarType, + message: String, +) { + Row( + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when (snackBarType) { + SnackBarType.LOADING, SnackBarType.LOADING_PROFILE -> WableCircularIndicator() + else -> WableSnackBarImage(snackBarType) + } + Text( + text = when (snackBarType) { + SnackBarType.LOADING -> stringResource(id = R.string.snackbar_text_loading) + SnackBarType.LOADING_PROFILE -> stringResource(id = R.string.snackbar_text_profile_loading) + else -> message + }, + textAlign = TextAlign.Start, + color = WableTheme.colors.black, + style = WableTheme.typography.body03, + modifier = Modifier.padding(start = 6.dp), + ) + } +} + +@Composable +fun WableSnackBarImage(snackBarType: SnackBarType) { + Image( + painter = painterResource(id = snackBarType.image), + contentDescription = null, + ) +} + +@Preview(showBackground = true) +@Composable +fun WableButtonDialogPreview() { + WableTheme { + WableSnackBar( + snackBarType = SnackBarType.SUCCESS, + message = "프로필 사진을 5MB 이하인 사진으로 바꿔주세요", + ) + } +} diff --git a/core/designsystem/src/main/java/com/teamwable/designsystem/component/snackbar/WableSnackBarPopUp.kt b/core/designsystem/src/main/java/com/teamwable/designsystem/component/snackbar/WableSnackBarPopUp.kt new file mode 100644 index 00000000..6db02be4 --- /dev/null +++ b/core/designsystem/src/main/java/com/teamwable/designsystem/component/snackbar/WableSnackBarPopUp.kt @@ -0,0 +1,58 @@ +package com.teamwable.designsystem.component.snackbar + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.teamwable.designsystem.theme.WableTheme +import com.teamwable.designsystem.type.SnackBarType + +@Composable +fun WableSnackBarPopUp( + isVisible: Boolean, + snackBarType: SnackBarType = SnackBarType.LOADING, + onDismissRequest: () -> Unit = {}, +) { + if (isVisible) { + Popup( + onDismissRequest = onDismissRequest, + properties = PopupProperties( + focusable = false, + dismissOnBackPress = true, + dismissOnClickOutside = false, + excludeFromSystemGesture = true, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(WableTheme.colors.white.copy(alpha = 0.5f)), + ) { + WableSnackBar( + snackBarType = snackBarType, + modifier = Modifier + .fillMaxWidth() + .padding(top = 30.dp), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun WableSnackBarPopUpPreview() { + WableTheme { + WableSnackBarPopUp( + isVisible = true, + snackBarType = SnackBarType.LOADING, + ) + } +} diff --git a/core/designsystem/src/main/java/com/teamwable/designsystem/extension/modifier/ModifierExt.kt b/core/designsystem/src/main/java/com/teamwable/designsystem/extension/modifier/ModifierExt.kt index f497359a..f64a5259 100644 --- a/core/designsystem/src/main/java/com/teamwable/designsystem/extension/modifier/ModifierExt.kt +++ b/core/designsystem/src/main/java/com/teamwable/designsystem/extension/modifier/ModifierExt.kt @@ -1,5 +1,6 @@ package com.teamwable.designsystem.extension.modifier +import android.graphics.BlurMaskFilter import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.Composable @@ -10,7 +11,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -46,3 +56,35 @@ fun Modifier.noRippleDebounceClickable( true } } + +@Composable +fun Modifier.dropShadow( + shape: Shape, + color: Color = Color.Black.copy(alpha = 0.25f), + blur: Dp = 4.dp, + offsetY: Dp = 4.dp, + offsetX: Dp = 0.dp, + spread: Dp = 0.dp, +) = this.drawBehind { + val spreadPx = spread.toPx() + val shadowSize = Size(size.width + spreadPx, size.height + spreadPx) + + val shadowOutline = shape.createOutline(shadowSize, layoutDirection, this) + + val paint = Paint().apply { + this.color = color + } + + if (blur.toPx() > 0) { + paint.asFrameworkPaint().apply { + maskFilter = BlurMaskFilter(blur.toPx(), BlurMaskFilter.Blur.NORMAL) + } + } + + drawIntoCanvas { canvas -> + canvas.save() + canvas.translate(offsetX.toPx() - spreadPx / 2, offsetY.toPx() - spreadPx / 2) + canvas.drawOutline(shadowOutline, paint) + canvas.restore() + } +} diff --git a/core/designsystem/src/main/java/com/teamwable/designsystem/theme/Color.kt b/core/designsystem/src/main/java/com/teamwable/designsystem/theme/Color.kt index 829184a4..be98e899 100644 --- a/core/designsystem/src/main/java/com/teamwable/designsystem/theme/Color.kt +++ b/core/designsystem/src/main/java/com/teamwable/designsystem/theme/Color.kt @@ -22,6 +22,7 @@ val Warning = Color(0xFFEE9209) val Error = Color(0xFFF01F1F) val SystemNavigationBar = Color(0xFFF5F5F5) val SystemLoginSystemAppBar = Color(0xFFEBE2FD) +val ProgressBackground = Color(0xFFCADFF7) // Gray Scale val Black = Color(0xFF0E0E0E) @@ -100,6 +101,7 @@ class WableColors( kdf10: Color, hle50: Color, hle10: Color, + progressBackground: Color, ) { var purple50 by mutableStateOf(purple50) private set @@ -181,6 +183,8 @@ class WableColors( private set var hle10 by mutableStateOf(hle10) private set + var progressBackground by mutableStateOf(progressBackground) + private set fun copy(): WableColors = WableColors( purple50, @@ -223,6 +227,7 @@ class WableColors( kdf10, hle50, hle10, + progressBackground, ) fun update(other: WableColors) { @@ -266,6 +271,7 @@ class WableColors( kdf10 = other.kdf10 hle50 = other.hle50 hle10 = other.hle10 + progressBackground = other.progressBackground } } @@ -310,6 +316,7 @@ fun wableLightColors( kdf10: Color = Kdf10, hle50: Color = Hle50, hle10: Color = Hle10, + progressBackground: Color = ProgressBackground, ) = WableColors( purple50, purple100, @@ -351,4 +358,5 @@ fun wableLightColors( kdf10, hle50, hle10, + progressBackground, ) diff --git a/core/designsystem/src/main/java/com/teamwable/designsystem/type/SnackBarType.kt b/core/designsystem/src/main/java/com/teamwable/designsystem/type/SnackBarType.kt new file mode 100644 index 00000000..3aa0dae1 --- /dev/null +++ b/core/designsystem/src/main/java/com/teamwable/designsystem/type/SnackBarType.kt @@ -0,0 +1,16 @@ +package com.teamwable.designsystem.type + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.teamwable.designsystem.R + +enum class SnackBarType( + @DrawableRes val image: Int = com.teamwable.common.R.drawable.ic_home_toast_success, + @StringRes val message: Int = R.string.empty, +) { + WARNING(image = com.teamwable.common.R.drawable.ic_home_toast_warning), + ERROR(image = com.teamwable.common.R.drawable.ic_home_toast_error), + SUCCESS(image = com.teamwable.common.R.drawable.ic_home_toast_success), + LOADING(message = R.string.snackbar_text_loading), + LOADING_PROFILE(message = R.string.snackbar_text_loading), +} diff --git a/core/designsystem/src/main/res/values/dimens.xml b/core/designsystem/src/main/res/values/dimens.xml index 4a8b17b2..8769f704 100644 --- a/core/designsystem/src/main/res/values/dimens.xml +++ b/core/designsystem/src/main/res/values/dimens.xml @@ -1,4 +1,5 @@ + 8dp 12dp diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index 52be1d24..d3151e74 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -29,4 +29,8 @@ + + 회원가입이 거의 마무리 되었어요.\n잠시만 기다려주세요! + 프로필 수정이 거의 마무리 되었어요.\n잠시만 기다려주세요! + diff --git a/core/network/src/main/java/com/teamwable/network/TokenInterceptor.kt b/core/network/src/main/java/com/teamwable/network/TokenInterceptor.kt index 9b5decbb..0386bbd1 100644 --- a/core/network/src/main/java/com/teamwable/network/TokenInterceptor.kt +++ b/core/network/src/main/java/com/teamwable/network/TokenInterceptor.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response @@ -25,6 +26,7 @@ class TokenInterceptor @Inject constructor( private val authService: AuthService, ) : Interceptor { private val mutex = Mutex() + private var currentToast: Toast? = null override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() @@ -70,17 +72,27 @@ class TokenInterceptor @Inject constructor( } } - private fun handleFailedTokenReissue() = with(context) { - CoroutineScope(Dispatchers.Main).launch { + private fun handleFailedTokenReissue() = CoroutineScope(Dispatchers.Main).launch { + showToast() + withContext(Dispatchers.IO) { defaultWablePreferenceDatasource.clear() + } + restartActivity() + } + + private fun showToast() { + currentToast?.cancel() + currentToast = Toast.makeText(context, "재 로그인이 필요해요", Toast.LENGTH_SHORT) + currentToast?.show() + } + + private suspend fun restartActivity() = with(context) { + mutex.withLock { startActivity( Intent.makeRestartActivityTask( packageManager.getLaunchIntentForPackage(packageName)?.component, - ).apply { - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) - }, + ), ) - Toast.makeText(context, "재 로그인이 필요해요", Toast.LENGTH_SHORT).show() } } diff --git a/core/ui/src/main/java/com/teamwable/ui/util/GetErrorMessage.kt b/core/ui/src/main/java/com/teamwable/ui/util/GetErrorMessage.kt new file mode 100644 index 00000000..6524df85 --- /dev/null +++ b/core/ui/src/main/java/com/teamwable/ui/util/GetErrorMessage.kt @@ -0,0 +1,16 @@ +package com.teamwable.ui.util + +import android.content.res.Resources +import com.teamwable.model.network.Error +import java.net.UnknownHostException + +fun getErrorMessage( + throwable: Throwable?, + localContextResource: Resources, +) = when (throwable) { + is UnknownHostException -> localContextResource.getString(com.teamwable.common.R.string.error_message_network) + is Error.NetWorkConnectError -> localContextResource.getString(com.teamwable.common.R.string.error_message_network) + is Error.ApiError -> throwable.message.toString() + is Error.TimeOutError -> throwable.message.toString() + else -> localContextResource.getString(com.teamwable.common.R.string.error_message_unknown) +} diff --git a/feature/main-compose/src/main/java/com/teamwable/main_compose/MainScreen.kt b/feature/main-compose/src/main/java/com/teamwable/main_compose/MainScreen.kt index 0f6c568d..db12db73 100644 --- a/feature/main-compose/src/main/java/com/teamwable/main_compose/MainScreen.kt +++ b/feature/main-compose/src/main/java/com/teamwable/main_compose/MainScreen.kt @@ -2,6 +2,7 @@ package com.teamwable.main_compose import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -11,21 +12,26 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.core.content.ContextCompat.startActivity import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.NavHost import androidx.navigation.navOptions import com.teamwable.auth.naviagation.loginNavGraph import com.teamwable.common.intentprovider.IntentProvider +import com.teamwable.designsystem.component.snackbar.SNACK_BAR_DURATION +import com.teamwable.designsystem.component.snackbar.WableSnackBar import com.teamwable.designsystem.component.topbar.WableAppBar +import com.teamwable.designsystem.type.SnackBarType +import com.teamwable.main_compose.extensions.getErrorMessage import com.teamwable.main_compose.splash.navigation.splashNavGraph -import com.teamwable.model.network.Error import com.teamwable.onboarding.agreeterms.naviagation.agreeTermsNavGraph import com.teamwable.onboarding.firstlckwatch.naviagation.firstLckWatchNavGraph import com.teamwable.onboarding.profile.naviagation.profileNavGraph import com.teamwable.onboarding.selectlckteam.naviagation.selectLckTeamNavGraph +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.net.UnknownHostException @Composable internal fun MainScreen( @@ -40,15 +46,11 @@ internal fun MainScreen( val localContextResource = LocalContext.current.resources val onShowErrorSnackBar: (throwable: Throwable?) -> Unit = { throwable -> coroutineScope.launch { - snackBarHostState.showSnackbar( - when (throwable) { - is UnknownHostException -> localContextResource.getString(R.string.error_message_network) - is Error.NetWorkConnectError -> localContextResource.getString(R.string.error_message_network) - is Error.ApiError -> throwable.message.toString() - is Error.TimeOutError -> throwable.message.toString() - else -> localContextResource.getString(R.string.error_message_unknown) - }, - ) + val job = launch { + snackBarHostState.showSnackbar(getErrorMessage(throwable, localContextResource)) + } + delay(SNACK_BAR_DURATION) + job.cancel() } } @@ -108,7 +110,19 @@ internal fun MainScreen( ) } } + SnackbarHost( + modifier = Modifier + .fillMaxWidth() + .padding(top = 30.dp) + .zIndex(1f), + hostState = snackBarHostState, + snackbar = { snackBarData -> + WableSnackBar( + message = snackBarData.visuals.message, + snackBarType = SnackBarType.ERROR, + ) + }, + ) }, - snackbarHost = { SnackbarHost(snackBarHostState) }, ) } diff --git a/feature/main-compose/src/main/java/com/teamwable/main_compose/extensions/GetErrorMessage.kt b/feature/main-compose/src/main/java/com/teamwable/main_compose/extensions/GetErrorMessage.kt new file mode 100644 index 00000000..8020cf35 --- /dev/null +++ b/feature/main-compose/src/main/java/com/teamwable/main_compose/extensions/GetErrorMessage.kt @@ -0,0 +1,16 @@ +package com.teamwable.main_compose.extensions + +import android.content.res.Resources +import com.teamwable.model.network.Error +import java.net.UnknownHostException + +fun getErrorMessage( + throwable: Throwable?, + localContextResource: Resources, +) = when (throwable) { + is UnknownHostException -> localContextResource.getString(com.teamwable.common.R.string.error_message_network) + is Error.NetWorkConnectError -> localContextResource.getString(com.teamwable.common.R.string.error_message_network) + is Error.ApiError -> throwable.message.toString() + is Error.TimeOutError -> throwable.message.toString() + else -> localContextResource.getString(com.teamwable.common.R.string.error_message_unknown) +} diff --git a/feature/main-compose/src/main/res/values/strings.xml b/feature/main-compose/src/main/res/values/strings.xml deleted file mode 100644 index 290814ea..00000000 --- a/feature/main-compose/src/main/res/values/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - 네트워크 연결이 원활하지 않습니다 - 알 수 없는 오류가 발생하였습니다 - diff --git a/feature/onboarding/src/main/java/com/teamwable/onboarding/agreeterms/AgreeTermsScreen.kt b/feature/onboarding/src/main/java/com/teamwable/onboarding/agreeterms/AgreeTermsScreen.kt index fbe1a76b..ea96dc5f 100644 --- a/feature/onboarding/src/main/java/com/teamwable/onboarding/agreeterms/AgreeTermsScreen.kt +++ b/feature/onboarding/src/main/java/com/teamwable/onboarding/agreeterms/AgreeTermsScreen.kt @@ -28,11 +28,14 @@ import com.teamwable.common.util.AmplitudeUtil.trackEvent import com.teamwable.designsystem.component.button.WableButton import com.teamwable.designsystem.component.checkbox.WableCheckBoxWithText import com.teamwable.designsystem.component.dialog.WableButtonDialog +import com.teamwable.designsystem.component.snackbar.WableSnackBarPopUp import com.teamwable.designsystem.theme.WableTheme import com.teamwable.designsystem.type.DialogType +import com.teamwable.designsystem.type.SnackBarType import com.teamwable.navigation.Route import com.teamwable.onboarding.R import com.teamwable.onboarding.agreeterms.model.AgreeTerm +import com.teamwable.onboarding.agreeterms.model.AgreeTermState import com.teamwable.onboarding.agreeterms.model.AgreeTermsSideEffect @Composable @@ -44,6 +47,7 @@ fun AgreeTermsRoute( ) { val lifecycleOwner = LocalLifecycleOwner.current val showDialog by viewModel.showDialog.collectAsStateWithLifecycle() + val agreeTermState by viewModel.agreeTermState.collectAsStateWithLifecycle() val memberInfoEditModel = args.memberInfoEditModel @@ -82,6 +86,11 @@ fun AgreeTermsRoute( onDismissRequest = { viewModel.showLoginDialog(false) }, ) } + + WableSnackBarPopUp( + isVisible = agreeTermState is AgreeTermState.Loading, + snackBarType = SnackBarType.LOADING, + ) } @Composable diff --git a/feature/onboarding/src/main/java/com/teamwable/onboarding/agreeterms/AgreeTermsViewModel.kt b/feature/onboarding/src/main/java/com/teamwable/onboarding/agreeterms/AgreeTermsViewModel.kt index 88a28959..20db5f8d 100644 --- a/feature/onboarding/src/main/java/com/teamwable/onboarding/agreeterms/AgreeTermsViewModel.kt +++ b/feature/onboarding/src/main/java/com/teamwable/onboarding/agreeterms/AgreeTermsViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.teamwable.data.repository.ProfileRepository import com.teamwable.data.repository.UserInfoRepository import com.teamwable.model.profile.MemberInfoEditModel +import com.teamwable.onboarding.agreeterms.model.AgreeTermState import com.teamwable.onboarding.agreeterms.model.AgreeTermsSideEffect import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow @@ -27,6 +28,9 @@ class AgreeTermsViewModel @Inject constructor( private val _showDialog = MutableStateFlow(false) val showDialog: StateFlow get() = _showDialog + private val _agreeTermState = MutableStateFlow(AgreeTermState.Idle) + val agreeTermState: StateFlow get() = _agreeTermState + fun navigateToHome() { viewModelScope.launch { _sideEffect.emit(AgreeTermsSideEffect.NavigateToHome) @@ -39,14 +43,21 @@ class AgreeTermsViewModel @Inject constructor( fun patchUserProfile(memberInfoEditModel: MemberInfoEditModel, imgUrl: String?) { viewModelScope.launch { + _agreeTermState.update { AgreeTermState.Loading } profileRepository.patchUserProfile(memberInfoEditModel, imgUrl) - .onSuccess { - _sideEffect.emit(AgreeTermsSideEffect.ShowDialog) - userInfoRepository.saveAutoLogin(true) - } - .onFailure { - _sideEffect.emit(AgreeTermsSideEffect.ShowSnackBar(it)) - } + .onSuccess { handleSuccess() } + .onFailure { handleFailure(it) } } } + + private suspend fun handleSuccess() { + _agreeTermState.update { AgreeTermState.Idle } + _sideEffect.emit(AgreeTermsSideEffect.ShowDialog) + userInfoRepository.saveAutoLogin(true) + } + + private suspend fun handleFailure(it: Throwable) { + _agreeTermState.update { AgreeTermState.Idle } + _sideEffect.emit(AgreeTermsSideEffect.ShowSnackBar(it)) + } } diff --git a/feature/onboarding/src/main/java/com/teamwable/onboarding/agreeterms/model/AgreeTermState.kt b/feature/onboarding/src/main/java/com/teamwable/onboarding/agreeterms/model/AgreeTermState.kt new file mode 100644 index 00000000..be765c33 --- /dev/null +++ b/feature/onboarding/src/main/java/com/teamwable/onboarding/agreeterms/model/AgreeTermState.kt @@ -0,0 +1,13 @@ +package com.teamwable.onboarding.agreeterms.model + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable + +@Stable +sealed interface AgreeTermState { + @Immutable + data object Idle : AgreeTermState + + @Immutable + data object Loading : AgreeTermState +} diff --git a/feature/profile/src/main/java/com/teamwable/profile/profile/edit/ProfileEditFragment.kt b/feature/profile/src/main/java/com/teamwable/profile/profile/edit/ProfileEditFragment.kt index 87401626..56d6707e 100644 --- a/feature/profile/src/main/java/com/teamwable/profile/profile/edit/ProfileEditFragment.kt +++ b/feature/profile/src/main/java/com/teamwable/profile/profile/edit/ProfileEditFragment.kt @@ -10,7 +10,6 @@ import com.teamwable.model.profile.MemberInfoEditModel import com.teamwable.profile.R import com.teamwable.profile.databinding.FragmentProfileEditBinding import com.teamwable.ui.base.BindingFragment -import com.teamwable.ui.extensions.toast import com.teamwable.ui.util.Arg.PROFILE_EDIT_RESULT class ProfileEditFragment : BindingFragment(FragmentProfileEditBinding::inflate) { @@ -39,7 +38,6 @@ class ProfileEditFragment : BindingFragment(Fragment ProfileEditRoute( profile = profile, navigateToProfile = { updatedProfile -> saveAndNavigateBack(updatedProfile) }, - onShowErrorSnackBar = { throwable -> toast(throwable?.message.toString()) }, ) } } diff --git a/feature/profile/src/main/java/com/teamwable/profile/profile/edit/ProfileEditRoute.kt b/feature/profile/src/main/java/com/teamwable/profile/profile/edit/ProfileEditRoute.kt index 44eb3331..b3c5cb37 100644 --- a/feature/profile/src/main/java/com/teamwable/profile/profile/edit/ProfileEditRoute.kt +++ b/feature/profile/src/main/java/com/teamwable/profile/profile/edit/ProfileEditRoute.kt @@ -1,32 +1,62 @@ package com.teamwable.profile.profile.edit +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.flowWithLifecycle +import com.teamwable.designsystem.component.snackbar.SNACK_BAR_DURATION +import com.teamwable.designsystem.component.snackbar.WableSnackBar +import com.teamwable.designsystem.component.snackbar.WableSnackBarPopUp import com.teamwable.designsystem.type.ProfileEditType import com.teamwable.designsystem.type.ProfileImageType +import com.teamwable.designsystem.type.SnackBarType import com.teamwable.model.profile.MemberInfoEditModel import com.teamwable.onboarding.profile.ProfileScreen import com.teamwable.onboarding.profile.model.ProfileSideEffect import com.teamwable.onboarding.profile.permission.launchImagePicker import com.teamwable.onboarding.profile.permission.rememberGalleryLauncher import com.teamwable.onboarding.profile.permission.rememberPhotoPickerLauncher +import com.teamwable.profile.profile.edit.model.ProfilePatchState +import com.teamwable.ui.util.getErrorMessage +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Composable internal fun ProfileEditRoute( viewModel: ProfileEditViewModel = hiltViewModel(), profile: MemberInfoEditModel, navigateToProfile: (MemberInfoEditModel) -> Unit, - onShowErrorSnackBar: (throwable: Throwable?) -> Unit = {}, ) { val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current val profileState by viewModel.profileState.collectAsStateWithLifecycle() + val loadingState by viewModel.profileLoadingState.collectAsStateWithLifecycle() + + val snackBarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + val localContextResource = LocalContext.current.resources + + val onShowErrorSnackBar: (throwable: Throwable?) -> Unit = { throwable -> + coroutineScope.launch { + val job = launch { + snackBarHostState.showSnackbar(getErrorMessage(throwable, localContextResource)) + } + delay(SNACK_BAR_DURATION) + job.cancel() + } + } val galleryLauncher = rememberGalleryLauncher { uri -> viewModel.onImageSelected(uri.toString()) @@ -37,7 +67,7 @@ internal fun ProfileEditRoute( } LaunchedEffect(Unit) { - viewModel.onNicknameChanged(profile.nickname ?: "") + viewModel.onNicknameChanged(profile.nickname.orEmpty()) val profileType = ProfileImageType.entries.find { it.name == profile.memberDefaultProfileImage } if (profileType != null) { viewModel.onRandomImageChange(profileType) @@ -88,4 +118,22 @@ internal fun ProfileEditRoute( viewModel.onNicknameChanged(newNickname) }, ) + + SnackbarHost( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + hostState = snackBarHostState, + snackbar = { snackBarData -> + WableSnackBar( + message = snackBarData.visuals.message, + snackBarType = SnackBarType.ERROR, + ) + }, + ) + + WableSnackBarPopUp( + isVisible = loadingState is ProfilePatchState.Loading, + snackBarType = SnackBarType.LOADING_PROFILE, + ) } diff --git a/feature/profile/src/main/java/com/teamwable/profile/profile/edit/ProfileEditViewModel.kt b/feature/profile/src/main/java/com/teamwable/profile/profile/edit/ProfileEditViewModel.kt index b8611bcf..c5f915b4 100644 --- a/feature/profile/src/main/java/com/teamwable/profile/profile/edit/ProfileEditViewModel.kt +++ b/feature/profile/src/main/java/com/teamwable/profile/profile/edit/ProfileEditViewModel.kt @@ -9,6 +9,7 @@ import com.teamwable.model.profile.MemberInfoEditModel import com.teamwable.onboarding.profile.model.ProfileSideEffect import com.teamwable.onboarding.profile.model.ProfileState import com.teamwable.onboarding.profile.regex.NicknameValidationUseCase +import com.teamwable.profile.profile.edit.model.ProfilePatchState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -30,6 +31,9 @@ internal class ProfileEditViewModel @Inject constructor( private val _profileState = MutableStateFlow(ProfileState()) val profileState: StateFlow = _profileState + private val _profilePatchState = MutableStateFlow(ProfilePatchState.Idle) + val profileLoadingState: StateFlow get() = _profilePatchState + fun requestImagePicker() { viewModelScope.launch { _sideEffect.emit(ProfileSideEffect.RequestImagePicker) @@ -71,11 +75,14 @@ internal class ProfileEditViewModel @Inject constructor( fun patchUserProfile(memberInfoEditModel: MemberInfoEditModel, imgUrl: String?) { viewModelScope.launch { + _profilePatchState.update { ProfilePatchState.Loading } profileRepository.patchUserProfile(memberInfoEditModel, imgUrl) .onSuccess { + _profilePatchState.update { ProfilePatchState.Idle } _sideEffect.emit(ProfileSideEffect.NavigateToProfile) } .onFailure { + _profilePatchState.update { ProfilePatchState.Idle } _sideEffect.emit(ProfileSideEffect.ShowSnackBar(it)) } } diff --git a/feature/profile/src/main/java/com/teamwable/profile/profile/edit/model/ProfilePatchState.kt b/feature/profile/src/main/java/com/teamwable/profile/profile/edit/model/ProfilePatchState.kt new file mode 100644 index 00000000..9aedce8e --- /dev/null +++ b/feature/profile/src/main/java/com/teamwable/profile/profile/edit/model/ProfilePatchState.kt @@ -0,0 +1,13 @@ +package com.teamwable.profile.profile.edit.model + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable + +@Stable +sealed interface ProfilePatchState { + @Immutable + data object Idle : ProfilePatchState + + @Immutable + data object Loading : ProfilePatchState +}