diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 76b52f20..00000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 60939a41..654934c9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -99,8 +99,6 @@ dependencies { implementation(project(":feature:questions-or-collections:api")) implementation(project(":feature:public-collections:api")) implementation(project(":feature:selection-specializations:api")) -// implementation(project(":feature:forgot-password:api")) -// implementation(project(":feature:forgot-password:impl")) implementation(project(":feature:example-home:impl")) implementation(project(":feature:example-profile:impl")) implementation(project(":feature:example-questions:impl")) @@ -110,6 +108,8 @@ dependencies { implementation(project(":feature:questions-or-collections:impl")) implementation(project(":feature:public-collections:impl")) implementation(project(":feature:selection-specializations:impl")) + implementation(project(":feature:authentication:api")) + implementation(project(":feature:authentication:impl")) } tasks.withType { diff --git a/app/src/main/java/ru/yeahub/Application.kt b/app/src/main/java/ru/yeahub/Application.kt index 303b420d..544806a5 100644 --- a/app/src/main/java/ru/yeahub/Application.kt +++ b/app/src/main/java/ru/yeahub/Application.kt @@ -3,6 +3,7 @@ package ru.yeahub import android.app.Application import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin +import ru.yeahub.authentication.impl.registration.di.registrationFeatureModule import ru.yeahub.datastore_impl.di.datastoreModule import ru.yeahub.detail_question.impl.di.detailQuestionFeatureModule import ru.yeahub.example_details.impl.detailsFeatureModule @@ -55,7 +56,8 @@ class Application : Application() { CollectionsFeatureModule, detailQuestionFeatureModule, collectionsAndQuestionsFeatureModule, - specializationFeatureModule + specializationFeatureModule, + registrationFeatureModule ) } // проверка, что модули загружены diff --git a/core/navigation-api/src/main/java/ru/yeahub/navigation_api/FeatureRoute.kt b/core/navigation-api/src/main/java/ru/yeahub/navigation_api/FeatureRoute.kt index 446c60e6..a2436fe2 100644 --- a/core/navigation-api/src/main/java/ru/yeahub/navigation_api/FeatureRoute.kt +++ b/core/navigation-api/src/main/java/ru/yeahub/navigation_api/FeatureRoute.kt @@ -62,4 +62,7 @@ object FeatureRoute { object PublicCollectionsFeature { const val FEATURE_NAME = "public_collections" } + object RegistrationFeature { + const val FEATURE_NAME = "registration" + } } \ No newline at end of file diff --git a/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/AppNavigation.kt b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/AppNavigation.kt index 35c59fef..25e156ed 100644 --- a/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/AppNavigation.kt +++ b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/AppNavigation.kt @@ -34,6 +34,7 @@ import androidx.navigation.compose.rememberNavController import org.koin.compose.getKoin import ru.yeahub.core_ui.theme.Theme import ru.yeahub.navigation_api.FeatureApi +import ru.yeahub.navigation_api.FeatureRoute import ru.yeahub.navigation_api.NavigationPathManager import ru.yeahub.navigation_impl.model.BottomNavigationItem import timber.log.Timber @@ -70,7 +71,7 @@ fun AppNavigation( val features: Set = getKoin().getAll().toSet() Timber.d("AppNavigation onCreate: Loaded features: ${features.map { it.javaClass.simpleName }}") val navItems = getBottomNavItems() - + features.forEach { feature -> feature.initialize(pathManager) } @@ -78,16 +79,18 @@ fun AppNavigation( val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route val selectedRoute = getSelectedRoute(currentRoute, navItems) - + currentRoute?.let { route -> pathManager.setCurrentPath(route) } Scaffold( modifier = modifier, + containerColor = Theme.colors.white900, bottomBar = { NavigationBar( - modifier = Modifier.clip(RoundedCornerShape(12.dp)) + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) .height(100.dp), containerColor = Theme.colors.purple700 ) { @@ -120,7 +123,11 @@ fun AppNavigation( .fillMaxWidth() .background( color = animateColorAsState( - targetValue = if (isSelected) Theme.colors.white900 else Color.Transparent, + targetValue = if (isSelected) { + Theme.colors.white900 + } else { + Color.Transparent + }, animationSpec = tween(durationMillis = 80) ).value, shape = RoundedCornerShape(8.dp) @@ -139,28 +146,44 @@ fun AppNavigation( }, alwaysShowLabel = false ) - Timber.d("NavSelected", "$currentRoute") + Timber.d("NavSelected: $currentRoute") } } } ) { padding -> Box(modifier = Modifier.padding(padding)) { - NavHost( + AppNavHost( navController = navController, - startDestination = navItems[1].route, - modifier = Modifier, - ) { - registerDynamicNavigation( - features = features, - pathManager = pathManager, - navController = navController, - navGraphBuilder = this - ) - } + features = features, + pathManager = pathManager + ) } } } +/** + * Отдельный компонент для NavHost, чтобы избежать дублирования кода. + */ +@Composable +private fun AppNavHost( + navController: NavHostController, + features: Set, + pathManager: NavigationPathManager +) { + NavHost( + navController = navController, + startDestination = FeatureRoute.HomeFeature.FEATURE_NAME, + modifier = Modifier, + ) { + registerDynamicNavigation( + features = features, + pathManager = pathManager, + navController = navController, + navGraphBuilder = this + ) + } +} + /** * Обработка нажатий на нижнюю панель навигации. */ @@ -186,9 +209,9 @@ private fun handleBottomNavClick( // Навигируем на родительский маршрут другого таба Timber.d( "AppNavigation onClick: Navigating to different tab: " + - "${item.route} from: $currentRoute" + "${item.route} from: $currentRoute" ) - + // Устанавливаем новый корневой путь pathManager.setCurrentPath(item.route) navController.navigate(item.route) { @@ -210,21 +233,21 @@ private fun registerDynamicNavigation( ) { val rootFeatures = features.filter { it.isRootFeature() } val childFeatures = features.filter { !it.isRootFeature() } - + // Регистрируем корневые фичи rootFeatures.forEach { feature -> Timber.d( "AppNavigation registerGraph: Registering root feature: " + "${feature.javaClass.simpleName}" ) - + // Сбрасываем путь для корневой фичи pathManager.setCurrentPath("") pathManager.registerFeaturePath(feature.getFeatureName(), feature.getFeatureName()) - + feature.registerGraph(navGraphBuilder, navController, pathManager) } - + // Регистрируем дочерние фичи для каждой корневой фичи registerChildFeatures(childFeatures, rootFeatures, pathManager, navController, navGraphBuilder) } @@ -241,17 +264,17 @@ private fun registerChildFeatures( ) { childFeatures.forEach { childFeature -> val dependentRootFeatures = childFeature.getDependentRootFeatures(rootFeatures) - + // Если фича не указала зависимости, регистрируем для всех корневых фич val targetRootFeatures = if (dependentRootFeatures.isEmpty()) { rootFeatures } else { dependentRootFeatures } - + targetRootFeatures.forEach { rootFeature -> pathManager.setCurrentPath(rootFeature.getFeatureName()) - + // Регистрируем дочернюю фичу childFeature.registerGraph( navGraphBuilder = navGraphBuilder, diff --git a/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NavigationFactory.kt b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NavigationFactory.kt index 946f2fe1..9c7b5fd2 100644 --- a/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NavigationFactory.kt +++ b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NavigationFactory.kt @@ -6,13 +6,14 @@ import ru.yeahub.navigation_impl.model.BottomNavigationItem * Фабрика для создания навигационных элементов. */ fun getBottomNavItems(): List = listOf( + BottomNavigationItem.Profile, BottomNavigationItem.Collections, BottomNavigationItem.Home, BottomNavigationItem.Questions ) fun getSelectedRoute(currentRoute: String?, navItems: List): String = when { - currentRoute == null -> navItems[1].route + currentRoute == null -> navItems[2].route navItems.any { it.route == currentRoute } -> currentRoute else -> navItems.find { currentRoute.startsWith(it.route) }?.route ?: navItems.last().route } \ No newline at end of file diff --git a/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/model/BottomNavigationItem.kt b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/model/BottomNavigationItem.kt index be7c35fd..692e957e 100644 --- a/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/model/BottomNavigationItem.kt +++ b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/model/BottomNavigationItem.kt @@ -32,6 +32,12 @@ sealed class BottomNavigationItem( val label: String, @DrawableRes val icon: Int ) { + data object Profile : BottomNavigationItem( + route = FeatureRoute.RegistrationFeature.FEATURE_NAME, + label = "Профиль", + icon = R.drawable.icon_tab_profile + ) + data object Collections : BottomNavigationItem( route = FeatureRoute.CollectionsFeature.FEATURE_NAME, label = "Коллекции", diff --git a/core/navigation-impl/src/main/res/drawable/icon_tab_profile.xml b/core/navigation-impl/src/main/res/drawable/icon_tab_profile.xml new file mode 100644 index 00000000..43325fc7 --- /dev/null +++ b/core/navigation-impl/src/main/res/drawable/icon_tab_profile.xml @@ -0,0 +1,14 @@ + + + + diff --git a/core/network-api/src/main/java/ru/yeahub/network_api/models/RegistrationRequestDto.kt b/core/network-api/src/main/java/ru/yeahub/network_api/models/RegistrationRequestDto.kt index 08a043c8..2f51902a 100644 --- a/core/network-api/src/main/java/ru/yeahub/network_api/models/RegistrationRequestDto.kt +++ b/core/network-api/src/main/java/ru/yeahub/network_api/models/RegistrationRequestDto.kt @@ -1,8 +1,7 @@ package ru.yeahub.network_api.models data class RegistrationRequestDto( - val nickname: String, + val username: String, val email: String, val password: String, - val isMailingAccepted: Boolean, ) diff --git a/core/network-impl/src/main/java/ru/yeahub/network_impl/RetrofitApiService.kt b/core/network-impl/src/main/java/ru/yeahub/network_impl/RetrofitApiService.kt index 1b7deeee..01b8a166 100644 --- a/core/network-impl/src/main/java/ru/yeahub/network_impl/RetrofitApiService.kt +++ b/core/network-impl/src/main/java/ru/yeahub/network_impl/RetrofitApiService.kt @@ -1,18 +1,29 @@ package ru.yeahub.network_impl +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query import ru.yeahub.network_api.ApiService +import ru.yeahub.network_api.models.AuthUserDto import ru.yeahub.network_api.models.GetCollectionsResponse import ru.yeahub.network_api.models.GetPublicQuestionResponse import ru.yeahub.network_api.models.GetPublicQuestionsResponse import ru.yeahub.network_api.models.GetSkillsResponse import ru.yeahub.network_api.models.GetSpecializationResponse import ru.yeahub.network_api.models.GetSpecializationsResponse +import ru.yeahub.network_api.models.LoginRequestDto +import ru.yeahub.network_api.models.LoginResponseDto +import ru.yeahub.network_api.models.RegistrationRequestDto interface RetrofitApiService : ApiService { + @POST("auth/signUp") + override suspend fun register( + @Body request: RegistrationRequestDto + ) + @GET("questions/public-questions") override suspend fun getQuestions( @Query("page") page: Int, @@ -61,4 +72,8 @@ interface RetrofitApiService : ApiService { @Query("specializations") specializationsId: Long, @Query("isFree") isFree: Boolean ): GetCollectionsResponse + + // Пустые реализации для ApiService, если они не нужны в Retrofit-слое + override suspend fun login(request: LoginRequestDto): LoginResponseDto = TODO("Not required for this feature") + override suspend fun getProfile(): AuthUserDto = TODO("Not required for this feature") } diff --git a/core/ui/src/main/java/ru/yeahub/core_ui/component/Button.kt b/core/ui/src/main/java/ru/yeahub/core_ui/component/Button.kt index 537fb1f8..89ee389b 100644 --- a/core/ui/src/main/java/ru/yeahub/core_ui/component/Button.kt +++ b/core/ui/src/main/java/ru/yeahub/core_ui/component/Button.kt @@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -37,23 +39,25 @@ fun PrimaryButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, + isLoading: Boolean = false, colors: YeahubButtonColors = YeahubButtonDefaults.primaryButtonColors(), border: BorderStroke? = null, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, shape: Shape = RoundedCornerShape(12.dp), contentPadding: PaddingValues = ButtonDefaults.ContentPadding, - content: @Composable RowScope.() -> Unit + content: @Composable RowScope.() -> Unit, ) { DefaultButton( onClick = onClick, modifier = modifier, enabled = enabled, + isLoading = isLoading, colors = colors, border = border, interactionSource = interactionSource, shape = shape, contentPadding = contentPadding, - content = content, + content = content ) } @@ -62,17 +66,19 @@ fun SecondaryButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, + isLoading: Boolean = false, colors: YeahubButtonColors = YeahubButtonDefaults.secondaryButtonColors(), border: BorderStroke? = null, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, shape: Shape = RoundedCornerShape(12.dp), contentPadding: PaddingValues = ButtonDefaults.ContentPadding, - content: @Composable RowScope.() -> Unit + content: @Composable RowScope.() -> Unit, ) { DefaultButton( onClick = onClick, modifier = modifier, enabled = enabled, + isLoading = isLoading, colors = colors, border = border, interactionSource = interactionSource, @@ -87,6 +93,7 @@ fun OutlineButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, + isLoading: Boolean = false, colors: YeahubButtonColors = YeahubButtonDefaults.outlinedButtonColors(), border: BorderStroke = YeahubButtonDefaults.outlineBorderDefaults(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, @@ -98,12 +105,13 @@ fun OutlineButton( onClick = onClick, modifier = modifier, enabled = enabled, + isLoading = isLoading, colors = colors, border = border, interactionSource = interactionSource, shape = shape, contentPadding = contentPadding, - content = content, + content = content ) } @@ -112,6 +120,7 @@ private fun DefaultButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, + isLoading: Boolean = false, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, colors: YeahubButtonColors = YeahubButtonDefaults.primaryButtonColors(), border: BorderStroke? = null, @@ -119,18 +128,18 @@ private fun DefaultButton( contentPadding: PaddingValues = ButtonDefaults.ContentPadding, content: @Composable RowScope.() -> Unit ) { - val contentColor: Color by colors.contentColor(enabled) - val containerColor: Color by colors.containerColor(enabled) + val contentColor: Color by colors.contentColor(enabled && !isLoading) + val containerColor: Color by colors.containerColor(enabled && !isLoading) Surface( onClick = onClick, modifier = modifier, - enabled = enabled, + enabled = enabled && !isLoading, shape = shape, color = containerColor, contentColor = contentColor, border = border, - interactionSource = interactionSource, + interactionSource = interactionSource ) { CompositionLocalProvider( value = LocalContentColor provides contentColor @@ -139,9 +148,18 @@ private fun DefaultButton( modifier = Modifier .padding(contentPadding), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - content = content, - ) + verticalAlignment = Alignment.CenterVertically + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = contentColor, + strokeWidth = 2.dp + ) + } else { + content() + } + } } } } @@ -158,7 +176,7 @@ object YeahubButtonDefaults { contentColor = contentColor, containerColor = containerColor, disabledContentColor = disabledContentColor, - disabledContainerColor = disabledContainerColor, + disabledContainerColor = disabledContainerColor ) } @@ -173,7 +191,7 @@ object YeahubButtonDefaults { contentColor = contentColor, containerColor = containerColor, disabledContentColor = disabledContentColor, - disabledContainerColor = disabledContainerColor, + disabledContainerColor = disabledContainerColor ) } @@ -188,7 +206,7 @@ object YeahubButtonDefaults { contentColor = contentColor, containerColor = containerColor, disabledContentColor = disabledContentColor, - disabledContainerColor = disabledContainerColor, + disabledContainerColor = disabledContainerColor ) } @@ -203,7 +221,7 @@ object YeahubButtonDefaults { contentColor = contentColor, containerColor = containerColor, disabledContentColor = disabledContentColor, - disabledContainerColor = disabledContainerColor, + disabledContainerColor = disabledContainerColor ) } @@ -212,13 +230,13 @@ object YeahubButtonDefaults { contentColor: Color = Theme.colors.red600, containerColor: Color = Color.Transparent, disabledContentColor: Color = Theme.colors.red200, - disabledContainerColor: Color = Color.Transparent + disabledContainerColor: Color = Color.Transparent, ): YeahubButtonColors { return YeahubButtonColors( contentColor = contentColor, containerColor = containerColor, disabledContentColor = disabledContentColor, - disabledContainerColor = disabledContainerColor, + disabledContainerColor = disabledContainerColor ) } @@ -229,7 +247,7 @@ object YeahubButtonDefaults { ): BorderStroke { return BorderStroke( width = width, - color = borderColor, + color = borderColor ) } } @@ -239,7 +257,7 @@ data class YeahubButtonColors( private val contentColor: Color, private val containerColor: Color, private val disabledContentColor: Color, - private val disabledContainerColor: Color + private val disabledContainerColor: Color, ) : ButtonColors { @Composable override fun containerColor(enabled: Boolean): State { @@ -290,7 +308,7 @@ fun ButtonPreviews() { } // Secondary Button - Text("Secondary Buttons", style = MaterialTheme.typography.titleMedium) + Text("Secondary Buttons", style = MaterialTheme.typography.titleMedium) SecondaryButton( onClick = {}, modifier = Modifier.fillMaxWidth(), diff --git a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/RegistrationFeatureImpl.kt b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/RegistrationFeatureImpl.kt new file mode 100644 index 00000000..72e803b6 --- /dev/null +++ b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/RegistrationFeatureImpl.kt @@ -0,0 +1,48 @@ +package ru.yeahub.authentication.impl.registration + +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import ru.yeahub.authentication.impl.R +import ru.yeahub.authentication.impl.registration.presentation.RegistrationScreen +import ru.yeahub.navigation_api.FeatureApi +import ru.yeahub.navigation_api.FeatureRoute +import ru.yeahub.navigation_api.NavigationPathManager + +class RegistrationFeatureImpl : FeatureApi { + + override fun getFeatureName(): String = FeatureRoute.RegistrationFeature.FEATURE_NAME + + override fun isRootFeature(): Boolean = true + + override fun initialize(pathManager: NavigationPathManager) { + super.initialize(pathManager) + pathManager.registerFeaturePath(getFeatureName(), getFeatureName()) + } + + override fun registerGraph( + navGraphBuilder: NavGraphBuilder, + navController: NavHostController, + pathManager: NavigationPathManager, + modifier: Modifier + ) { + navGraphBuilder.composable(route = getFeatureName()) { + val uriHandler = LocalUriHandler.current + val pdUrl = stringResource(R.string.pd_offer_url) + val offerUrl = stringResource(R.string.public_offer_uri) + + RegistrationScreen( + onRegistrationSuccess = { + navController.navigate(FeatureRoute.HomeFeature.FEATURE_NAME) { + popUpTo(getFeatureName()) { inclusive = true } + } + }, + onOpenPdPolicy = { uriHandler.openUri(pdUrl) }, + onOpenOffer = { uriHandler.openUri(offerUrl) } + ) + } + } +} diff --git a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/data/mapper/RegistrationDomainToDataMapper.kt b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/data/mapper/RegistrationDomainToDataMapper.kt index 5380ccbe..b54170c4 100644 --- a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/data/mapper/RegistrationDomainToDataMapper.kt +++ b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/data/mapper/RegistrationDomainToDataMapper.kt @@ -7,10 +7,9 @@ class RegistrationDomainToDataMapper { fun map(model: RegistrationModel): RegistrationRequestDto { return RegistrationRequestDto( - nickname = model.nickname, + username = model.nickname, email = model.email, password = model.password, - isMailingAccepted = model.isMailingAccepted ) } } \ No newline at end of file diff --git a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/data/repository/RegistrationRepositoryImpl.kt b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/data/repository/RegistrationRepositoryImpl.kt index 27d5ca6a..9c2ae8fc 100644 --- a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/data/repository/RegistrationRepositoryImpl.kt +++ b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/data/repository/RegistrationRepositoryImpl.kt @@ -1,5 +1,6 @@ package ru.yeahub.authentication.impl.registration.data.repository +import com.google.gson.Gson import kotlinx.coroutines.CancellationException import retrofit2.HttpException import ru.yeahub.authentication.impl.registration.data.mapper.RegistrationDomainToDataMapper @@ -9,11 +10,13 @@ import ru.yeahub.authentication.impl.registration.domain.entity.RegistrationErro import ru.yeahub.authentication.impl.registration.domain.entity.RegistrationException import ru.yeahub.authentication.impl.registration.domain.entity.RegistrationModel import ru.yeahub.authentication.impl.registration.domain.repository.RegistrationRepositoryApi +import ru.yeahub.network_api.models.ErrorResponseDto import java.io.IOException class RegistrationRepositoryImpl( private val remoteDataSourceApi: RegistrationRemoteDataSourceApi, - private val mapper: RegistrationDomainToDataMapper + private val mapper: RegistrationDomainToDataMapper, + private val gson: Gson ) : RegistrationRepositoryApi { override suspend fun register(registrationModel: RegistrationModel) = @@ -24,7 +27,7 @@ class RegistrationRepositoryImpl( throw e } catch (e: IOException) { throw RegistrationException( - error = RegistrationError.Network, + error = RegistrationError.UnknownError, failure = Failure(cause = e) ) } catch (e: HttpException) { @@ -33,20 +36,26 @@ class RegistrationRepositoryImpl( private fun mapHttpException(e: HttpException): RegistrationException { val code = e.code() - val error = - when (code) { - HTTP_CONFLICT -> RegistrationError.EmailAlreadyExists - HTTP_BAD_REQUEST -> RegistrationError.InvalidCredentials - in HTTP_SERVER_ERROR_MIN..HTTP_SERVER_ERROR_MAX -> RegistrationError.Server - else -> RegistrationError.Unknown - } + val errorBody = e.response()?.errorBody()?.string() + val errorDto = errorBody?.let { + runCatching { gson.fromJson(it, ErrorResponseDto::class.java) }.getOrNull() + } + + val backendKey = errorDto?.message + + val error = when { + backendKey == BACKEND_KEY_USER_CONFLICT || code == HTTP_CONFLICT -> RegistrationError.Conflict + backendKey == BACKEND_KEY_USER_NOT_FOUND || code == HTTP_NOT_FOUND -> RegistrationError.NotFound + else -> RegistrationError.UnknownError + } + return RegistrationException(error = error, failure = Failure(cause = e, httpCode = code)) } private companion object { - private const val HTTP_BAD_REQUEST = 400 private const val HTTP_CONFLICT = 409 - private const val HTTP_SERVER_ERROR_MIN = 500 - private const val HTTP_SERVER_ERROR_MAX = 599 + private const val HTTP_NOT_FOUND = 404 + private const val BACKEND_KEY_USER_CONFLICT = "user.user.conflict" + private const val BACKEND_KEY_USER_NOT_FOUND = "user.user.id.not_found" } } \ No newline at end of file diff --git a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/di/RegistrationFeatureModule.kt b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/di/RegistrationFeatureModule.kt index 77aa3a8b..994b6cb4 100644 --- a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/di/RegistrationFeatureModule.kt +++ b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/di/RegistrationFeatureModule.kt @@ -1,5 +1,6 @@ package ru.yeahub.authentication.impl.registration.di +import com.google.gson.Gson import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.core.module.dsl.bind import org.koin.core.module.dsl.factoryOf @@ -11,14 +12,18 @@ import ru.yeahub.authentication.impl.registration.data.repository.remote.Registr import ru.yeahub.authentication.impl.registration.data.repository.remote.RegistrationRemoteDataSourceImpl import ru.yeahub.authentication.impl.registration.domain.repository.RegistrationRepositoryApi import ru.yeahub.authentication.impl.registration.domain.usecase.RegistrationUseCase -import ru.yeahub.authentication.impl.registration.presentation.RegistrationUiStateMapper import ru.yeahub.authentication.impl.registration.presentation.RegistrationViewModel +import ru.yeahub.authentication.impl.registration.presentation.RegistrationUiStateMapper +import ru.yeahub.authentication.impl.registration.RegistrationFeatureImpl +import ru.yeahub.navigation_api.FeatureApi val registrationFeatureModule = module { + singleOf(::Gson) singleOf(::RegistrationDomainToDataMapper) singleOf(::RegistrationRemoteDataSourceImpl) { bind() } singleOf(::RegistrationRepositoryImpl) { bind() } factoryOf(::RegistrationUseCase) singleOf(::RegistrationUiStateMapper) viewModelOf(::RegistrationViewModel) + singleOf(::RegistrationFeatureImpl) { bind() } } diff --git a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/domain/entity/RegistrationException.kt b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/domain/entity/RegistrationException.kt index cd69c63e..a0e6188a 100644 --- a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/domain/entity/RegistrationException.kt +++ b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/domain/entity/RegistrationException.kt @@ -6,12 +6,10 @@ data class Failure( ) sealed interface RegistrationError { - data object EmailAlreadyExists : RegistrationError - data object NickNameTaken : RegistrationError - data object InvalidCredentials : RegistrationError - data object Network : RegistrationError - data object Server : RegistrationError - data object Unknown : RegistrationError + data object Success : RegistrationError + data object NotFound : RegistrationError + data object Conflict : RegistrationError + data object UnknownError : RegistrationError } class RegistrationException( diff --git a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/PasswordValidation.kt b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/PasswordValidation.kt deleted file mode 100644 index 2109da95..00000000 --- a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/PasswordValidation.kt +++ /dev/null @@ -1,10 +0,0 @@ -package ru.yeahub.authentication.impl.registration.presentation - -private const val MIN_PASSWORD_LENGTH = 8 - -internal fun isPasswordValid(password: String): Boolean { - val isValid = password.matches( - Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z\\d]).{$MIN_PASSWORD_LENGTH,}$") - ) - return isValid -} \ No newline at end of file diff --git a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationScreen.kt b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationScreen.kt index 3110da3a..6bd0f300 100644 --- a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationScreen.kt +++ b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationScreen.kt @@ -18,245 +18,225 @@ import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.unit.Dp import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import org.koin.androidx.compose.koinViewModel import ru.yeahub.authentication.impl.R import ru.yeahub.core_ui.component.PrimaryButton +import ru.yeahub.core_ui.component.PrimaryTextField import ru.yeahub.core_ui.theme.Theme +import ru.yeahub.core_ui.theme.YeaHubTheme +import ru.yeahub.core_utils.common.TextOrResource @Composable fun RegistrationScreen( + viewModel: RegistrationViewModel = koinViewModel(), + onRegistrationSuccess: () -> Unit, + onOpenPdPolicy: () -> Unit, + onOpenOffer: () -> Unit +) { + val state by viewModel.state.collectAsState() + val context = androidx.compose.ui.platform.LocalContext.current + + LaunchedEffect(Unit) { + viewModel.commands.collect { command -> + when (command) { + is RegistrationCommand.NavigateToSuccess -> onRegistrationSuccess() + is RegistrationCommand.ShowError -> { + android.widget.Toast.makeText( + context, + command.message.getString(context), + android.widget.Toast.LENGTH_SHORT + ).show() + } + } + } + } + + RegistrationContent( + state = state, + onAction = viewModel::onAction, + onOpenPdPolicy = onOpenPdPolicy, + onOpenOffer = onOpenOffer + ) +} + +@Composable +fun RegistrationContent( state: RegistrationUiState, onAction: (RegistrationAction) -> Unit, onOpenPdPolicy: () -> Unit, onOpenOffer: () -> Unit ) { - val linkColor = MaterialTheme.colorScheme.primary + val linkColor = Theme.colors.purple700 val form = state.formState - Scaffold { paddings -> + Scaffold( + containerColor = Theme.colors.white900 + ) { paddings -> Column( modifier = Modifier .padding(paddings) .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 24.dp), + .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(14.dp) ) { Text( text = stringResource(R.string.registration_title), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold + style = Theme.typography.body6, + color = Theme.colors.black900 ) - FormTextField( + PrimaryTextField( title = stringResource(R.string.nickname_title), placeholder = stringResource(R.string.nickname_placeholder), value = form.nickname, onValueChange = { onAction(RegistrationAction.NicknameChanged(it)) }, - keyboardType = KeyboardType.Ascii + error = form.nicknameError, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii) ) - FormTextField( + PrimaryTextField( title = stringResource(R.string.email_title), placeholder = stringResource(R.string.email_placeholder), value = form.email, onValueChange = { onAction(RegistrationAction.EmailChanged(it)) }, - keyboardType = KeyboardType.Email + error = form.emailError, + onFocusChanged = { hasFocus -> + onAction(RegistrationAction.EmailFocusChanged(hasFocus)) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email) ) - FormPasswordField( + PrimaryTextField( title = stringResource(R.string.password_title), placeholder = stringResource(R.string.password_placeholder), value = form.password, - isVisible = form.isPasswordVisible, onValueChange = { onAction(RegistrationAction.PasswordChanged(it)) }, - onToggleVisibility = { + error = form.passwordError, + onFocusChanged = { hasFocus -> + onAction(RegistrationAction.PasswordFocusChanged(hasFocus)) + }, + visualTransformation = if (form.isPasswordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = rememberVectorPainter( + if (form.isPasswordVisible) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + } + ), + onTrailingIconClick = { onAction(RegistrationAction.TogglePasswordVisible) - } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) ) - FormPasswordField( + PrimaryTextField( title = stringResource(R.string.confirm_password_title), placeholder = stringResource(R.string.password_placeholder), value = form.confirmPassword, - isVisible = form.isConfirmPasswordVisible, onValueChange = { onAction(RegistrationAction.ConfirmPasswordChanged(it)) }, - onToggleVisibility = { + error = form.confirmPasswordError, + onFocusChanged = { hasFocus -> + onAction(RegistrationAction.ConfirmPasswordFocusChanged(hasFocus)) + }, + visualTransformation = if (form.isConfirmPasswordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = rememberVectorPainter( + if (form.isConfirmPasswordVisible) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + } + ), + onTrailingIconClick = { onAction(RegistrationAction.ToggleConfirmPasswordVisible) - } - ) - - ConsentRow( - checked = form.isPdAccepted, - onCheckedChange = { - onAction(RegistrationAction.PdAcceptedChanged(it)) }, - text = pdConsentText(linkColor), - onLinkClicked = { tag -> if (tag == "pd") onOpenPdPolicy() } + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) ) - ConsentRow( - checked = form.isOfferAccepted, - onCheckedChange = { - onAction(RegistrationAction.OfferAcceptedChanged(it)) - }, - text = offerConsentText(linkColor), - onLinkClicked = { tag -> if (tag == "offer") onOpenOffer() } - ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + ConsentRow( + checked = form.isPdAccepted, + onCheckedChange = { + onAction(RegistrationAction.PdAcceptedChanged(it)) + }, + text = pdConsentText(linkColor), + onLinkClicked = { tag -> if (tag == "pd") onOpenPdPolicy() } + ) - ConsentRow( - checked = form.isMailingAccepted, - onCheckedChange = { - onAction(RegistrationAction.MailingAcceptedChanged(it)) - }, - text = - AnnotatedString( - stringResource(R.string.marketing_opt_in_text) - ), - onLinkClicked = {} - ) + ConsentRow( + checked = form.isOfferAccepted, + onCheckedChange = { + onAction(RegistrationAction.OfferAcceptedChanged(it)) + }, + text = offerConsentText(linkColor), + onLinkClicked = { tag -> if (tag == "offer") onOpenOffer() } + ) + + ConsentRow( + checked = form.isMailingAccepted, + onCheckedChange = { + onAction(RegistrationAction.MailingAcceptedChanged(it)) + }, + text = + AnnotatedString( + stringResource(R.string.marketing_opt_in_text) + ), + onLinkClicked = {} + ) + } Spacer(Modifier.height(8.dp)) PrimaryButton( onClick = { onAction(RegistrationAction.SubmitClicked) }, enabled = form.isSubmitEnabled, + isLoading = state is RegistrationUiState.Loading, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.registration_button)) } } } } -@Composable -private fun FormTextField( - title: String, - placeholder: String, - value: String, - onValueChange: (String) -> Unit, - keyboardType: KeyboardType, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(text = title) - - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = value, - onValueChange = onValueChange, - placeholder = { - Text( - text = placeholder, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = keyboardType), - colors = - OutlinedTextFieldDefaults.colors( - focusedTextColor = - MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedTextColor = - MaterialTheme.colorScheme.onSurfaceVariant - ) - ) - } -} - -@Composable -private fun FormPasswordField( - title: String, - placeholder: String, - value: String, - isVisible: Boolean, - onValueChange: (String) -> Unit, - onToggleVisibility: () -> Unit, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(text = title) - - PasswordField( - placeholder = placeholder, - value = value, - isVisible = isVisible, - onValueChange = onValueChange, - onToggleVisibility = onToggleVisibility - ) - } -} - -@Composable -private fun PasswordField( - placeholder: String, - value: String, - isVisible: Boolean, - onValueChange: (String) -> Unit, - onToggleVisibility: () -> Unit, - modifier: Modifier = Modifier, -) { - OutlinedTextField( - modifier = modifier.fillMaxWidth(), - value = value, - onValueChange = onValueChange, - placeholder = { - Text(text = placeholder, color = MaterialTheme.colorScheme.onSurfaceVariant) - }, - singleLine = true, - visualTransformation = - if (isVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - trailingIcon = { - IconButton(onClick = onToggleVisibility) { - Icon( - imageVector = - if (isVisible) { - Icons.Filled.VisibilityOff - } else { - Icons.Filled.Visibility - }, - contentDescription = null - ) - } - }, - colors = - OutlinedTextFieldDefaults.colors( - focusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - ) -} - @Composable private fun pdConsentText(linkColor: Color): AnnotatedString = buildAnnotatedString { append(stringResource(R.string.pd_consent_prefix)) @@ -286,22 +266,25 @@ private fun ConsentRow( onLinkClicked: (tag: String) -> Unit ) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { - Checkbox( - checked = checked, - onCheckedChange = onCheckedChange, - colors = - CheckboxDefaults.colors( - checkedColor = Theme.colors.purple700, - uncheckedColor = Theme.colors.purple200, - checkmarkColor = Theme.colors.white900 - ) - ) + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = Modifier.padding(top = 2.dp), + colors = + CheckboxDefaults.colors( + checkedColor = Theme.colors.purple700, + uncheckedColor = Theme.colors.black200, + checkmarkColor = Theme.colors.white900 + ) + ) + } Spacer(Modifier.width(8.dp)) ClickableText( text = text, style = - MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.onSurface, + Theme.typography.body1.copy( + color = Theme.colors.black900, textDecoration = null ), onClick = { offset -> @@ -314,24 +297,132 @@ private fun ConsentRow( } } -@Preview(showBackground = true) +@Preview(showBackground = true, name = "Empty State") +@Composable +fun RegistrationScreenPreview_Empty() { + YeaHubTheme { + RegistrationContent( + state = RegistrationUiState.Content( + RegistrationFormState( + nickname = "", + nicknameError = null, + email = "", + emailError = null, + password = "", + passwordError = null, + confirmPassword = "", + confirmPasswordError = null, + isPdAccepted = false, + isOfferAccepted = false, + isMailingAccepted = false, + isPasswordVisible = false, + isConfirmPasswordVisible = false, + isSubmitEnabled = false, + isEmailTouched = false, + isPasswordTouched = false, + isConfirmPasswordTouched = false + ) + ), + onAction = {}, + onOpenPdPolicy = {}, + onOpenOffer = {} + ) + } +} + +@Preview(showBackground = true, name = "Valid State") @Composable -fun RegistrationScreenPreview() { - MaterialTheme { - RegistrationScreen( +fun RegistrationScreenPreview_Valid() { + YeaHubTheme { + RegistrationContent( state = RegistrationUiState.Content( RegistrationFormState( - nickname = "admin", - email = "admin@mail.ru", - password = "1234", - confirmPassword = "1234", + nickname = "JohnDoe", + nicknameError = null, + email = "john@example.com", + emailError = null, + password = "Password123!", + passwordError = null, + confirmPassword = "Password123!", + confirmPasswordError = null, + isPasswordVisible = false, + isConfirmPasswordVisible = false, + isPdAccepted = true, + isOfferAccepted = true, + isMailingAccepted = true, + isSubmitEnabled = true, + isEmailTouched = true, + isPasswordTouched = true, + isConfirmPasswordTouched = true + ) + ), + onAction = {}, + onOpenPdPolicy = {}, + onOpenOffer = {} + ) + } +} + +@Preview(showBackground = true, name = "Error State") +@Composable +fun RegistrationScreenPreview_Error() { + YeaHubTheme { + RegistrationContent( + state = + RegistrationUiState.Error( + formState = RegistrationFormState( + nickname = "", + nicknameError = TextOrResource.Text("Имя пользователя не может быть пустым"), + email = "invalid-email", + emailError = TextOrResource.Text("Некорректный email"), + password = "123", + passwordError = TextOrResource.Text("Пароль слишком короткий"), + confirmPassword = "456", + confirmPasswordError = TextOrResource.Text("Пароли не совпадают"), isPasswordVisible = true, isConfirmPasswordVisible = true, + isPdAccepted = false, + isOfferAccepted = false, + isMailingAccepted = false, + isSubmitEnabled = false, + isEmailTouched = true, + isPasswordTouched = true, + isConfirmPasswordTouched = true + ) + ), + onAction = {}, + onOpenPdPolicy = {}, + onOpenOffer = {} + ) + } +} + +@Preview(showBackground = true, name = "Loading State") +@Composable +fun RegistrationScreenPreview_Loading() { + YeaHubTheme { + RegistrationContent( + state = + RegistrationUiState.Loading( + RegistrationFormState( + nickname = "JohnDoe", + nicknameError = null, + email = "john@example.com", + emailError = null, + password = "Password123!", + passwordError = null, + confirmPassword = "Password123!", + confirmPasswordError = null, + isPasswordVisible = false, + isConfirmPasswordVisible = false, isPdAccepted = true, isOfferAccepted = true, - isMailingAccepted = false, - isSubmitEnabled = true + isMailingAccepted = true, + isSubmitEnabled = true, + isEmailTouched = true, + isPasswordTouched = true, + isConfirmPasswordTouched = true ) ), onAction = {}, diff --git a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationUiState.kt b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationUiState.kt index a710ea90..2900ca59 100644 --- a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationUiState.kt +++ b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationUiState.kt @@ -1,16 +1,25 @@ package ru.yeahub.authentication.impl.registration.presentation +import ru.yeahub.core_utils.common.TextOrResource + data class RegistrationFormState( val nickname: String, + val nicknameError: TextOrResource?, val email: String, + val emailError: TextOrResource?, val password: String, + val passwordError: TextOrResource?, val confirmPassword: String, + val confirmPasswordError: TextOrResource?, val isPdAccepted: Boolean, val isOfferAccepted: Boolean, val isMailingAccepted: Boolean, val isPasswordVisible: Boolean, val isConfirmPasswordVisible: Boolean, val isSubmitEnabled: Boolean, + val isEmailTouched: Boolean, + val isPasswordTouched: Boolean, + val isConfirmPasswordTouched: Boolean, ) sealed interface RegistrationUiState { @@ -21,7 +30,7 @@ sealed interface RegistrationUiState { data class Loading(override val formState: RegistrationFormState) : RegistrationUiState - data class Error(val message: String, override val formState: RegistrationFormState) : + data class Error(val message: TextOrResource? = null, override val formState: RegistrationFormState) : RegistrationUiState } @@ -36,4 +45,7 @@ sealed interface RegistrationAction { data object TogglePasswordVisible : RegistrationAction data object ToggleConfirmPasswordVisible : RegistrationAction data object SubmitClicked : RegistrationAction + data class EmailFocusChanged(val hasFocus: Boolean) : RegistrationAction + data class PasswordFocusChanged(val hasFocus: Boolean) : RegistrationAction + data class ConfirmPasswordFocusChanged(val hasFocus: Boolean) : RegistrationAction } diff --git a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationUiStateMapper.kt b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationUiStateMapper.kt index ba05e5cb..1e06ac94 100644 --- a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationUiStateMapper.kt +++ b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationUiStateMapper.kt @@ -1,46 +1,211 @@ package ru.yeahub.authentication.impl.registration.presentation -import android.util.Patterns +import ru.yeahub.authentication.impl.R +import ru.yeahub.authentication.impl.registration.domain.entity.RegistrationError +import ru.yeahub.authentication.impl.registration.domain.entity.RegistrationException +import ru.yeahub.core_utils.common.TextOrResource +import ru.yeahub.core_utils.validation.EmailValidator +import ru.yeahub.core_utils.validation.PasswordValidationError +import ru.yeahub.core_utils.validation.PasswordValidator class RegistrationUiStateMapper { fun getInitialFormState(): RegistrationFormState = RegistrationFormState( nickname = "", + nicknameError = null, email = "", + emailError = null, password = "", + passwordError = null, confirmPassword = "", + confirmPasswordError = null, isPdAccepted = false, isOfferAccepted = false, isMailingAccepted = false, isPasswordVisible = false, isConfirmPasswordVisible = false, isSubmitEnabled = false, + isEmailTouched = false, + isPasswordTouched = false, + isConfirmPasswordTouched = false, ) - fun getInitialState(): RegistrationUiState = RegistrationUiState.Content(getInitialFormState()) + fun mapToInitialState(): RegistrationUiState { + return RegistrationUiState.Content(getInitialFormState()) + } - fun getScreenState( - formState: RegistrationFormState, - isLoading: Boolean, - errorMessage: String? + fun mapToUpdatedState( + currentState: RegistrationUiState, + action: RegistrationAction ): RegistrationUiState { - val validatedForm = formState.revalidate() - return when { - isLoading -> RegistrationUiState.Loading(validatedForm) - errorMessage != null -> RegistrationUiState.Error(errorMessage, validatedForm) - else -> RegistrationUiState.Content(validatedForm) + val newForm = updateFormState(currentState.formState, action) + return RegistrationUiState.Content(validateForm(newForm)) + } + + private fun updateFormState( + currentForm: RegistrationFormState, + action: RegistrationAction + ): RegistrationFormState { + return when (action) { + is RegistrationAction.NicknameChanged, + is RegistrationAction.EmailChanged, + is RegistrationAction.PasswordChanged, + is RegistrationAction.ConfirmPasswordChanged -> handleFieldAction(currentForm, action) + + is RegistrationAction.EmailFocusChanged, + is RegistrationAction.PasswordFocusChanged, + is RegistrationAction.ConfirmPasswordFocusChanged -> handleFocusAction(currentForm, action) + + is RegistrationAction.PdAcceptedChanged, + is RegistrationAction.OfferAcceptedChanged, + is RegistrationAction.MailingAcceptedChanged -> handleConsentAction(currentForm, action) + + is RegistrationAction.TogglePasswordVisible, + is RegistrationAction.ToggleConfirmPasswordVisible -> handleToggleAction(currentForm, action) + + is RegistrationAction.SubmitClicked -> currentForm } } - private fun RegistrationFormState.revalidate(): RegistrationFormState { - val nicknameOk = nickname.trim().isNotEmpty() - val emailOk = Patterns.EMAIL_ADDRESS.matcher(email.trim()).matches() - val passOk = isPasswordValid(password) - val confirmOk = password == confirmPassword && confirmPassword.isNotEmpty() - val requiredConsentsOk = isPdAccepted && isOfferAccepted + private fun handleFieldAction( + form: RegistrationFormState, + action: RegistrationAction + ): RegistrationFormState = when (action) { + is RegistrationAction.NicknameChanged -> form.copy(nickname = action.value) + is RegistrationAction.EmailChanged -> form.copy(email = action.value) + is RegistrationAction.PasswordChanged -> form.copy(password = action.value) + is RegistrationAction.ConfirmPasswordChanged -> form.copy(confirmPassword = action.value) + is RegistrationAction.PdAcceptedChanged, + is RegistrationAction.OfferAcceptedChanged, + is RegistrationAction.MailingAcceptedChanged, + is RegistrationAction.TogglePasswordVisible, + is RegistrationAction.ToggleConfirmPasswordVisible, + is RegistrationAction.SubmitClicked, + is RegistrationAction.EmailFocusChanged, + is RegistrationAction.PasswordFocusChanged, + is RegistrationAction.ConfirmPasswordFocusChanged -> form + } - return copy( - isSubmitEnabled = nicknameOk && emailOk && passOk && confirmOk && requiredConsentsOk + private fun handleFocusAction( + form: RegistrationFormState, + action: RegistrationAction + ): RegistrationFormState = when (action) { + is RegistrationAction.EmailFocusChanged -> + form.copy(isEmailTouched = !action.hasFocus && form.email.isNotEmpty()) + is RegistrationAction.PasswordFocusChanged -> + form.copy(isPasswordTouched = !action.hasFocus && form.password.isNotEmpty()) + is RegistrationAction.ConfirmPasswordFocusChanged -> + form.copy(isConfirmPasswordTouched = !action.hasFocus && form.confirmPassword.isNotEmpty()) + is RegistrationAction.NicknameChanged, + is RegistrationAction.EmailChanged, + is RegistrationAction.PasswordChanged, + is RegistrationAction.ConfirmPasswordChanged, + is RegistrationAction.PdAcceptedChanged, + is RegistrationAction.OfferAcceptedChanged, + is RegistrationAction.MailingAcceptedChanged, + is RegistrationAction.TogglePasswordVisible, + is RegistrationAction.ToggleConfirmPasswordVisible, + is RegistrationAction.SubmitClicked -> form + } + + private fun handleConsentAction( + form: RegistrationFormState, + action: RegistrationAction + ): RegistrationFormState = when (action) { + is RegistrationAction.PdAcceptedChanged -> form.copy(isPdAccepted = action.value) + is RegistrationAction.OfferAcceptedChanged -> form.copy(isOfferAccepted = action.value) + is RegistrationAction.MailingAcceptedChanged -> form.copy(isMailingAccepted = action.value) + is RegistrationAction.NicknameChanged, + is RegistrationAction.EmailChanged, + is RegistrationAction.PasswordChanged, + is RegistrationAction.ConfirmPasswordChanged, + is RegistrationAction.TogglePasswordVisible, + is RegistrationAction.ToggleConfirmPasswordVisible, + is RegistrationAction.SubmitClicked, + is RegistrationAction.EmailFocusChanged, + is RegistrationAction.PasswordFocusChanged, + is RegistrationAction.ConfirmPasswordFocusChanged -> form + } + + private fun handleToggleAction( + form: RegistrationFormState, + action: RegistrationAction + ): RegistrationFormState = when (action) { + is RegistrationAction.TogglePasswordVisible -> + form.copy(isPasswordVisible = !form.isPasswordVisible) + is RegistrationAction.ToggleConfirmPasswordVisible -> + form.copy(isConfirmPasswordVisible = !form.isConfirmPasswordVisible) + is RegistrationAction.NicknameChanged, + is RegistrationAction.EmailChanged, + is RegistrationAction.PasswordChanged, + is RegistrationAction.ConfirmPasswordChanged, + is RegistrationAction.PdAcceptedChanged, + is RegistrationAction.OfferAcceptedChanged, + is RegistrationAction.MailingAcceptedChanged, + is RegistrationAction.SubmitClicked, + is RegistrationAction.EmailFocusChanged, + is RegistrationAction.PasswordFocusChanged, + is RegistrationAction.ConfirmPasswordFocusChanged -> form + } + + fun mapToLoadingState(currentState: RegistrationUiState): RegistrationUiState { + return RegistrationUiState.Loading(currentState.formState) + } + + fun mapToErrorState( + currentState: RegistrationUiState, + exception: RegistrationException + ): RegistrationUiState { + val errorResource = mapExceptionToResource(exception) + return RegistrationUiState.Error(errorResource, currentState.formState) + } + + fun mapExceptionToResource(exception: RegistrationException): TextOrResource { + return when (exception.error) { + RegistrationError.Conflict -> TextOrResource.Resource(R.string.error_user_already_exists) + RegistrationError.NotFound -> TextOrResource.Resource(R.string.error_resource_not_found) + else -> TextOrResource.Resource(R.string.login_unknown_error) + } + } + + private fun validateForm(form: RegistrationFormState): RegistrationFormState { + val isEmailValid = EmailValidator.isValid(form.email.trim()) + val passwordErrors = PasswordValidator.validate(form.password) + val isPassValid = passwordErrors.isEmpty() + val arePasswordsMatch = form.password == form.confirmPassword && form.confirmPassword.isNotEmpty() + val isNicknameOk = form.nickname.trim().isNotEmpty() + + val matchError = if (form.isConfirmPasswordTouched && form.confirmPassword.isNotEmpty() && !arePasswordsMatch) { + TextOrResource.Resource(R.string.error_passwords_not_match) + } else { + null + } + + return form.copy( + emailError = if (form.isEmailTouched && !isEmailValid) { + TextOrResource.Resource(R.string.error_email_invalid) + } else { + null + }, + passwordError = getPasswordError(form, passwordErrors) ?: matchError, + confirmPasswordError = matchError, + isSubmitEnabled = isNicknameOk && isEmailValid && isPassValid && + arePasswordsMatch && form.isPdAccepted && form.isOfferAccepted ) } + + private fun getPasswordError( + form: RegistrationFormState, + errors: Set + ): TextOrResource? { + if (!form.isPasswordTouched || form.password.isEmpty() || errors.isEmpty()) { + return null + } + return when (errors.first()) { + PasswordValidationError.TOO_SHORT -> TextOrResource.Resource(R.string.error_password_too_short) + PasswordValidationError.NO_UPPERCASE -> TextOrResource.Resource(R.string.error_password_no_uppercase) + PasswordValidationError.NO_DIGIT -> TextOrResource.Resource(R.string.error_password_no_digit) + PasswordValidationError.NO_SPECIAL_CHAR -> TextOrResource.Resource(R.string.error_password_no_special_char) + } + } } diff --git a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationViewModel.kt b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationViewModel.kt index e6b3eaa3..711f9103 100644 --- a/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationViewModel.kt +++ b/feature/authentication/impl/src/main/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationViewModel.kt @@ -2,117 +2,61 @@ package ru.yeahub.authentication.impl.registration.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import ru.yeahub.authentication.impl.registration.domain.entity.RegistrationError import ru.yeahub.authentication.impl.registration.domain.entity.RegistrationException import ru.yeahub.authentication.impl.registration.domain.entity.RegistrationModel import ru.yeahub.authentication.impl.registration.domain.usecase.RegistrationUseCase +import ru.yeahub.core_utils.common.TextOrResource -private const val UI_STATE_STOP_TIMEOUT = 5000L +sealed interface RegistrationCommand { + data object NavigateToSuccess : RegistrationCommand + data class ShowError(val message: TextOrResource) : RegistrationCommand +} class RegistrationViewModel( private val registrationUseCase: RegistrationUseCase, private val mapper: RegistrationUiStateMapper ) : ViewModel() { - private val formData = MutableStateFlow(mapper.getInitialFormState()) - private val isLoading = MutableStateFlow(false) - private val error = MutableStateFlow(null) + private val _state = MutableStateFlow(mapper.mapToInitialState()) + val state: StateFlow = _state.asStateFlow() - val state: StateFlow = - combine(formData, isLoading, error) { form, loading, err -> - mapper.getScreenState(form, loading, err) - } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(UI_STATE_STOP_TIMEOUT), - initialValue = mapper.getInitialState() - ) + private val _commands = MutableSharedFlow() + val commands: SharedFlow = _commands.asSharedFlow() fun onAction(action: RegistrationAction) { when (action) { - is RegistrationAction.ConfirmPasswordChanged -> { - updateForm { it.copy(confirmPassword = action.value) } - } - - is RegistrationAction.EmailChanged -> { - updateForm { it.copy(email = action.value) } - } - - is RegistrationAction.MailingAcceptedChanged -> { - updateForm { it.copy(isMailingAccepted = action.value) } - } - - is RegistrationAction.NicknameChanged -> { - updateForm { it.copy(nickname = action.value) } - } - - is RegistrationAction.OfferAcceptedChanged -> { - updateForm { it.copy(isOfferAccepted = action.value) } - } - - is RegistrationAction.PasswordChanged -> { - updateForm { it.copy(password = action.value) } + RegistrationAction.SubmitClicked -> submitRegistration() + else -> { + _state.value = mapper.mapToUpdatedState(_state.value, action) } - - is RegistrationAction.PdAcceptedChanged -> { - updateForm { it.copy(isPdAccepted = action.value) } - } - - RegistrationAction.SubmitClicked -> { - submitRegistration() - } - - RegistrationAction.ToggleConfirmPasswordVisible -> { - updateForm { it.copy(isConfirmPasswordVisible = !it.isConfirmPasswordVisible) } - } - - RegistrationAction.TogglePasswordVisible -> { - updateForm { it.copy(isPasswordVisible = !it.isPasswordVisible) } - } - } - } - - private fun updateForm(transform: (RegistrationFormState) -> RegistrationFormState) { - formData.update { transform(it) } - if (error.value != null) { - error.value = null } } private fun submitRegistration() { - isLoading.value = true - error.value = null + _state.value = mapper.mapToLoadingState(_state.value) viewModelScope.launch { try { - val currentForm = formData.value - val userModel = - RegistrationModel( - nickname = currentForm.nickname, - email = currentForm.email, - password = currentForm.password, - isMailingAccepted = currentForm.isMailingAccepted - ) + val currentForm = _state.value.formState + val userModel = RegistrationModel( + nickname = currentForm.nickname, + email = currentForm.email, + password = currentForm.password, + isMailingAccepted = currentForm.isMailingAccepted + ) registrationUseCase.invoke(userModel) - isLoading.value = false + _state.value = mapper.mapToInitialState() + _commands.emit(RegistrationCommand.NavigateToSuccess) } catch (e: RegistrationException) { - val errorMessage = - when (e.error) { - RegistrationError.EmailAlreadyExists -> "Такой Email уже существует" - RegistrationError.NickNameTaken -> "Никнейм занят" - RegistrationError.InvalidCredentials -> "Неверные данные" - RegistrationError.Network -> "Ошибка сети. Проверьте подключение" - RegistrationError.Server, RegistrationError.Unknown -> - "Произошла ошибка на сервере" - } - isLoading.value = false - error.value = errorMessage + val errorResource = mapper.mapExceptionToResource(e) + _state.value = mapper.mapToErrorState(_state.value, e) + _commands.emit(RegistrationCommand.ShowError(errorResource)) } } } diff --git a/feature/authentication/impl/src/main/res/values/strings.xml b/feature/authentication/impl/src/main/res/values/strings.xml index 2972c407..b85e4da8 100644 --- a/feature/authentication/impl/src/main/res/values/strings.xml +++ b/feature/authentication/impl/src/main/res/values/strings.xml @@ -15,6 +15,10 @@ , в соответствии с Политикой в отношении ПД "Подтверждаю что ознакомился(-ась) с " Договором-офертой + + https://docs.google.com/document/d/1OX9Fc3HPhjL_U9xkF2P3vsSmM1fAdhmQ88J2NT0emFo/edit?tab=t.0 + https://docs.google.com/document/d/1tU9lgOu_W21DAoHOH0kQmTWq5hG3rvxv_Q_jzRi8Gh4/edit?tab=t.0 + Вход в личный кабинет Вход Забыли пароль? @@ -29,5 +33,14 @@ Произошла непредвиденная ошибка Показать пароль Скрыть пароль + + Email введен неверно + Минимум 8 символов + Нужна заглавная буква + Нужна цифра + Нужен спецсимвол + Пароли не совпадают + Пользователь уже существует + Ресурс не найден Не удалось сохранить сессию. Попробуйте войти ещё раз \ No newline at end of file diff --git a/feature/authentication/impl/src/test/java/ru/yeahub/authentication/impl/registration/data/mapper/RegistrationDomainToDataMapperTest.kt b/feature/authentication/impl/src/test/java/ru/yeahub/authentication/impl/registration/data/mapper/RegistrationDomainToDataMapperTest.kt new file mode 100644 index 00000000..95b05b6c --- /dev/null +++ b/feature/authentication/impl/src/test/java/ru/yeahub/authentication/impl/registration/data/mapper/RegistrationDomainToDataMapperTest.kt @@ -0,0 +1,31 @@ +package ru.yeahub.authentication.impl.registration.data.mapper + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import ru.yeahub.authentication.impl.registration.domain.entity.RegistrationModel +import ru.yeahub.network_api.models.RegistrationRequestDto + +class RegistrationDomainToDataMapperTest { + + private val mapper = RegistrationDomainToDataMapper() + + @Test + fun `map RegistrationModel to RegistrationRequestDto correctly`() { + val model = RegistrationModel( + nickname = "testuser", + email = "test@mail.ru", + password = "Password123!", + isMailingAccepted = true + ) + + val expected = RegistrationRequestDto( + username = "testuser", + email = "test@mail.ru", + password = "Password123!" + ) + + val result = mapper.map(model) + + assertEquals(expected, result) + } +} diff --git a/feature/authentication/impl/src/test/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationUiStateMapperTest.kt b/feature/authentication/impl/src/test/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationUiStateMapperTest.kt new file mode 100644 index 00000000..f9795519 --- /dev/null +++ b/feature/authentication/impl/src/test/java/ru/yeahub/authentication/impl/registration/presentation/RegistrationUiStateMapperTest.kt @@ -0,0 +1,405 @@ +package ru.yeahub.authentication.impl.registration.presentation + +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.ArgumentsProvider +import org.junit.jupiter.params.provider.ArgumentsSource +import ru.yeahub.authentication.impl.R +import ru.yeahub.authentication.impl.registration.domain.entity.RegistrationError +import ru.yeahub.authentication.impl.registration.domain.entity.RegistrationException +import ru.yeahub.core_utils.common.TextOrResource +import ru.yeahub.core_utils.validation.EmailValidator +import java.util.stream.Stream + +class RegistrationUiStateMapperTest { + + private val mapper = RegistrationUiStateMapper() + + @BeforeEach + fun setUp() { + mockkObject(EmailValidator) + every { EmailValidator.isValid(any()) } returns true + } + + @AfterEach + fun tearDown() { + unmockkAll() + } + + @ParameterizedTest + @ArgumentsSource(UpdatedStateArgumentsProvider::class) + fun `should update form state by action correctly`(testCase: UpdatedStateTestCase) { + val initialForm = testCase.setupForm(mapper.getInitialFormState()) + val currentState = RegistrationUiState.Content(initialForm) + + val result = mapper.mapToUpdatedState(currentState, testCase.action) + + assertEquals(testCase.expectedForm, result.formState) + } + + @ParameterizedTest + @ArgumentsSource(ValidationArgumentsProvider::class) + fun `should validate form and return correct isSubmitEnabled`(testCase: ValidationTestCase) { + every { EmailValidator.isValid(any()) } returns testCase.isEmailValid + + val form = mapper.getInitialFormState().copy( + nickname = testCase.nickname, + email = testCase.email, + password = testCase.password, + confirmPassword = testCase.confirmPassword, + isPdAccepted = testCase.isPdAccepted, + isOfferAccepted = testCase.isOfferAccepted, + ) + val currentState = RegistrationUiState.Content(form) + + val result = mapper.mapToUpdatedState( + currentState, + testCase.action, + ) + + assertEquals(testCase.expectedSubmitEnabled, result.formState.isSubmitEnabled) + } + + @ParameterizedTest + @ArgumentsSource(ErrorStateArgumentsProvider::class) + fun `should map exception to correct error resource`(testCase: ErrorStateTestCase) { + val currentState = RegistrationUiState.Content(mapper.getInitialFormState()) + + val result = mapper.mapToErrorState(currentState, testCase.exception) + + assertTrue(result is RegistrationUiState.Error) + val errorMessage = (result as RegistrationUiState.Error).message as TextOrResource.Resource + assertEquals(testCase.expectedErrorRes, errorMessage.resource) + } + + data class UpdatedStateTestCase( + val name: String, + val action: RegistrationAction, + val setupForm: (RegistrationFormState) -> RegistrationFormState = { it }, + val expectedForm: RegistrationFormState, + ) { + override fun toString(): String = name + } + + data class ValidationTestCase( + val name: String, + val nickname: String, + val email: String, + val isEmailValid: Boolean, + val password: String, + val confirmPassword: String, + val isPdAccepted: Boolean, + val isOfferAccepted: Boolean, + val action: RegistrationAction, + val expectedSubmitEnabled: Boolean, + ) { + override fun toString(): String = name + } + + data class ErrorStateTestCase( + val name: String, + val exception: RegistrationException, + val expectedErrorRes: Int, + ) { + override fun toString(): String = name + } + + class UpdatedStateArgumentsProvider : ArgumentsProvider { + override fun provideArguments(context: ExtensionContext?): Stream { + return Stream.of( + Arguments.of( + UpdatedStateTestCase( + name = "NicknameChanged обновляет nickname", + action = RegistrationAction.NicknameChanged("John"), + expectedForm = initialFormWith(nickname = "John"), + ) + ), + Arguments.of( + UpdatedStateTestCase( + name = "EmailChanged обновляет email", + action = RegistrationAction.EmailChanged("test@mail.ru"), + expectedForm = initialFormWith(email = "test@mail.ru"), + ) + ), + Arguments.of( + UpdatedStateTestCase( + name = "PasswordChanged обновляет password", + action = RegistrationAction.PasswordChanged("Pass123!"), + expectedForm = initialFormWith(password = "Pass123!"), + ) + ), + Arguments.of( + UpdatedStateTestCase( + name = "ConfirmPasswordChanged обновляет confirmPassword", + action = RegistrationAction.ConfirmPasswordChanged("Pass123!"), + expectedForm = initialFormWith(confirmPassword = "Pass123!"), + ) + ), + Arguments.of( + UpdatedStateTestCase( + name = "EmailFocusChanged(false) при непустом email ставит isEmailTouched = true", + action = RegistrationAction.EmailFocusChanged(false), + setupForm = { it.copy(email = "test@mail.ru") }, + expectedForm = initialFormWith( + email = "test@mail.ru", + isEmailTouched = true, + ), + ) + ), + Arguments.of( + UpdatedStateTestCase( + name = "EmailFocusChanged(true) не ставит isEmailTouched", + action = RegistrationAction.EmailFocusChanged(true), + setupForm = { it.copy(email = "test@mail.ru", isEmailTouched = true) }, + expectedForm = initialFormWith(email = "test@mail.ru"), + ) + ), + Arguments.of( + UpdatedStateTestCase( + name = "PasswordFocusChanged(false) при непустом пароле ставит isPasswordTouched = true", + action = RegistrationAction.PasswordFocusChanged(false), + setupForm = { it.copy(password = "Pass123!") }, + expectedForm = initialFormWith( + password = "Pass123!", + isPasswordTouched = true, + ), + ) + ), + Arguments.of( + UpdatedStateTestCase( + name = "ConfirmPasswordFocusChanged(false) при непустом " + + "пароле ставит isConfirmPasswordTouched = true", + action = RegistrationAction.ConfirmPasswordFocusChanged(false), + setupForm = { it.copy(password = "Pass123!", confirmPassword = "Pass123!") }, + expectedForm = initialFormWith( + password = "Pass123!", + confirmPassword = "Pass123!", + isConfirmPasswordTouched = true, + ), + ) + ), + Arguments.of( + UpdatedStateTestCase( + name = "PdAcceptedChanged(true) обновляет isPdAccepted", + action = RegistrationAction.PdAcceptedChanged(true), + expectedForm = initialFormWith(isPdAccepted = true), + ) + ), + Arguments.of( + UpdatedStateTestCase( + name = "OfferAcceptedChanged(true) обновляет isOfferAccepted", + action = RegistrationAction.OfferAcceptedChanged(true), + expectedForm = initialFormWith(isOfferAccepted = true), + ) + ), + Arguments.of( + UpdatedStateTestCase( + name = "MailingAcceptedChanged(true) обновляет isMailingAccepted", + action = RegistrationAction.MailingAcceptedChanged(true), + expectedForm = initialFormWith(isMailingAccepted = true), + ) + ), + Arguments.of( + UpdatedStateTestCase( + name = "TogglePasswordVisible переключает isPasswordVisible на true", + action = RegistrationAction.TogglePasswordVisible, + expectedForm = initialFormWith(isPasswordVisible = true), + ) + ), + Arguments.of( + UpdatedStateTestCase( + name = "ToggleConfirmPasswordVisible переключает isConfirmPasswordVisible на true", + action = RegistrationAction.ToggleConfirmPasswordVisible, + expectedForm = initialFormWith(isConfirmPasswordVisible = true), + ) + ), + Arguments.of( + UpdatedStateTestCase( + name = "SubmitClicked не меняет форму", + action = RegistrationAction.SubmitClicked, + expectedForm = initialFormWith(), + ) + ), + ) + } + } + + class ValidationArgumentsProvider : ArgumentsProvider { + override fun provideArguments(context: ExtensionContext?): Stream { + return Stream.of( + Arguments.of( + ValidationTestCase( + name = "Все поля валидны — submit разрешён", + nickname = "user", + email = "test@test.com", + isEmailValid = true, + password = "Pass123!", + confirmPassword = "Pass123!", + isPdAccepted = true, + isOfferAccepted = true, + expectedSubmitEnabled = true, + action = RegistrationAction.NicknameChanged("user") + ) + ), + Arguments.of( + ValidationTestCase( + name = "Пустой nickname — submit запрещён", + nickname = "", + email = "test@test.com", + isEmailValid = true, + password = "Pass123!", + confirmPassword = "Pass123!", + isPdAccepted = true, + isOfferAccepted = true, + expectedSubmitEnabled = false, + action = RegistrationAction.NicknameChanged("") + ) + ), + Arguments.of( + ValidationTestCase( + name = "Невалидный email — submit запрещён", + nickname = "user", + email = "invalid", + isEmailValid = false, + password = "Pass123!", + confirmPassword = "Pass123!", + isPdAccepted = true, + isOfferAccepted = true, + expectedSubmitEnabled = false, + action = RegistrationAction.EmailChanged("user") + ) + ), + Arguments.of( + ValidationTestCase( + name = "Слабый пароль — submit запрещён", + nickname = "user", + email = "test@test.com", + isEmailValid = true, + password = "short", + confirmPassword = "short", + isPdAccepted = true, + isOfferAccepted = true, + expectedSubmitEnabled = false, + action = RegistrationAction.PasswordChanged("1234") + ) + ), + Arguments.of( + ValidationTestCase( + name = "Пароли не совпадают — submit запрещён", + nickname = "user", + email = "test@test.com", + isEmailValid = true, + password = "Pass123!", + confirmPassword = "Mismatch!", + isPdAccepted = true, + isOfferAccepted = true, + expectedSubmitEnabled = false, + action = RegistrationAction.ConfirmPasswordChanged("1234") + ) + ), + Arguments.of( + ValidationTestCase( + name = "ПД не принят — submit запрещён", + nickname = "user", + email = "test@test.com", + isEmailValid = true, + password = "Pass123!", + confirmPassword = "Pass123!", + isPdAccepted = false, + isOfferAccepted = true, + expectedSubmitEnabled = false, + action = RegistrationAction.PdAcceptedChanged(false) + ) + ), + Arguments.of( + ValidationTestCase( + name = "Оферта не принята — submit запрещён", + nickname = "user", + email = "test@test.com", + isEmailValid = true, + password = "Pass123!", + confirmPassword = "Pass123!", + isPdAccepted = true, + isOfferAccepted = false, + expectedSubmitEnabled = false, + action = RegistrationAction.OfferAcceptedChanged(false) + ) + ), + ) + } + } + + class ErrorStateArgumentsProvider : ArgumentsProvider { + override fun provideArguments(context: ExtensionContext?): Stream { + return Stream.of( + Arguments.of( + ErrorStateTestCase( + name = "Conflict → error_user_already_exists", + exception = RegistrationException(RegistrationError.Conflict), + expectedErrorRes = R.string.error_user_already_exists, + ) + ), + Arguments.of( + ErrorStateTestCase( + name = "NotFound → error_resource_not_found", + exception = RegistrationException(RegistrationError.NotFound), + expectedErrorRes = R.string.error_resource_not_found, + ) + ), + Arguments.of( + ErrorStateTestCase( + name = "UnknownError → login_unknown_error", + exception = RegistrationException(RegistrationError.UnknownError), + expectedErrorRes = R.string.login_unknown_error, + ) + ), + ) + } + } +} + +private fun initialFormWith( + nickname: String = "", + nicknameError: TextOrResource? = null, + email: String = "", + emailError: TextOrResource? = null, + password: String = "", + passwordError: TextOrResource? = null, + confirmPassword: String = "", + confirmPasswordError: TextOrResource? = null, + isPdAccepted: Boolean = false, + isOfferAccepted: Boolean = false, + isMailingAccepted: Boolean = false, + isPasswordVisible: Boolean = false, + isConfirmPasswordVisible: Boolean = false, + isSubmitEnabled: Boolean = false, + isEmailTouched: Boolean = false, + isPasswordTouched: Boolean = false, + isConfirmPasswordTouched: Boolean = false, +): RegistrationFormState = RegistrationFormState( + nickname = nickname, + nicknameError = nicknameError, + email = email, + emailError = emailError, + password = password, + passwordError = passwordError, + confirmPassword = confirmPassword, + confirmPasswordError = confirmPasswordError, + isPdAccepted = isPdAccepted, + isOfferAccepted = isOfferAccepted, + isMailingAccepted = isMailingAccepted, + isPasswordVisible = isPasswordVisible, + isConfirmPasswordVisible = isConfirmPasswordVisible, + isSubmitEnabled = isSubmitEnabled, + isEmailTouched = isEmailTouched, + isPasswordTouched = isPasswordTouched, + isConfirmPasswordTouched = isConfirmPasswordTouched, +) diff --git a/feature/forgot-password/.gitignore b/feature/forgot-password/.gitignore deleted file mode 100644 index 42afabfd..00000000 --- a/feature/forgot-password/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/feature/forgot-password/api/.gitignore b/feature/forgot-password/api/.gitignore deleted file mode 100644 index 42afabfd..00000000 --- a/feature/forgot-password/api/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/feature/forgot-password/api/build.gradle.kts b/feature/forgot-password/api/build.gradle.kts deleted file mode 100644 index b4a39edf..00000000 --- a/feature/forgot-password/api/build.gradle.kts +++ /dev/null @@ -1,44 +0,0 @@ -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) -} - -android { - namespace = "ru.yeahub.api" - compileSdk { - version = release(35) - } - - defaultConfig { - minSdk = 24 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - kotlinOptions { - jvmTarget = "11" - } -} - -dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.appcompat) - implementation(libs.material) - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file diff --git a/feature/forgot-password/api/consumer-rules.pro b/feature/forgot-password/api/consumer-rules.pro deleted file mode 100644 index e69de29b..00000000 diff --git a/feature/forgot-password/api/src/main/AndroidManifest.xml b/feature/forgot-password/api/src/main/AndroidManifest.xml deleted file mode 100644 index a5918e68..00000000 --- a/feature/forgot-password/api/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/feature/forgot-password/build.gradle.kts b/feature/forgot-password/build.gradle.kts deleted file mode 100644 index c6d924a4..00000000 --- a/feature/forgot-password/build.gradle.kts +++ /dev/null @@ -1,44 +0,0 @@ -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) -} - -android { - namespace = "ru.yeahub.forgot_password" - compileSdk { - version = release(35) - } - - defaultConfig { - minSdk = 24 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - kotlinOptions { - jvmTarget = "11" - } -} - -dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.appcompat) - implementation(libs.material) - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file diff --git a/feature/forgot-password/consumer-rules.pro b/feature/forgot-password/consumer-rules.pro deleted file mode 100644 index e69de29b..00000000 diff --git a/feature/forgot-password/impl/.gitignore b/feature/forgot-password/impl/.gitignore deleted file mode 100644 index 42afabfd..00000000 --- a/feature/forgot-password/impl/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/feature/forgot-password/impl/build.gradle.kts b/feature/forgot-password/impl/build.gradle.kts deleted file mode 100644 index 290ccdc7..00000000 --- a/feature/forgot-password/impl/build.gradle.kts +++ /dev/null @@ -1,61 +0,0 @@ -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) -} - -android { - namespace = "ru.yeahub.impl" - compileSdk = 35 - - defaultConfig { - minSdk = 24 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - kotlinOptions { - jvmTarget = "11" - } - buildFeatures { - compose = true - } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.8" - } -} - -dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.appcompat) - implementation(libs.material) - implementation(libs.androidx.compose.runtime) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.foundation) - implementation(libs.androidx.compose.material3) - implementation(libs.timber) - implementation(project(":core:ui")) - - // Compose Preview dependencies - implementation(libs.androidx.ui.tooling.preview) - debugImplementation(libs.androidx.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) - - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file diff --git a/feature/forgot-password/impl/consumer-rules.pro b/feature/forgot-password/impl/consumer-rules.pro deleted file mode 100644 index e69de29b..00000000 diff --git a/feature/forgot-password/impl/src/main/AndroidManifest.xml b/feature/forgot-password/impl/src/main/AndroidManifest.xml deleted file mode 100644 index a5918e68..00000000 --- a/feature/forgot-password/impl/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/data/dto/ForgotPasswordRequestDto.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/data/dto/ForgotPasswordRequestDto.kt deleted file mode 100644 index 0517819a..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/data/dto/ForgotPasswordRequestDto.kt +++ /dev/null @@ -1,5 +0,0 @@ -package ru.yeahub.impl.data.dto - -data class ForgotPasswordRequestDto( - val email: String -) \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/data/dto/ForgotPasswordResponseDto.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/data/dto/ForgotPasswordResponseDto.kt deleted file mode 100644 index cfc5132c..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/data/dto/ForgotPasswordResponseDto.kt +++ /dev/null @@ -1,6 +0,0 @@ -package ru.yeahub.impl.data.dto - -data class ForgotPasswordResponseDto( - val ok: Boolean, - val message: String -) \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/data/mapper/ForgotPasswordMapper.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/data/mapper/ForgotPasswordMapper.kt deleted file mode 100644 index ce20fabe..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/data/mapper/ForgotPasswordMapper.kt +++ /dev/null @@ -1,14 +0,0 @@ -package ru.yeahub.impl.data.mapper - -import ru.yeahub.impl.data.dto.ForgotPasswordResponseDto -import ru.yeahub.impl.domain.ForgotPasswordResult - -class ForgotPasswordMapper { - fun toDomain(responseDto: ForgotPasswordResponseDto): ForgotPasswordResult { - return if (responseDto.ok) { - ForgotPasswordResult.Success - } else { - ForgotPasswordResult.Error(responseDto.message) - } - } -} \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/data/remote/AuthApi.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/data/remote/AuthApi.kt deleted file mode 100644 index b673889e..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/data/remote/AuthApi.kt +++ /dev/null @@ -1,8 +0,0 @@ -package ru.yeahub.impl.data.remote - -import ru.yeahub.impl.data.dto.ForgotPasswordRequestDto -import ru.yeahub.impl.data.dto.ForgotPasswordResponseDto - -interface AuthApi { - suspend fun forgotPassword(request: ForgotPasswordRequestDto): ForgotPasswordResponseDto -} \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/data/repository/ForgotPasswordRepositoryImpl.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/data/repository/ForgotPasswordRepositoryImpl.kt deleted file mode 100644 index 118a9cae..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/data/repository/ForgotPasswordRepositoryImpl.kt +++ /dev/null @@ -1,48 +0,0 @@ -package ru.yeahub.impl.data.repository - -import kotlinx.coroutines.CancellationException -import java.io.IOException -import ru.yeahub.impl.data.dto.ForgotPasswordRequestDto -import ru.yeahub.impl.data.mapper.ForgotPasswordMapper -import ru.yeahub.impl.data.remote.AuthApi -import ru.yeahub.impl.domain.ForgotPasswordRepository -import ru.yeahub.impl.domain.ForgotPasswordResult -import timber.log.Timber - -class ForgotPasswordRepositoryImpl( - private val api: AuthApi, - private val mapper: ForgotPasswordMapper -) : ForgotPasswordRepository { - - @Suppress("TooGenericExceptionCaught") - override suspend fun sendResetLink(email: String): ForgotPasswordResult { - return try { - val dto = api.forgotPassword(ForgotPasswordRequestDto(email)) - mapper.toDomain(dto) - } catch (e: CancellationException) { - throw e - } catch (e: IOException) { - Timber.e( - "Network error sending reset link for email: $email," + - "error: ${e.message}" - ) - ForgotPasswordResult.Error( - "Ошибка сети. Проверьте подключение к интернету." - ) - } catch (e: IllegalArgumentException) { - Timber.e( - "Invalid data error sending reset link for email: $email, " + - "error: ${e.message}" - ) - ForgotPasswordResult.Error( - "Некорректные данные. Проверьте введенный email." - ) - } catch (e: Exception) { - Timber.e( - "Unexpected error sending reset link for email: $email," + - "error: ${e.message}" - ) - ForgotPasswordResult.Error("Неизвестная ошибка. Попробуйте позже.") - } - } -} \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/di/ForgotPasswordFactory.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/di/ForgotPasswordFactory.kt deleted file mode 100644 index f7202c10..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/di/ForgotPasswordFactory.kt +++ /dev/null @@ -1,23 +0,0 @@ -package ru.yeahub.impl.di - -import ru.yeahub.impl.data.mapper.ForgotPasswordMapper -import ru.yeahub.impl.data.remote.AuthApi -import ru.yeahub.impl.data.repository.ForgotPasswordRepositoryImpl -import ru.yeahub.impl.domain.SendResetLinkUseCase -import ru.yeahub.impl.presentation.ForgotPasswordViewModel -import ru.yeahub.impl.presentation.mapper.EmailValidator -import ru.yeahub.impl.presentation.mapper.ForgotPasswordScreenMapper - -object ForgotPasswordFactory { - - fun createViewModel( - api: AuthApi, - mapper: ForgotPasswordMapper, - emailValidator: EmailValidator, - ): ForgotPasswordViewModel { - val forgotPasswordRepository = ForgotPasswordRepositoryImpl(api, mapper) - val sendResetLinkUseCase = SendResetLinkUseCase(forgotPasswordRepository) - val forgotPasswordScreenMapper = ForgotPasswordScreenMapper(emailValidator) - return ForgotPasswordViewModel(forgotPasswordScreenMapper, sendResetLinkUseCase) - } -} \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/domain/ForgotPasswordRepository.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/domain/ForgotPasswordRepository.kt deleted file mode 100644 index 07aef5e8..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/domain/ForgotPasswordRepository.kt +++ /dev/null @@ -1,6 +0,0 @@ -package ru.yeahub.impl.domain - -interface ForgotPasswordRepository { - - suspend fun sendResetLink(email: String): ForgotPasswordResult -} \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/domain/ForgotPasswordResult.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/domain/ForgotPasswordResult.kt deleted file mode 100644 index 4220d03e..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/domain/ForgotPasswordResult.kt +++ /dev/null @@ -1,6 +0,0 @@ -package ru.yeahub.impl.domain - -sealed interface ForgotPasswordResult { - data object Success : ForgotPasswordResult - data class Error(val message: String) : ForgotPasswordResult -} \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/domain/SendResetLinkUseCase.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/domain/SendResetLinkUseCase.kt deleted file mode 100644 index c141eb30..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/domain/SendResetLinkUseCase.kt +++ /dev/null @@ -1,15 +0,0 @@ -package ru.yeahub.impl.domain - -class SendResetLinkUseCase( - private val repository: ForgotPasswordRepository -) { - suspend operator fun invoke(email: String): ForgotPasswordResult { - val errorMessage = when { - email.isBlank() -> "Введите email" - !email.contains("@") -> "Некорректный email" - else -> null - } - return errorMessage?.let { ForgotPasswordResult.Error(it) } - ?: repository.sendResetLink(email.trim()) - } -} \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/ForgotPasswordViewModel.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/ForgotPasswordViewModel.kt deleted file mode 100644 index 346a31fc..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/ForgotPasswordViewModel.kt +++ /dev/null @@ -1,123 +0,0 @@ -package ru.yeahub.impl.presentation - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import ru.yeahub.impl.domain.ForgotPasswordResult -import ru.yeahub.impl.domain.SendResetLinkUseCase -import ru.yeahub.impl.presentation.intents.ForgotPasswordCommand -import ru.yeahub.impl.presentation.intents.ForgotPasswordEvent -import ru.yeahub.impl.presentation.mapper.ForgotPasswordScreenMapper -import ru.yeahub.impl.presentation.state.ForgotPasswordScreenState -import ru.yeahub.impl.presentation.state.ForgotPasswordState - -class ForgotPasswordViewModel( - private val forgotPasswordScreenMapper: ForgotPasswordScreenMapper, - private val sendResetLinkUseCase: SendResetLinkUseCase -) : ViewModel() { - - private val mutableState = MutableStateFlow( - ForgotPasswordState( - email = "", - isLoading = false, - error = null, - emailValidationError = "", - isSuccessDialogVisible = false, - ) - ) - - private val mutableUiState = - MutableStateFlow(ForgotPasswordScreenState.Initial) - val uiState: StateFlow = mutableUiState.asStateFlow() - - private val mutableCommands = - MutableSharedFlow() - val commands: SharedFlow = mutableCommands - - init { - viewModelScope.launch { - mutableState.collect { state -> - mutableUiState.value = forgotPasswordScreenMapper.getScreenState(state) - } - } - } - - fun handleEvents(event: ForgotPasswordEvent) { - when (event) { - is ForgotPasswordEvent.EmailChanged -> onEmailChanged(event.value) - is ForgotPasswordEvent.SubmitClicked -> onSubmit() - is ForgotPasswordEvent.BackClicked -> handleBackClick() - } - } - - private fun onEmailChanged(value: String) { - mutableState.update { currentState -> - val validationError = if (value.isNotBlank()) { - forgotPasswordScreenMapper.validateEmail(value) - } else { - null - } - currentState.copy( - email = value, - emailValidationError = validationError, - error = null - ) - } - } - - private fun onSubmit() { - val currentState = mutableState.value - val email = currentState.email.trim() - - if (!forgotPasswordScreenMapper.canSubmit(email)) { - mutableState.update { - it.copy(emailValidationError = forgotPasswordScreenMapper.validateEmail(email)) - } - return - } - - mutableState.update { it.copy(isLoading = true, error = null) } - - viewModelScope.launch { - when (val result = sendResetLinkUseCase(email)) { - is ForgotPasswordResult.Success -> { - mutableState.update { - it.copy( - isLoading = false, - isSuccessDialogVisible = true - ) - } - emitCommand(ForgotPasswordCommand.NavigateToCheckEmail) - } - - is ForgotPasswordResult.Error -> { - mutableState.update { - it.copy( - isLoading = false, - error = result.message - ) - } - emitCommand(ForgotPasswordCommand.ShowSnackbar(result.message)) - } - } - } - } - - private fun handleBackClick() { - viewModelScope.launch { - mutableCommands.emit(ForgotPasswordCommand.NavigateBack) - } - } - - private fun emitCommand(command: ForgotPasswordCommand) { - viewModelScope.launch { - mutableCommands.emit(command) - } - } -} \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/intents/ForgotPasswordCommand.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/intents/ForgotPasswordCommand.kt deleted file mode 100644 index 091724ec..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/intents/ForgotPasswordCommand.kt +++ /dev/null @@ -1,7 +0,0 @@ -package ru.yeahub.impl.presentation.intents - -sealed interface ForgotPasswordCommand { - data class ShowSnackbar(val message: String) : ForgotPasswordCommand - data object NavigateBack : ForgotPasswordCommand - data object NavigateToCheckEmail : ForgotPasswordCommand -} \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/intents/ForgotPasswordEvent.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/intents/ForgotPasswordEvent.kt deleted file mode 100644 index 72324f87..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/intents/ForgotPasswordEvent.kt +++ /dev/null @@ -1,7 +0,0 @@ -package ru.yeahub.impl.presentation.intents - -sealed interface ForgotPasswordEvent { - data class EmailChanged(val value: String) : ForgotPasswordEvent - data object SubmitClicked : ForgotPasswordEvent - data object BackClicked : ForgotPasswordEvent -} \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/mapper/EmailValidator.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/mapper/EmailValidator.kt deleted file mode 100644 index 19d9b3e7..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/mapper/EmailValidator.kt +++ /dev/null @@ -1,28 +0,0 @@ -package ru.yeahub.impl.presentation.mapper - -import android.util.Patterns - -sealed interface EmailValidationResult { - data object Valid : EmailValidationResult - data object Empty : EmailValidationResult - data class Invalid(val errorMessage: String) : EmailValidationResult -} - -class EmailValidator { - - fun validate(email: String): EmailValidationResult { - val trimmedEmail = email.trim() - - return when { - trimmedEmail.isBlank() -> EmailValidationResult.Empty - !Patterns.EMAIL_ADDRESS.matcher(trimmedEmail).matches() -> { - EmailValidationResult.Invalid("Введите корректный email") - } - else -> EmailValidationResult.Valid - } - } - - fun isValid(email: String): Boolean { - return validate(email) is EmailValidationResult.Valid - } -} \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/mapper/ForgotPasswordScreenMapper.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/mapper/ForgotPasswordScreenMapper.kt deleted file mode 100644 index 8dd1cbac..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/mapper/ForgotPasswordScreenMapper.kt +++ /dev/null @@ -1,41 +0,0 @@ -package ru.yeahub.impl.presentation.mapper - -import ru.yeahub.impl.presentation.state.ForgotPasswordScreenState -import ru.yeahub.impl.presentation.state.ForgotPasswordState - -class ForgotPasswordScreenMapper( - private val emailValidator: EmailValidator, -) { - fun getScreenState(state: ForgotPasswordState): ForgotPasswordScreenState { - return when { - state.error != null -> { - ForgotPasswordScreenState.Error(message = state.error) - } - - state.email.isBlank() && state.emailValidationError == null -> { - ForgotPasswordScreenState.Initial - } - - else -> { - ForgotPasswordScreenState.Content( - email = state.email, - isLoading = state.isLoading, - emailError = state.emailValidationError, - isSent = state.isSuccessDialogVisible, - ) - } - } - } - - fun validateEmail(email: String): String? { - return when (val result = emailValidator.validate(email)) { - is EmailValidationResult.Valid -> null - is EmailValidationResult.Empty -> null - is EmailValidationResult.Invalid -> result.errorMessage - } - } - - fun canSubmit(email: String): Boolean { - return emailValidator.isValid(email) - } -} diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/state/ForgotPasswordScreenState.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/state/ForgotPasswordScreenState.kt deleted file mode 100644 index 11735ce2..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/state/ForgotPasswordScreenState.kt +++ /dev/null @@ -1,14 +0,0 @@ -package ru.yeahub.impl.presentation.state - -sealed class ForgotPasswordScreenState { - data object Initial : ForgotPasswordScreenState() - data class Content( - val email: String, - val isLoading: Boolean, - val emailError: String?, - val isSent: Boolean, - ) : ForgotPasswordScreenState() { - val isEmailValid: Boolean = emailError == null && email.isNotBlank() - } - data class Error(val message: String?) : ForgotPasswordScreenState() -} \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/state/ForgotPasswordState.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/state/ForgotPasswordState.kt deleted file mode 100644 index 78486f01..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/presentation/state/ForgotPasswordState.kt +++ /dev/null @@ -1,9 +0,0 @@ -package ru.yeahub.impl.presentation.state - -data class ForgotPasswordState( - val email: String, - val isLoading: Boolean, - val error: String?, - val emailValidationError: String?, - val isSuccessDialogVisible: Boolean, -) \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/ui/ForgotPasswordModalWindow.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/ui/ForgotPasswordModalWindow.kt deleted file mode 100644 index 4fbefe02..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/ui/ForgotPasswordModalWindow.kt +++ /dev/null @@ -1,173 +0,0 @@ -@file:OptIn(ExperimentalMaterial3Api::class) - -package ru.yeahub.impl.ui - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import kotlinx.coroutines.delay -import ru.yeahub.core_ui.example.staticPreview.StaticPreview -import ru.yeahub.core_ui.theme.Theme -import ru.yeahub.impl.R - -@Composable -fun InstructionsSentDialog( - onDismiss: () -> Unit, - onResend: () -> Unit, - modifier: Modifier = Modifier, -) { - Dialog(onDismissRequest = onDismiss) { - InstructionsSentDialogContent( - onDismiss = onDismiss, - onResend = onResend, - modifier = modifier - ) - } -} - -@Composable -private fun InstructionsSentDialogContent( - onDismiss: () -> Unit, - onResend: () -> Unit, - modifier: Modifier = Modifier, -) { - val color = Theme.colors.purple700 - val shape = RoundedCornerShape(16.dp) - - var secondsLeft by remember { mutableStateOf(60) } - - LaunchedEffect(Unit) { - while (secondsLeft > 0) { - delay(1_000) - secondsLeft-- - } - } - Surface( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - shape = shape, - color = Color.White, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = BorderStroke(1.dp, color), - ) { - Box( - modifier = Modifier - .clip(shape) - .padding(16.dp), - contentAlignment = Alignment.TopEnd - ) { - IconButton( - onClick = onDismiss, - modifier = Modifier - .size(32.dp) - ) { - Image( - painter = painterResource(id = R.drawable.close), - contentDescription = stringResource(R.string.close), - modifier = Modifier.size(24.dp) - ) - } - - Column( - modifier = Modifier - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - painter = painterResource(id = R.drawable.letter), - contentDescription = stringResource(R.string.letter), - modifier = Modifier - .size(116.dp) - .padding(vertical = 2.dp) - ) - - Text( - text = stringResource(R.string.instructions_letter), - style = Theme.typography.body5Accent, - textAlign = TextAlign.Center - ) - - Spacer(Modifier.height(6.dp)) - - Text( - text = stringResource(R.string.instruction), - style = Theme.typography.body2, - color = Theme.colors.black900, - textAlign = TextAlign.Center, - lineHeight = MaterialTheme.typography.bodyMedium.lineHeight - ) - - Spacer(Modifier.height(14.dp)) - - if (secondsLeft > 0) { - Text( - text = stringResource(R.string.resend, secondsLeft), - color = Theme.colors.black300, - style = Theme.typography.body2 - ) - } else { - Text( - text = stringResource(R.string.resend_button), - color = color, - style = Theme.typography.body2, - modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .clickable(onClick = onResend) - .padding(horizontal = 12.dp, vertical = 10.dp) - ) - } - - Spacer(Modifier.height(8.dp)) - - Text( - text = stringResource(R.string.ok), - color = color, - style = Theme.typography.body2, - modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .clickable(onClick = onDismiss) - .padding(horizontal = 12.dp, vertical = 4.dp) - ) - } - } - } -} - -@StaticPreview -@Composable -fun InstructionsSentDialogPreview() { - InstructionsSentDialogContent( - onDismiss = {}, - onResend = {} - ) -} diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/ui/ForgotPasswordRoute.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/ui/ForgotPasswordRoute.kt deleted file mode 100644 index 7264ad8e..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/ui/ForgotPasswordRoute.kt +++ /dev/null @@ -1,33 +0,0 @@ -package ru.yeahub.impl.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import ru.yeahub.impl.presentation.ForgotPasswordViewModel -import ru.yeahub.impl.presentation.intents.ForgotPasswordCommand - -@Composable -fun ForgotPasswordRoute( - viewModel: ForgotPasswordViewModel, - onBack: () -> Unit, - onCheckEmail: () -> Unit, - showSnackbar: suspend (String) -> Unit -) { - val state by viewModel.uiState.collectAsState() - - LaunchedEffect(Unit) { - viewModel.commands.collect { command -> - when (command) { - is ForgotPasswordCommand.NavigateBack -> onBack() - is ForgotPasswordCommand.NavigateToCheckEmail -> onCheckEmail() - is ForgotPasswordCommand.ShowSnackbar -> showSnackbar(command.message) - } - } - } - - ForgotPasswordScreen( - state = state, - onEvent = viewModel::handleEvents - ) -} \ No newline at end of file diff --git a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/ui/ForgotPasswordScreen.kt b/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/ui/ForgotPasswordScreen.kt deleted file mode 100644 index e8e55718..00000000 --- a/feature/forgot-password/impl/src/main/java/ru/yeahub/impl/ui/ForgotPasswordScreen.kt +++ /dev/null @@ -1,173 +0,0 @@ -@file:OptIn(ExperimentalMaterial3Api::class) - -package ru.yeahub.impl.ui - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -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.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import ru.yeahub.core_ui.component.PrimaryButton -import ru.yeahub.core_ui.component.YeahubButtonColors -import ru.yeahub.core_ui.component.textInput.DefaultTextField -import ru.yeahub.core_ui.component.textInput.TextInputColorsDefaults -import ru.yeahub.core_ui.theme.Theme -import ru.yeahub.impl.R -import ru.yeahub.impl.presentation.intents.ForgotPasswordEvent -import ru.yeahub.impl.presentation.state.ForgotPasswordScreenState - -@Composable -fun ForgotPasswordScreen( - modifier: Modifier = Modifier, - state: ForgotPasswordScreenState, - onEvent: (ForgotPasswordEvent) -> Unit -) { - Column( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - .padding(top = 32.dp) - ) { - Text( - text = stringResource(R.string.title_forgot_password), - style = Theme.typography.head5 - ) - - Spacer(Modifier.height(6.dp)) - - Text( - text = stringResource(R.string.enter_email_instruction), - style = Theme.typography.body2Accent, - color = Theme.colors.black500 - ) - - Spacer(Modifier.height(96.dp)) - - Text( - text = stringResource(R.string.email), - style = Theme.typography.body3Accent - ) - - Spacer(Modifier.height(6.dp)) - - val email = when (state) { - is ForgotPasswordScreenState.Content -> state.email - else -> "" - } - val isLoading = when (state) { - is ForgotPasswordScreenState.Content -> state.isLoading - else -> false - } - val emailError = when (state) { - is ForgotPasswordScreenState.Content -> state.emailError - else -> null - } - val isEmailValid = when (state) { - is ForgotPasswordScreenState.Content -> state.isEmailValid - else -> false - } - - DefaultTextField( - value = email, - onValueChange = { onEvent(ForgotPasswordEvent.EmailChanged(it)) }, - label = stringResource(R.string.enter_email), - isEnabled = !isLoading, - isError = emailError != null, - showLeadingIcon = false, - onExpandedChange = { }, - modifier = Modifier - .fillMaxWidth() - .height(52.dp), - colors = TextInputColorsDefaults.defaultColors() - ) - - if (emailError != null) { - Spacer(Modifier.height(4.dp)) - Text( - text = emailError, - style = Theme.typography.body3, - color = Theme.colors.red500 - ) - } - - Spacer(Modifier.height(20.dp)) - - PrimaryButton( - onClick = { onEvent(ForgotPasswordEvent.SubmitClicked) }, - enabled = isEmailValid && !isLoading, - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - colors = YeahubButtonColors( - contentColor = Theme.colors.white900, - containerColor = Theme.colors.purple700, - disabledContentColor = Theme.colors.black200, - disabledContainerColor = Theme.colors.black50 - ) - ) { - Text( - text = stringResource(R.string.send_button), - style = Theme.typography.body4 - ) - } - } -} - -@Preview(showBackground = true, name = "Initial State") -@Composable -fun ForgotPasswordScreenPreview_Initial() { - ForgotPasswordScreen( - state = ForgotPasswordScreenState.Initial, - onEvent = {} - ) -} - -@Preview(showBackground = true, name = "Valid Email") -@Composable -fun ForgotPasswordScreenPreview_Valid() { - ForgotPasswordScreen( - state = ForgotPasswordScreenState.Content( - email = "user@example.com", - isLoading = false, - emailError = null, - isSent = false - ), - onEvent = {} - ) -} - -@Preview(showBackground = true, name = "Invalid Email") -@Composable -fun ForgotPasswordScreenPreview_Invalid() { - ForgotPasswordScreen( - state = ForgotPasswordScreenState.Content( - email = "invalid-email", - isLoading = false, - emailError = "Введите корректный email", - isSent = false - ), - onEvent = {} - ) -} - -@Preview(showBackground = true, name = "Loading") -@Composable -fun ForgotPasswordScreenPreview_Loading() { - ForgotPasswordScreen( - state = ForgotPasswordScreenState.Content( - email = "user@example.com", - isLoading = true, - emailError = null, - isSent = false - ), - onEvent = {} - ) -} diff --git a/feature/forgot-password/impl/src/main/res/drawable/close.jpg b/feature/forgot-password/impl/src/main/res/drawable/close.jpg deleted file mode 100644 index 4de6c081..00000000 Binary files a/feature/forgot-password/impl/src/main/res/drawable/close.jpg and /dev/null differ diff --git a/feature/forgot-password/impl/src/main/res/drawable/letter.jpg b/feature/forgot-password/impl/src/main/res/drawable/letter.jpg deleted file mode 100644 index 4b336363..00000000 Binary files a/feature/forgot-password/impl/src/main/res/drawable/letter.jpg and /dev/null differ diff --git a/feature/forgot-password/impl/src/main/res/values/strings.xml b/feature/forgot-password/impl/src/main/res/values/strings.xml deleted file mode 100644 index 6dfa844e..00000000 --- a/feature/forgot-password/impl/src/main/res/values/strings.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - Close - Letter - Мы отправили письмо\nс инструкциями - Если вы не получили письмо\nс инструкциями, проверьте, пожалуйста,\nпапку «Спам» или попробуйте отправить\nзапрос еще раз - Отправить повторно через %1$d c - Отправить повторно - Хорошо - Забыли пароль? - Для восстановления пароля введите адрес\nэл.почты, на который вы регистрировались.\nМы отправим письмо для восстановления пароля - Электронная почта - Введите электронную почту - Отправить - Не удалось отправить письмо - \ No newline at end of file diff --git a/feature/forgot-password/src/main/AndroidManifest.xml b/feature/forgot-password/src/main/AndroidManifest.xml deleted file mode 100644 index a5918e68..00000000 --- a/feature/forgot-password/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index a8d55eb7..ecf16c5a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -64,7 +64,4 @@ include(":feature:authentication:impl") include(":feature:enter-and-registration") include(":feature:enter-and-registration:api") include(":feature:enter-and-registration:impl") -//include(":feature:forgot-password") -//include(":feature:forgot-password:api") -//include(":feature:forgot-password:impl")