diff --git a/app/src/main/java/org/sopt/and/core/utils/PreferenceUtil.kt b/app/src/main/java/org/sopt/and/core/utils/PreferenceUtil.kt new file mode 100644 index 00000000..23d1adeb --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/utils/PreferenceUtil.kt @@ -0,0 +1,33 @@ +package org.sopt.and.core.utils + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + + +class PreferenceUtil @Inject constructor( + @ApplicationContext private val context: Context +) { + private val sharedPreferences = + context.getSharedPreferences("wavve_prefs", Context.MODE_PRIVATE) + + fun saveUserToken(token: String) { + sharedPreferences.edit().putString(USER_TOKEN, token).apply() + } + + fun getUserToken(): String? { + return sharedPreferences.getString(USER_TOKEN, null) + } + + fun clearUserToken() { + sharedPreferences.edit().remove(USER_TOKEN).apply() + } + + fun clearAll() { + sharedPreferences.edit().clear().apply() + } + + companion object { + private const val USER_TOKEN = "user_token" + } +} diff --git a/app/src/main/java/org/sopt/and/core/utils/SnackBarUtils.kt b/app/src/main/java/org/sopt/and/core/utils/SnackBarUtils.kt new file mode 100644 index 00000000..0335cb3d --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/utils/SnackBarUtils.kt @@ -0,0 +1,33 @@ +package org.sopt.and.core.utils + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult + +object SnackBarUtils { + private lateinit var snackBarHostState: SnackbarHostState + + fun init(snackBarHostState: SnackbarHostState) { + SnackBarUtils.snackBarHostState = snackBarHostState + } + + suspend fun showSnackBar( + message: String, + actionLabel: String? = null, + onActionClick: (() -> Unit)? = null, + duration: SnackbarDuration = SnackbarDuration.Short, + ) { + if (this::snackBarHostState.isInitialized) { + val result = snackBarHostState.showSnackbar( + message = message, + actionLabel = actionLabel, + duration = duration + ) + if (result == SnackbarResult.ActionPerformed) { + onActionClick?.invoke() + } + } else { + throw UninitializedPropertyAccessException("SnackBarHostState가 초기화 되지 않았습니다. init()을 먼저 호출하세요") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/util/UiState.kt b/app/src/main/java/org/sopt/and/core/utils/UiState.kt similarity index 86% rename from app/src/main/java/org/sopt/and/core/util/UiState.kt rename to app/src/main/java/org/sopt/and/core/utils/UiState.kt index c4348a39..e4159383 100644 --- a/app/src/main/java/org/sopt/and/core/util/UiState.kt +++ b/app/src/main/java/org/sopt/and/core/utils/UiState.kt @@ -1,4 +1,4 @@ -package org.sopt.and.core.util +package org.sopt.and.core.utils sealed class UiState { object Loading : UiState() diff --git a/app/src/main/java/org/sopt/and/data/di/PreferenceModule.kt b/app/src/main/java/org/sopt/and/data/di/PreferenceModule.kt new file mode 100644 index 00000000..21b22816 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/di/PreferenceModule.kt @@ -0,0 +1,20 @@ +package org.sopt.and.data.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.sopt.and.core.utils.PreferenceUtil +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PreferenceModule { + @Provides + @Singleton + fun providePreferenceUtil(@ApplicationContext context: Context): PreferenceUtil { + return PreferenceUtil(context) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/di/RepositoryModule.kt b/app/src/main/java/org/sopt/and/data/di/RepositoryModule.kt new file mode 100644 index 00000000..c8e4e5f7 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/di/RepositoryModule.kt @@ -0,0 +1,39 @@ +package org.sopt.and.data.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.sopt.and.data.datasource.MyHobbyDataSource +import org.sopt.and.data.datasource.SignInDataSource +import org.sopt.and.data.datasource.SignUpDataSource +import org.sopt.and.data.repositoryimpl.MyHobbyRepositoryImpl +import org.sopt.and.data.repositoryimpl.SignInRepositoryImpl +import org.sopt.and.data.repositoryimpl.SignUpRepositoryImpl +import org.sopt.and.domain.repository.MyHobbyRepository +import org.sopt.and.domain.repository.SignUpRepository +import org.sopt.and.domain.repository.SignInRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object RepositoryModule { + + @Provides + @Singleton + fun provideUserRegisterRepository(userService: SignUpDataSource): SignUpRepository { + return SignUpRepositoryImpl(userService) + } + + @Provides + @Singleton + fun provideUserLoginRepository(userService: SignInDataSource): SignInRepository { + return SignInRepositoryImpl(userService) + } + + @Provides + @Singleton + fun provideGetMyHobbyRepository(userService: MyHobbyDataSource): MyHobbyRepository { + return MyHobbyRepositoryImpl(userService) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/di/RetrofitModule.kt b/app/src/main/java/org/sopt/and/data/di/RetrofitModule.kt new file mode 100644 index 00000000..6878faf4 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/di/RetrofitModule.kt @@ -0,0 +1,45 @@ +package org.sopt.and.data.di + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.sopt.and.BuildConfig.BASE_URL +import retrofit2.Retrofit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object RetrofitModule { + + @Provides + @Singleton + fun provideLoggingInterceptor(): HttpLoggingInterceptor { + return HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + } + + @Provides + @Singleton + fun provideOkHttpClient(loggingInterceptor: HttpLoggingInterceptor): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .build() + } + + @Provides + @Singleton + fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { + return Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(Json.asConverterFactory("application/json".toMediaType())) + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/di/UseCaseModule.kt b/app/src/main/java/org/sopt/and/data/di/UseCaseModule.kt new file mode 100644 index 00000000..2dd55535 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/di/UseCaseModule.kt @@ -0,0 +1,36 @@ +package org.sopt.and.data.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.sopt.and.domain.repository.MyHobbyRepository +import org.sopt.and.domain.repository.SignUpRepository +import org.sopt.and.domain.repository.SignInRepository +import org.sopt.and.domain.usecase.MyHobbyUseCase +import org.sopt.and.domain.usecase.SignInUseCase +import org.sopt.and.domain.usecase.SignUpUseCase +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object UseCaseModule { + + @Provides + @Singleton + fun provideRegisterUserUseCase(userRepository: SignUpRepository): SignUpUseCase { + return SignUpUseCase(userRepository) + } + + @Provides + @Singleton + fun provideLoginUserUseCase(userRepository: SignInRepository): SignInUseCase { + return SignInUseCase(userRepository) + } + + @Provides + @Singleton + fun provideGetMyHobbyUseCase(userRepository: MyHobbyRepository): MyHobbyUseCase { + return MyHobbyUseCase(userRepository) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/entity/BaseResult.kt b/app/src/main/java/org/sopt/and/domain/entity/BaseResult.kt new file mode 100644 index 00000000..cca5795d --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/entity/BaseResult.kt @@ -0,0 +1,9 @@ +package org.sopt.and.domain.entity + +sealed class BaseResult { + data class Success(val data: T) : BaseResult() + data class Error( + val message: String, + val errorCode: String? = null + ) : BaseResult() +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/entity/UserData.kt b/app/src/main/java/org/sopt/and/domain/entity/UserData.kt new file mode 100644 index 00000000..077a5634 --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/entity/UserData.kt @@ -0,0 +1,7 @@ +package org.sopt.and.domain.entity + +data class UserData( + val username: String, + val password: String, + val hobby: String +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/entity/UserLoginResult.kt b/app/src/main/java/org/sopt/and/domain/entity/UserLoginResult.kt new file mode 100644 index 00000000..50395fcb --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/entity/UserLoginResult.kt @@ -0,0 +1,5 @@ +package org.sopt.and.domain.entity + +data class UserLoginResult( + val token: String +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/repository/SignInRepository.kt b/app/src/main/java/org/sopt/and/domain/repository/SignInRepository.kt index 9a7a39a4..1a6182bf 100644 --- a/app/src/main/java/org/sopt/and/domain/repository/SignInRepository.kt +++ b/app/src/main/java/org/sopt/and/domain/repository/SignInRepository.kt @@ -1,21 +1,10 @@ package org.sopt.and.domain.repository -import org.sopt.and.data.datasource.SignInDataSource -import org.sopt.and.data.repositoryimpl.SignInRepositoryImpl -import org.sopt.and.data.service.ServicePool -import org.sopt.and.domain.model.SignInInformationEntity -import org.sopt.and.domain.model.SignInResponseEntity -interface SignInRepository { - suspend fun signIn(request: SignInInformationEntity): Result +import org.sopt.and.domain.entity.BaseResult +import org.sopt.and.domain.entity.UserLoginResult + - companion object { - fun create(): SignInRepositoryImpl { - return SignInRepositoryImpl( - SignInDataSource( - ServicePool.userService - ) - ) - } - } +interface SignInRepository { + suspend fun loginUser(user: org.sopt.and.domain.entity.UserData): BaseResult } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/usecase/SignInUseCase.kt b/app/src/main/java/org/sopt/and/domain/usecase/SignInUseCase.kt index eb1504db..ce8bc1f5 100644 --- a/app/src/main/java/org/sopt/and/domain/usecase/SignInUseCase.kt +++ b/app/src/main/java/org/sopt/and/domain/usecase/SignInUseCase.kt @@ -1,12 +1,15 @@ package org.sopt.and.domain.usecase -import org.sopt.and.domain.model.SignInInformationEntity -import org.sopt.and.domain.model.SignInResponseEntity +import org.sopt.and.domain.entity.BaseResult +import org.sopt.and.domain.entity.UserData +import org.sopt.and.domain.entity.UserLoginResult import org.sopt.and.domain.repository.SignInRepository +import javax.inject.Inject -class SignInUseCase( - private val signInRepository: SignInRepository +class SignInUseCase @Inject constructor( + private val userLoginRepository: SignInRepository ) { - suspend operator fun invoke(request: SignInInformationEntity): Result = - signInRepository.signIn(request = request) + suspend operator fun invoke(user: UserData): BaseResult { + return userLoginRepository.loginUser(user) + } } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/SignInContract.kt b/app/src/main/java/org/sopt/and/presentation/signin/SignInContract.kt new file mode 100644 index 00000000..b996a5c6 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signin/SignInContract.kt @@ -0,0 +1,29 @@ +package org.sopt.and.presentation.auth.signin + +import org.sopt.and.presentation.util.UiEffect +import org.sopt.and.presentation.util.UiEvent +import org.sopt.and.presentation.util.UiState + +class SignInContract { + data class SignInUiState( + val username: String = "", + val password: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null + ) : UiState + + sealed class SignInUiEvent : UiEvent { + data class UpdateUserName(val username: String) : SignInUiEvent() + data class UpdatePassword(val password: String) : SignInUiEvent() + data object SignInFormSubmit : SignInUiEvent() + data object NavigateUp : SignInUiEvent() + } + + sealed class SignInUiEffect : UiEffect { + data object ShowSuccessSnackBar : SignInUiEffect() + data class ShowErrorSnackBar(val message: String) : SignInUiEffect() + data object NavigateToSignUp : SignInUiEffect() + data object NavigateToMy : SignInUiEffect() + data object NavigateUp : SignInUiEffect() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt b/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt index 9d26ca10..57925281 100644 --- a/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt +++ b/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt @@ -1,72 +1,73 @@ package org.sopt.and.presentation.signin import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Scaffold -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.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.sopt.and.R +import org.sopt.and.core.utils.SnackBarUtils +import org.sopt.and.presentation.auth.signin.SignInContract +import org.sopt.and.presentation.auth.signin.component.SignInButton +import org.sopt.and.presentation.auth.signin.viewmodel.SignInViewModel import org.sopt.and.presentation.components.SnSBox -import org.sopt.and.presentation.signin.components.SignInButton -import org.sopt.and.presentation.signin.components.SignInPasswordField -import org.sopt.and.presentation.signin.components.SignInToAdditionalFeatures -import org.sopt.and.presentation.signin.components.SignInTopBar -import org.sopt.and.presentation.signin.components.SignInUsernameField -import org.sopt.and.presentation.viewmodelfactory.SignInViewModelFactory -import org.sopt.and.ui.theme.ANDANDROIDTheme +import org.sopt.and.presentation.signin.components.* import org.sopt.and.ui.theme.Black100 + @Composable fun SignInScreen( + navigateToMy: () -> Unit, navigateToSignUp: () -> Unit, - navigateToMyInfo: () -> Unit, + navigateUp: () -> Unit, modifier: Modifier = Modifier, + viewModel: SignInViewModel = hiltViewModel(), ) { - val snackbarHostState = remember { SnackbarHostState() } - - val signInViewModel: SignInViewModel = viewModel( - factory = SignInViewModelFactory() - ) - val signInUiState by signInViewModel.uiState.collectAsStateWithLifecycle() - val scope = rememberCoroutineScope() + val signInState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current - val signInResult by signInViewModel.signInResult.collectAsStateWithLifecycle() - - val signInUsername = signInUiState.signInUsername - val signInPassword = signInUiState.signInPassword - val isSignInPasswordVisible = signInUiState.isSignInPasswordVisible - - LaunchedEffect(signInResult) { - signInViewModel.confirmLogin( - snackbarHostState = snackbarHostState, - navigateToMyInfo = navigateToMyInfo, - context = context, - scope = scope - ) + // Effect handling + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + is SignInContract.SignInUiEffect.ShowSuccessSnackBar -> { + CoroutineScope(Dispatchers.Main).launch { + SnackBarUtils.showSnackBar( + message = context.getString(R.string.sign_in_snackbar_login_success), + actionLabel = context.getString(R.string.sign_in_snackbar_action_close) + ) + } + navigateToMy() + } + + is SignInContract.SignInUiEffect.ShowErrorSnackBar -> { + CoroutineScope(Dispatchers.Main).launch { + SnackBarUtils.showSnackBar( + message = effect.message, + actionLabel = context.getString(R.string.sign_in_snackbar_action_close) + ) + } + } + + is SignInContract.SignInUiEffect.NavigateToSignUp -> navigateToSignUp() + is SignInContract.SignInUiEffect.NavigateToMy -> navigateToMy() + is SignInContract.SignInUiEffect.NavigateUp -> navigateUp() + } + } } Scaffold( modifier = Modifier.fillMaxSize(), - snackbarHost = { SnackbarHost(hostState = snackbarHostState) } + snackbarHost = { viewModel.sendEvent(SignInContract.SignInUiEvent.NavigateUp) } ) { innerPadding -> Column( modifier = modifier @@ -85,27 +86,39 @@ fun SignInScreen( Spacer(modifier = Modifier.height(60.dp)) SignInUsernameField( - signInUsername = signInUsername, - onSignInUsernameChange = signInViewModel::setSignInUsername + signInUsername = signInState.username, + onSignInUsernameChange = { + viewModel.sendEvent( + SignInContract.SignInUiEvent.UpdateUserName( + it + ) + ) + } ) Spacer(modifier = Modifier.height(5.dp)) SignInPasswordField( - signInPassword = signInPassword, - onSignInPasswordChange = signInViewModel::setSignInPassword, - isSignInPasswordVisible = isSignInPasswordVisible, - onVisibilityChange = signInViewModel::changeSignInPasswordVisibility + signInPassword = signInState.password, + onSignInPasswordChange = { + viewModel.sendEvent( + SignInContract.SignInUiEvent.UpdatePassword( + it + ) + ) + }, + isSignInPasswordVisible = false, ) Spacer(modifier = Modifier.height(30.dp)) SignInButton( - signIn = signInViewModel::signIn, - signInUsername = signInUiState.signInUsername, - signInPassword = signInUiState.signInPassword + text = stringResource(R.string.sign_in_text_login), + onClick = { viewModel.signIn() }, + modifier = Modifier ) + Spacer(modifier = Modifier.height(20.dp)) SignInToAdditionalFeatures(navigateToSignUp = navigateToSignUp) @@ -116,21 +129,4 @@ fun SignInScreen( } } } -} - -@Preview( - showBackground = true, - showSystemUi = true -) -@Composable -fun SignInScreenPreview() { - ANDANDROIDTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - innerPadding - SignInScreen( - navigateToSignUp = {}, - navigateToMyInfo = {} - ) - } - } } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt b/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt index 43de7afc..452cdb7f 100644 --- a/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt +++ b/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt @@ -1,130 +1,82 @@ -package org.sopt.and.presentation.signin +package org.sopt.and.presentation.auth.signin.viewmodel -import android.content.Context -import androidx.compose.material3.SnackbarHostState -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch -import org.sopt.and.R -import org.sopt.and.data.service.AppContext -import org.sopt.and.data.service.TokenManager -import org.sopt.and.domain.model.SignInInformationEntity -import org.sopt.and.domain.model.SignInResponseEntity +import org.sopt.and.core.utils.PreferenceUtil +import org.sopt.and.domain.entity.BaseResult +import org.sopt.and.domain.entity.UserData import org.sopt.and.domain.usecase.SignInUseCase -import org.sopt.and.presentation.util.Utils.showSnackbar +import org.sopt.and.presentation.auth.signin.SignInContract.SignInUiEffect +import org.sopt.and.presentation.auth.signin.SignInContract.SignInUiEvent +import org.sopt.and.presentation.auth.signin.SignInContract.SignInUiState +import org.sopt.and.presentation.util.BaseViewModel +import javax.inject.Inject -class SignInViewModel( - private val signInUseCase: SignInUseCase -) : ViewModel() { - private val tokenManager = TokenManager(AppContext.get()) - - private val _uiState = MutableStateFlow(SignInUiState()) - val uiState: StateFlow = _uiState.asStateFlow() +@HiltViewModel +class SignInViewModel @Inject constructor( + private val loginUseCase: SignInUseCase, + private val preferenceUtil: PreferenceUtil +) : BaseViewModel(SignInUiState()) { + override fun reduceState(event: SignInUiEvent) { + when (event) { + is SignInUiEvent.UpdateUserName -> { + updateState( + currentState.copy( + username = event.username + ) + ) + } - private val _signInResult = MutableStateFlow(SignInResult.Initial) - val signInResult: StateFlow = _signInResult.asStateFlow() + is SignInUiEvent.UpdatePassword -> { + updateState( + currentState.copy( + password = event.password + ) + ) + } - private fun initSignInResult() { - _signInResult.value = SignInResult.Initial - } + is SignInUiEvent.SignInFormSubmit -> signIn() - fun setSignInUsername(signInUsername: String) { - _uiState.value = _uiState.value.copy( - signInUsername = signInUsername - ) - } - - fun setSignInPassword(signInPassword: String) { - _uiState.value = _uiState.value.copy( - signInPassword = signInPassword - ) + is SignInUiEvent.NavigateUp -> postEffect(SignInUiEffect.NavigateUp) + } } - fun changeSignInPasswordVisibility() { - _uiState.value = _uiState.value.copy( - isSignInPasswordVisible = !_uiState.value.isSignInPasswordVisible + fun signIn() { + updateState( + currentState.copy( + isLoading = true + ) ) - } - - fun signIn( - signInUsername: String, - signInPassword: String - ) { viewModelScope.launch { - signInUseCase( - request = SignInInformationEntity( - username = signInUsername, - password = signInPassword - ) - ).onSuccess { signInResponseEntity: SignInResponseEntity -> - if (signInResponseEntity.status == 200) { - _signInResult.value = SignInResult.Success - signInResponseEntity.token?.let { token -> - tokenManager.saveToken(token) + when ( + val result = loginUseCase( + with(currentState) { + UserData(username, password, "") } - } else if (signInResponseEntity.code == SignInFailureCase.FAILURE_LENGTH.errorCode - && signInResponseEntity.status == SignInFailureCase.FAILURE_LENGTH.statusCode - ) { - _signInResult.value = SignInResult.FailurePasswordLength - } else if (signInResponseEntity.code == SignInFailureCase.FAILURE_WRONG_PASSWORD.errorCode - && signInResponseEntity.status == SignInFailureCase.FAILURE_WRONG_PASSWORD.statusCode - ) { - _signInResult.value = SignInResult.FailureWrongPassword - } - } - } - } - - fun confirmLogin( - snackbarHostState: SnackbarHostState, - navigateToMyInfo: () -> Unit, - context: Context, - scope: CoroutineScope - ) { - when (signInResult.value) { - is SignInResult.Success -> { - context.showSnackbar( - scope = scope, - snackbarHostState = snackbarHostState, - message = R.string.sign_in_success_message, - ) - navigateToMyInfo() - initSignInResult() - } - - is SignInResult.FailurePasswordLength -> { - context.showSnackbar( - scope = scope, - snackbarHostState = snackbarHostState, - message = R.string.sign_in_failed_password_length, ) - initSignInResult() - } + ) { + is BaseResult.Success -> { + updateState( + currentState.copy( + isLoading = false + ) + ) + preferenceUtil.saveUserToken(result.data.token) + postEffect(SignInUiEffect.ShowSuccessSnackBar) + postEffect(SignInUiEffect.NavigateToMy) + } - is SignInResult.FailureWrongPassword -> { - context.showSnackbar( - scope = scope, - snackbarHostState = snackbarHostState, - message = R.string.sign_in_failed_wrong_password, - ) - initSignInResult() + is BaseResult.Error -> { + updateState( + currentState.copy( + isLoading = false, + errorMessage = result.message + ) + ) + postEffect(SignInUiEffect.ShowErrorSnackBar(result.message)) + } } - - else -> {} } } } - -data class SignInFailureCase( - val statusCode: Int, - val errorCode: String -) { - companion object { - val FAILURE_LENGTH = SignInFailureCase(statusCode = 400, errorCode = "01") - val FAILURE_WRONG_PASSWORD = SignInFailureCase(statusCode = 403, errorCode = "01") - } -} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/components/SignInButton.kt b/app/src/main/java/org/sopt/and/presentation/signin/components/SignInButton.kt index 5a2cb8a7..a4a40816 100644 --- a/app/src/main/java/org/sopt/and/presentation/signin/components/SignInButton.kt +++ b/app/src/main/java/org/sopt/and/presentation/signin/components/SignInButton.kt @@ -1,43 +1,37 @@ -package org.sopt.and.presentation.signin.components +package org.sopt.and.presentation.auth.signin.component import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import org.sopt.and.R +import androidx.compose.ui.unit.sp import org.sopt.and.ui.theme.Blue100 import org.sopt.and.ui.theme.White100 @Composable fun SignInButton( - signIn: (String, String) -> Unit, - signInUsername: String, - signInPassword: String + text: String, + onClick : () -> Unit, + modifier: Modifier ) { - Button( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .height(50.dp), - onClick = { - signIn( - signInUsername, signInPassword - ) - }, - shape = RoundedCornerShape(50.dp), + .padding(horizontal = 8.dp), colors = ButtonDefaults.buttonColors( - containerColor = Blue100, - contentColor = White100 - ) + containerColor = Blue100 + ), + onClick = onClick, ) { Text( - text = stringResource(id = R.string.sign_in_button) + text = text, + color = White100, + fontSize = 16.sp, + modifier = modifier.padding(vertical = 8.dp) ) } } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/components/SignInPasswordField.kt b/app/src/main/java/org/sopt/and/presentation/signin/components/SignInPasswordField.kt index 44d66be0..e1fe0e15 100644 --- a/app/src/main/java/org/sopt/and/presentation/signin/components/SignInPasswordField.kt +++ b/app/src/main/java/org/sopt/and/presentation/signin/components/SignInPasswordField.kt @@ -11,15 +11,11 @@ fun SignInPasswordField( signInPassword: String, onSignInPasswordChange: (String) -> Unit, isSignInPasswordVisible: Boolean, - onVisibilityChange: () -> Unit ) { SignInOrSignUpTextField( information = signInPassword, onValueChange = onSignInPasswordChange, placeholder = R.string.sign_in_password_placeholder, visualTransformation = transformationPasswordVisual(isSignInPasswordVisible), - trailingIcon = { - ShowOrHideToggle(isSignInPasswordVisible, onVisibilityChange) - } ) } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/util/Utils.kt b/app/src/main/java/org/sopt/and/presentation/util/Utils.kt index 95cf93f8..1637d431 100644 --- a/app/src/main/java/org/sopt/and/presentation/util/Utils.kt +++ b/app/src/main/java/org/sopt/and/presentation/util/Utils.kt @@ -1,5 +1,7 @@ package org.sopt.and.presentation.util +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import android.content.Context import android.widget.Toast import androidx.annotation.StringRes @@ -7,9 +9,18 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.sopt.and.R + +// Utility object for common functions object Utils { const val MIN_PASSWORD_LENGTH = 8 const val MAX_PASSWORD_LENGTH = 20 @@ -33,9 +44,11 @@ object Utils { Pair(R.drawable.apple_icon, R.string.link_apple_icon_description), ) + // Visual transformation for password visibility fun transformationPasswordVisual(isVisible: Boolean): VisualTransformation = if (isVisible) VisualTransformation.None else PasswordVisualTransformation() + // Show a Toast message fun Context.showToast( @StringRes message: Int ) = Toast.makeText( @@ -44,6 +57,7 @@ object Utils { Toast.LENGTH_SHORT ).show() + // Show a Snackbar message fun Context.showSnackbar( scope: CoroutineScope, snackbarHostState: SnackbarHostState, @@ -51,4 +65,65 @@ object Utils { ) = scope.launch { snackbarHostState.showSnackbar(message = getString(message)) } +} + +// UiState, UiEvent, and UiEffect interfaces +interface UiState +interface UiEvent +interface UiEffect + +// Base ViewModel class with generic state, event, and effect +abstract class BaseViewModel( + initialState: State +) : ViewModel() { + + // UI State management + private val _uiState: MutableStateFlow = MutableStateFlow(initialState) + val currentState: State + get() = _uiState.value + + val uiState = _uiState.asStateFlow() + + // Event handling + private val _event: MutableSharedFlow = MutableSharedFlow() + val event = _event.asSharedFlow() + + // Effect handling + private val _effect: Channel = Channel() + val effect = _effect.receiveAsFlow() + + init { + subscribeEvents() + } + + // Abstract function to handle state changes based on events + protected abstract fun reduceState(event: Event) + + // Function to post effects (to be observed) + protected fun postEffect(effect: Effect) { + viewModelScope.launch { + _effect.send(effect) + } + } + + // Subscribe to events and call reduceState + private fun subscribeEvents() { + viewModelScope.launch { + event.collect { + reduceState(it) + } + } + } + + // Update the current state + protected fun updateState(currentState: State) { + _uiState.update { + currentState + } + } + + // Send events to trigger state changes + fun sendEvent(event: Event) { + viewModelScope.launch { _event.emit(event) } + } } \ 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 ffa5b7b6..a57cddee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -103,4 +103,89 @@ %1$s + + + 사용자 이름 + 비밀번호 + 로그인 + 아이디 찾기 + | + 비밀번호 재설정 + 회원가입 + 또는 다른 서비스 계정으로 로그인 + SNS계정으로 간편하게 가입하여 서비스를 이용하실 수 있습니다. 기존 POOQ 계정 또는 Wavve 계정과는 연동되지 않으니 이용에 참고하세요. + 로그인 성공 + 아이디와 비밀번호를 입력해주세요 + 아이디 혹은 비밀번호가 틀렸습니다. + 닫기 + hide + show + + + 회원가입 + 입력하신 아이디가 형식에 맞지 않습니다. 아이디는 8자 이내로 입력해주세요. + 아이디는 8자 이내로 입력해주세요. + 입력하신 비밀번호가 형식에 맞지 않습니다. 비밀번호는 8자 이내로 입력해주세요. + 비밀번호는 8자 이내로 입력해주세요. + 입력하신 취미가 형식에 맞지 않습니다. 취미는 8자 이내로 입력해 주세요. + 취미는 8자 이내로 입력해주세요. + Wavve 비밀번호 설정 + 또는 다른 서비스 계정으로 로그인 + SNS계정으로 간편하게 가입하여 서비스를 이용하실 수 있습니다. 기존 POOQ 계정 또는 Wavve 계정과는 연동되지 않으니 이용에 참고하세요. + 회원가입을 완료하였습니다. + 아이디, 비밀번호, 취미를 양식에 맞게 작성해주세요 + Wavve 회원가입 + 아이디와 비밀번호, 취미 만으로\nWavve를 즐길 수 있어요! + hide + show + 아이디 + 취미 + + KaKao Account + T Account + Naver Account + FaceBook Account + Apple Account + + + Logo + back + Close + + + profile image + notification + setting + 첫 결제 시 첫 달 100원! + 현재 보유하신 이용권이 없습니다. + 전체 시청내역 + 시청내역이 없어요. + 관심 프로그램 + 관심 프로그램이 없어요. + 로그아웃 + 로그아웃 되었습니다. + + 구매하기 + purchase + No Content + more + + + 회원가입 + + MY + 검색 + 뉴클래식 + 드라마 + 예능 + 영화 + 애니 + 해외시리즈 + 시사교양 + 키즈 + 영화플러스 + live button + + + \ No newline at end of file