diff --git a/app/src/main/java/com/stslex/aproselection/SelectApplication.kt b/app/src/main/java/com/stslex/aproselection/SelectApplication.kt index 4422154..2f4dc44 100644 --- a/app/src/main/java/com/stslex/aproselection/SelectApplication.kt +++ b/app/src/main/java/com/stslex/aproselection/SelectApplication.kt @@ -1,19 +1,32 @@ package com.stslex.aproselection import android.app.Application +import com.stslex.aproselection.controller.AuthController import com.stslex.aproselection.core.datastore.coreDataStoreModule import com.stslex.aproselection.core.network.di.ModuleCoreNetwork.moduleCoreNetwork import com.stslex.aproselection.di.appModule import com.stslex.aproselection.feature.auth.di.ModuleFeatureAuth.moduleFeatureAuth import com.stslex.aproselection.feature.home.di.moduleFeatureHome +import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin class SelectApplication : Application() { + private val appController: AuthController by inject() + override fun onCreate() { super.onCreate() + setupDependencies() + initControllers() + } + + private fun initControllers() { + appController.init() + } + + private fun setupDependencies() { startKoin { androidLogger() androidContext(applicationContext) diff --git a/app/src/main/java/com/stslex/aproselection/controller/AuthController.kt b/app/src/main/java/com/stslex/aproselection/controller/AuthController.kt new file mode 100644 index 0000000..a9269d5 --- /dev/null +++ b/app/src/main/java/com/stslex/aproselection/controller/AuthController.kt @@ -0,0 +1,11 @@ +package com.stslex.aproselection.controller + +import kotlinx.coroutines.flow.StateFlow + +interface AuthController { + + val isAuth: StateFlow + + fun init() +} + diff --git a/app/src/main/java/com/stslex/aproselection/controller/AuthControllerImpl.kt b/app/src/main/java/com/stslex/aproselection/controller/AuthControllerImpl.kt new file mode 100644 index 0000000..5067b85 --- /dev/null +++ b/app/src/main/java/com/stslex/aproselection/controller/AuthControllerImpl.kt @@ -0,0 +1,46 @@ +package com.stslex.aproselection.controller + +import com.stslex.aproselection.core.datastore.AppDataStore +import com.stslex.aproselection.core.network.client.NetworkClient +import com.stslex.aproselection.core.ui.core.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class AuthControllerImpl( + private val dataStore: AppDataStore, + private val networkClient: NetworkClient, +) : AuthController { + + private val scope = CoroutineScope(SupervisorJob()) + private val _isAuth = MutableStateFlow(null) + override val isAuth: StateFlow + get() = _isAuth.asStateFlow() + + override fun init() { + dataStore.token + .catch { error -> + Logger.exception(error) + } + .onEach { token -> + if (token.isBlank()) { + networkClient.regenerateToken() + } + } + .launchIn(scope) + + dataStore.uuid + .catch { error -> + Logger.exception(error) + } + .onEach { uuid -> + _isAuth.emit(uuid.isNotBlank()) + } + .launchIn(scope) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/stslex/aproselection/di/AppModule.kt b/app/src/main/java/com/stslex/aproselection/di/AppModule.kt index c6e6376..782b023 100644 --- a/app/src/main/java/com/stslex/aproselection/di/AppModule.kt +++ b/app/src/main/java/com/stslex/aproselection/di/AppModule.kt @@ -1,9 +1,23 @@ package com.stslex.aproselection.di +import androidx.navigation.NavHostController +import com.stslex.aproselection.controller.AuthController +import com.stslex.aproselection.controller.AuthControllerImpl +import com.stslex.aproselection.core.ui.navigation.navigator.Navigator +import com.stslex.aproselection.core.ui.navigation.navigator.NavigatorImpl import com.stslex.aproselection.ui.InitialAppViewModel import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.singleOf import org.koin.dsl.module val appModule = module { + singleOf(::AuthControllerImpl) { bind() } +} + +fun navigationModule( + navHostController: NavHostController +) = module { + single { NavigatorImpl(navHostController) } viewModelOf(::InitialAppViewModel) } \ No newline at end of file diff --git a/app/src/main/java/com/stslex/aproselection/navigation/NavigationHost.kt b/app/src/main/java/com/stslex/aproselection/navigation/NavigationHost.kt index c41fecb..c423e36 100644 --- a/app/src/main/java/com/stslex/aproselection/navigation/NavigationHost.kt +++ b/app/src/main/java/com/stslex/aproselection/navigation/NavigationHost.kt @@ -1,78 +1,24 @@ package com.stslex.aproselection.navigation -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import com.stslex.aproselection.core.ui.navigation.AppDestination -import com.stslex.aproselection.core.ui.navigation.NavigationScreen +import com.stslex.aproselection.core.ui.navigation.destination.AppDestination import com.stslex.aproselection.feature.auth.ui.navigation.authRouter import com.stslex.aproselection.feature.home.ui.navigation.homeRouter -import com.stslex.aproselection.ui.InitialAppViewModel -import org.koin.androidx.compose.koinViewModel -import org.koin.core.parameter.parametersOf @Composable fun NavigationHost( navController: NavHostController, + startDestination: AppDestination, modifier: Modifier = Modifier, - startDestination: AppDestination = AppDestination.SPLASH ) { - val navigator: (NavigationScreen) -> Unit = { screen -> - when (screen) { - is NavigationScreen.PopBackStack -> navController.popBackStack() - else -> navController.navigateScreen(screen) - } - } - - val viewModel = koinViewModel( - parameters = { parametersOf(navigator) } - ) - val isAuth by remember { - viewModel.isAuth - }.collectAsState() - NavHost( navController = navController, startDestination = startDestination.route ) { - composable(NavigationScreen.Splash.screenRoute) { - Box( - contentAlignment = Alignment.Center - ) { - Text(text = "splash") // TODO replace with norm navigator - } - val screen = when (isAuth) { - true -> NavigationScreen.Home - false -> NavigationScreen.Auth - null -> null - } - screen?.let(navigator) - } - authRouter(modifier, navigator) - homeRouter(modifier, navigator) + authRouter(modifier) + homeRouter(modifier) } } - -fun NavHostController.navigateScreen(screen: NavigationScreen) { - val currentRoute = this.currentDestination?.route - if (currentRoute == screen.screenRoute) return - navigate(screen.screenRoute) { - if (screen.isSingleTop.not()) return@navigate - currentRoute?.let { route -> - popUpTo(route) { - inclusive = true - saveState = true - } - } - - launchSingleTop = true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/stslex/aproselection/ui/InitialApp.kt b/app/src/main/java/com/stslex/aproselection/ui/InitialApp.kt index 204e964..9db8cf1 100644 --- a/app/src/main/java/com/stslex/aproselection/ui/InitialApp.kt +++ b/app/src/main/java/com/stslex/aproselection/ui/InitialApp.kt @@ -1,27 +1,34 @@ package com.stslex.aproselection.ui -import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import androidx.navigation.compose.rememberNavController -import com.stslex.aproselection.core.ui.theme.AppTheme +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.navigation.NavHostController +import com.stslex.aproselection.core.ui.navigation.destination.AppDestination import com.stslex.aproselection.navigation.NavigationHost +import org.koin.androidx.compose.koinViewModel @Composable -fun InitialApp() { - val navController = rememberNavController() +fun InitialApp( + navController: NavHostController +) { + val viewModel: InitialAppViewModel = koinViewModel() + val isInitialAuth by remember { + viewModel.isInitialAuth + }.collectAsState() - Box { - NavigationHost( - navController = navController, - ) + LaunchedEffect(Unit) { + viewModel.init() } -} -@Preview(showBackground = true) -@Composable -fun InitialAppPreview() { - AppTheme { - InitialApp() - } -} \ No newline at end of file + AppDestination + .getStartDestination(isInitialAuth) + ?.let { destination -> + NavigationHost( + navController = navController, + startDestination = destination + ) + } +} diff --git a/app/src/main/java/com/stslex/aproselection/ui/InitialAppViewModel.kt b/app/src/main/java/com/stslex/aproselection/ui/InitialAppViewModel.kt index 1a784c2..3ac651b 100644 --- a/app/src/main/java/com/stslex/aproselection/ui/InitialAppViewModel.kt +++ b/app/src/main/java/com/stslex/aproselection/ui/InitialAppViewModel.kt @@ -1,48 +1,33 @@ package com.stslex.aproselection.ui import androidx.lifecycle.viewModelScope -import com.stslex.aproselection.core.datastore.AppDataStore -import com.stslex.aproselection.core.network.client.NetworkClient +import com.stslex.aproselection.controller.AuthController import com.stslex.aproselection.core.ui.base.BaseViewModel -import com.stslex.aproselection.core.ui.navigation.NavigationScreen +import com.stslex.aproselection.core.ui.navigation.destination.NavigationScreen +import com.stslex.aproselection.core.ui.navigation.navigator.Navigator import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach class InitialAppViewModel( - dataStore: AppDataStore, - navigate: (NavigationScreen) -> Unit, - networkClient: NetworkClient, + private val navigator: Navigator, + private val controller: AuthController ) : BaseViewModel() { - private val _isAuth = MutableStateFlow(null) - val isAuth: StateFlow - get() = _isAuth.asStateFlow() + private val _isInitialAuth = MutableStateFlow(null) + val isInitialAuth: StateFlow + get() = _isInitialAuth.asStateFlow() - // TODO (вынести из init блока) - init { - dataStore.token - .catch { error -> - handleError(error) - } - .onEach { token -> - if (token.isBlank()) { - networkClient.regenerateToken() + fun init() { + controller.isAuth + .onEach { isAuth -> + if (isInitialAuth.value == null) { + _isInitialAuth.emit(isAuth) } - } - .launchIn(viewModelScope) - - dataStore.uuid - .catch { error -> - handleError(error) - } - .onEach { uuid -> - _isAuth.emit(uuid.isNotBlank()) - if (uuid.isBlank()) { - navigate(NavigationScreen.Auth) + if (isAuth == false) { + navigator.navigate(NavigationScreen.Auth) } } .launchIn(viewModelScope) diff --git a/app/src/main/java/com/stslex/aproselection/ui/MainActivity.kt b/app/src/main/java/com/stslex/aproselection/ui/MainActivity.kt index 7f226d1..d95a918 100644 --- a/app/src/main/java/com/stslex/aproselection/ui/MainActivity.kt +++ b/app/src/main/java/com/stslex/aproselection/ui/MainActivity.kt @@ -1,30 +1,38 @@ package com.stslex.aproselection.ui import android.os.Bundle -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.lifecycle.lifecycleScope -import com.stslex.aproselection.core.datastore.AppDataStore -import com.stslex.aproselection.core.network.client.NetworkClient +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController import com.stslex.aproselection.core.ui.theme.AppTheme -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import org.koin.android.ext.android.inject +import com.stslex.aproselection.di.navigationModule +import org.koin.androidx.compose.getKoin class MainActivity : ComponentActivity() { - private val dataStore by inject() - private val networkClient by inject() - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { + val navHostController = rememberNavController() + SetupComposeDependencies(navHostController) AppTheme { - InitialApp() + InitialApp( + navController = navHostController + ) } } } + + @Composable + private fun SetupComposeDependencies( + navController: NavHostController + ) { + val navModule = navigationModule(navController) + getKoin().loadModules( + listOf(navModule) + ) + } } diff --git a/app/src/test/java/com/stslex/aproselection/DiKoinModuleTest.kt b/app/src/test/java/com/stslex/aproselection/DiKoinModuleTest.kt index df90d9c..767eda5 100644 --- a/app/src/test/java/com/stslex/aproselection/DiKoinModuleTest.kt +++ b/app/src/test/java/com/stslex/aproselection/DiKoinModuleTest.kt @@ -3,7 +3,7 @@ package com.stslex.aproselection import android.content.Context import com.stslex.aproselection.core.datastore.coreDataStoreModule import com.stslex.aproselection.core.network.di.ModuleCoreNetwork.moduleCoreNetwork -import com.stslex.aproselection.core.ui.navigation.NavigationScreen +import com.stslex.aproselection.core.ui.navigation.destination.NavigationScreen import com.stslex.aproselection.di.appModule import com.stslex.aproselection.feature.auth.di.ModuleFeatureAuth.moduleFeatureAuth import com.stslex.aproselection.feature.home.di.moduleFeatureHome diff --git a/core/ui/src/main/java/com/stslex/aproselection/core/ui/components/ErrorSnackbar.kt b/core/ui/src/main/java/com/stslex/aproselection/core/ui/components/ErrorSnackbar.kt new file mode 100644 index 0000000..40952c8 --- /dev/null +++ b/core/ui/src/main/java/com/stslex/aproselection/core/ui/components/ErrorSnackbar.kt @@ -0,0 +1,103 @@ +package com.stslex.aproselection.core.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarData +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarVisuals +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.stslex.aproselection.core.ui.theme.AppDimens +import com.stslex.aproselection.core.ui.theme.AppTheme + +@Composable +fun ErrorSnackbar( + snackbarData: SnackbarData, + modifier: Modifier = Modifier, +) { + val isDarkTheme = isSystemInDarkTheme() + val containerColor = if (isDarkTheme) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.errorContainer + } + val contentColor = if (isDarkTheme) { + MaterialTheme.colorScheme.onError + } else { + MaterialTheme.colorScheme.onErrorContainer + } + + Snackbar( + modifier = modifier + .padding(AppDimens.Padding.medium) + .clip(RoundedCornerShape(AppDimens.Corners.big)), + containerColor = containerColor, + contentColor = contentColor + ) { + Row { + Icon( + Icons.Default.Warning, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(AppDimens.Padding.medium)) + Text( + text = snackbarData.visuals.message, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Preview( + device = "id:pixel_6a", showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL +) +@Composable +fun ErrorSnackbarPreview() { + val data = object : SnackbarData { + override val visuals: SnackbarVisuals + get() = object : SnackbarVisuals { + override val actionLabel: String = "action label" + override val duration: SnackbarDuration = SnackbarDuration.Short + override val message: String = "message message message message message message" + override val withDismissAction: Boolean = false + } + + override fun dismiss() = Unit + + override fun performAction() = Unit + } + AppTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + ErrorSnackbar( + modifier = Modifier.align(Alignment.BottomCenter), + snackbarData = data + ) + } + } +} + diff --git a/core/ui/src/main/java/com/stslex/aproselection/core/ui/core/Logger.kt b/core/ui/src/main/java/com/stslex/aproselection/core/ui/core/Logger.kt new file mode 100644 index 0000000..785c443 --- /dev/null +++ b/core/ui/src/main/java/com/stslex/aproselection/core/ui/core/Logger.kt @@ -0,0 +1,21 @@ +package com.stslex.aproselection.core.ui.core + +import android.util.Log + +object Logger { + + private const val DEFAULT_TAG = "PRO_SELECTION" + + fun exception( + throwable: Throwable, + tag: String? = null, + message: String? = null + ) { + val currentTag = "$DEFAULT_TAG:${tag.orEmpty()}" + Log.e( + currentTag, + message ?: throwable.message.orEmpty(), + throwable, + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/AppArguments.kt b/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/destination/AppArguments.kt similarity index 85% rename from core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/AppArguments.kt rename to core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/destination/AppArguments.kt index b030ad8..d63c38e 100644 --- a/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/AppArguments.kt +++ b/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/destination/AppArguments.kt @@ -1,4 +1,4 @@ -package com.stslex.aproselection.core.ui.navigation +package com.stslex.aproselection.core.ui.navigation.destination sealed class AppArguments { diff --git a/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/AppDestination.kt b/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/destination/AppDestination.kt similarity index 72% rename from core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/AppDestination.kt rename to core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/destination/AppDestination.kt index d9d8d3b..0e3db8a 100644 --- a/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/AppDestination.kt +++ b/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/destination/AppDestination.kt @@ -1,11 +1,10 @@ -package com.stslex.aproselection.core.ui.navigation +package com.stslex.aproselection.core.ui.navigation.destination enum class AppDestination( vararg val argsNames: String ) { AUTH, - HOME, - SPLASH; + HOME; val route: String get() = StringBuilder() @@ -26,5 +25,13 @@ enum class AppDestination( companion object { private const val SEPARATOR_ROUTE = "_" private const val TAG_ROUTE = "route" + + fun getStartDestination( + isAuth: Boolean? + ) = when (isAuth) { + true -> HOME + false -> AUTH + null -> null + } } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/NavigationScreen.kt b/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/destination/NavigationScreen.kt similarity index 78% rename from core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/NavigationScreen.kt rename to core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/destination/NavigationScreen.kt index 2520e83..c9ac2be 100644 --- a/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/NavigationScreen.kt +++ b/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/destination/NavigationScreen.kt @@ -1,4 +1,4 @@ -package com.stslex.aproselection.core.ui.navigation +package com.stslex.aproselection.core.ui.navigation.destination sealed interface NavigationScreen { @@ -23,11 +23,6 @@ sealed interface NavigationScreen { override val isSingleTop: Boolean = true } - data object Splash: NavigationScreen{ - override val screen: AppDestination = AppDestination.SPLASH - override val isSingleTop: Boolean = true - } - data object PopBackStack : NavigationScreen { override val screen: AppDestination = throw Exception("PopBackStack") override val appArgs: AppArguments = throw Exception("PopBackStack") diff --git a/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/NavExt.kt b/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/ext/NavExt.kt similarity index 80% rename from core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/NavExt.kt rename to core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/ext/NavExt.kt index c920fb4..4dfbda9 100644 --- a/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/NavExt.kt +++ b/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/ext/NavExt.kt @@ -1,9 +1,10 @@ -package com.stslex.aproselection.core.ui.navigation +package com.stslex.aproselection.core.ui.navigation.ext import androidx.navigation.NamedNavArgument import androidx.navigation.NavBackStackEntry import androidx.navigation.NavType import androidx.navigation.navArgument +import com.stslex.aproselection.core.ui.navigation.destination.AppDestination object NavExt { diff --git a/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/navigator/Navigator.kt b/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/navigator/Navigator.kt new file mode 100644 index 0000000..f52061e --- /dev/null +++ b/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/navigator/Navigator.kt @@ -0,0 +1,9 @@ +package com.stslex.aproselection.core.ui.navigation.navigator + +import com.stslex.aproselection.core.ui.navigation.destination.NavigationScreen + +interface Navigator { + + fun navigate(screen: NavigationScreen) +} + diff --git a/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/navigator/NavigatorImpl.kt b/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/navigator/NavigatorImpl.kt new file mode 100644 index 0000000..adf649e --- /dev/null +++ b/core/ui/src/main/java/com/stslex/aproselection/core/ui/navigation/navigator/NavigatorImpl.kt @@ -0,0 +1,31 @@ +package com.stslex.aproselection.core.ui.navigation.navigator + +import androidx.navigation.NavHostController +import com.stslex.aproselection.core.ui.navigation.destination.NavigationScreen + +class NavigatorImpl( + private val navController: NavHostController +) : Navigator { + + override fun navigate(screen: NavigationScreen) { + when (screen) { + is NavigationScreen.PopBackStack -> navController.popBackStack() + else -> navigateScreen(screen) + } + } + + private fun navigateScreen(screen: NavigationScreen) { + val currentRoute = navController.currentDestination?.route ?: return + if (currentRoute == screen.screenRoute) return + + navController.navigate(screen.screenRoute) { + if (screen.isSingleTop.not()) return@navigate + + popUpTo(currentRoute) { + inclusive = true + saveState = true + } + launchSingleTop = true + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/stslex/aproselection/core/ui/theme/AppDimens.kt b/core/ui/src/main/java/com/stslex/aproselection/core/ui/theme/AppDimens.kt new file mode 100644 index 0000000..2a967b9 --- /dev/null +++ b/core/ui/src/main/java/com/stslex/aproselection/core/ui/theme/AppDimens.kt @@ -0,0 +1,21 @@ +package com.stslex.aproselection.core.ui.theme + +import androidx.compose.ui.unit.dp + +object AppDimens { + + object Padding { + + val smallest = 4.dp + val small = 8.dp + val medium = 16.dp + val big = 32.dp + } + + object Corners { + + val small = 5.dp + val medium = 10.dp + val big = 15.dp + } +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthScreen.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthScreen.kt index 85689cd..cbce505 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthScreen.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthScreen.kt @@ -1,39 +1,41 @@ package com.stslex.aproselection.feature.auth.ui -import androidx.compose.animation.AnimatedVisibility +import android.annotation.SuppressLint +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Divider -import androidx.compose.material3.ElevatedButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp +import com.stslex.aproselection.core.ui.components.ErrorSnackbar +import com.stslex.aproselection.core.ui.theme.AppDimens +import com.stslex.aproselection.core.ui.theme.AppTheme +import com.stslex.aproselection.feature.auth.R +import com.stslex.aproselection.feature.auth.ui.components.AuthBottomText +import com.stslex.aproselection.feature.auth.ui.components.AuthFieldsColumn +import com.stslex.aproselection.feature.auth.ui.components.AuthTitle import com.stslex.aproselection.feature.auth.ui.model.ScreenLoadingState import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenState import com.stslex.aproselection.feature.auth.ui.model.screen.AuthScreenState import com.stslex.aproselection.feature.auth.ui.model.screen.rememberAuthScreenState +import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.UsernameTextFieldState import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +@SuppressLint("CoroutineCreationDuringComposition") @Composable fun AuthScreen( state: AuthScreenState, @@ -47,14 +49,10 @@ fun AuthScreen( ) { AuthScreenContent(state) SnackbarHost( - modifier = Modifier - .padding(16.dp) - .align(Alignment.BottomCenter), + modifier = Modifier.align(Alignment.BottomCenter), hostState = state.snackbarHostState ) { snackbarData -> - Snackbar { - Text(text = snackbarData.visuals.message) - } + ErrorSnackbar(snackbarData) } } if (state.screenLoadingState == ScreenLoadingState.Loading) { @@ -74,113 +72,72 @@ fun AuthScreenContent( state: AuthScreenState, modifier: Modifier = Modifier ) { - - val buttonRes by remember { - derivedStateOf { state.authFieldsState.inverse.buttonResId } - } - - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally + Box( + modifier = modifier + .fillMaxSize() + .padding(AppDimens.Padding.big) ) { - AuthUsernameTextField( - inputUsername = state.username, - onTextChange = state::onUsernameChange + AuthTitle( + modifier = Modifier.align(Alignment.TopCenter), + state = state.authFieldsState ) - Divider(Modifier.padding(16.dp)) - AuthPasswordTextField( - inputPassword = state.password, - onTextChange = state::onPasswordChange + AuthFieldsColumn( + modifier = Modifier.align(Alignment.Center), + state = state ) - AnimatedVisibility( - visible = state.isRegisterState - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(8.dp)) - AuthPasswordTextField( - inputPassword = state.passwordSubmit, - onTextChange = state::onPasswordSubmitChange - ) - } - } - Divider(Modifier.padding(16.dp)) - ElevatedButton( - onClick = state::onSubmitClicked, - enabled = state.isFieldsValid - ) { - Text( - text = "submit", - style = MaterialTheme.typography.headlineMedium - ) - } - Divider(Modifier.padding(16.dp)) - TextButton( + AuthBottomText( + modifier = Modifier + .align(Alignment.BottomCenter), + authFieldsState = state.authFieldsState, onClick = state::onAuthFieldChange - ) { - Text( - text = stringResource(id = buttonRes), - style = MaterialTheme.typography.titleMedium - ) - } + ) } } @Composable fun AuthUsernameTextField( - inputUsername: String, - onTextChange: (String) -> Unit, + state: UsernameTextFieldState, modifier: Modifier = Modifier ) { TextField( - modifier = modifier, - value = inputUsername, - onValueChange = { value -> - if (inputUsername != value) { - onTextChange(value) - } - }, + modifier = modifier + .fillMaxWidth(), + value = state.text, + onValueChange = state::onTextChange, singleLine = true, label = { - Text(text = "username") - } - ) -} - -@Composable -fun AuthPasswordTextField( - inputPassword: String, - onTextChange: (String) -> Unit, - modifier: Modifier = Modifier -) { - TextField( - modifier = modifier, - value = inputPassword, - onValueChange = { value -> - if (inputPassword != value) { - onTextChange(value) - } + Text( + text = stringResource(id = R.string.auth_username_text) + ) }, - singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), supportingText = { - Text(text = "enter password") + Box(modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier.align(Alignment.CenterEnd), + text = state.supportingEndText + ) + } }, - visualTransformation = PasswordVisualTransformation(), - label = { - Text(text = "password") - } ) } -@Preview(device = "id:pixel_6", showSystemUi = true, showBackground = true) +@Preview( + device = "id:pixel_6", showSystemUi = true, showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL +) @Composable fun AuthScreenPreview() { - AuthScreen( - state = rememberAuthScreenState( - screenStateFlow = { MutableStateFlow(ScreenState()) }, - screenEventFlow = { MutableSharedFlow() }, - processAction = {} + AppTheme { + AuthScreen( + state = rememberAuthScreenState( + screenStateFlow = { MutableStateFlow(ScreenState()) }, + screenEventFlow = { MutableSharedFlow() }, + processAction = {} + ) ) - ) + } } \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthViewModel.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthViewModel.kt index 11a54d5..9664ed4 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthViewModel.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthViewModel.kt @@ -2,12 +2,16 @@ package com.stslex.aproselection.feature.auth.ui import androidx.lifecycle.viewModelScope import com.stslex.aproselection.core.ui.base.BaseViewModel -import com.stslex.aproselection.core.ui.navigation.NavigationScreen +import com.stslex.aproselection.core.ui.navigation.destination.NavigationScreen +import com.stslex.aproselection.core.ui.navigation.navigator.Navigator import com.stslex.aproselection.feature.auth.domain.interactor.AuthInteractor import com.stslex.aproselection.feature.auth.ui.model.AuthFieldsState import com.stslex.aproselection.feature.auth.ui.model.ErrorType import com.stslex.aproselection.feature.auth.ui.model.ScreenLoadingState import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenAction +import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenAction.InputAction.PasswordInput +import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenAction.InputAction.PasswordSubmitInput +import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenAction.InputAction.UsernameInput import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenEvent import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenState import kotlinx.coroutines.flow.MutableSharedFlow @@ -23,7 +27,7 @@ import kotlinx.coroutines.flow.update class AuthViewModel( private val interactor: AuthInteractor, - private val navigate: (NavigationScreen) -> Unit + private val navigator: Navigator ) : BaseViewModel() { private val _screenState = MutableStateFlow(ScreenState()) @@ -36,15 +40,15 @@ class AuthViewModel( fun process(action: ScreenAction) { when (action) { - is ScreenAction.UsernameInput -> processUsernameInput(action) + is UsernameInput -> processUsernameInput(action) is ScreenAction.OnSubmitClicked -> processSubmitClicked() - is ScreenAction.PasswordInput -> processPasswordInput(action) - is ScreenAction.PasswordSubmitInput -> processPasswordSubmitInput(action) + is PasswordInput -> processPasswordInput(action) + is PasswordSubmitInput -> processPasswordSubmitInput(action) is ScreenAction.OnAuthFieldChange -> processAuthFieldChange() } } - private fun processUsernameInput(action: ScreenAction.UsernameInput) { + private fun processUsernameInput(action: UsernameInput) { _screenState.update { currentValue -> currentValue.copy( username = action.value @@ -52,7 +56,7 @@ class AuthViewModel( } } - private fun processPasswordInput(action: ScreenAction.PasswordInput) { + private fun processPasswordInput(action: PasswordInput) { _screenState.update { currentValue -> currentValue.copy( password = action.value @@ -60,10 +64,10 @@ class AuthViewModel( } } - private fun processPasswordSubmitInput(action: ScreenAction.PasswordSubmitInput) { + private fun processPasswordSubmitInput(action: PasswordSubmitInput) { _screenState.update { currentValue -> currentValue.copy( - passwordSubmit = action.value + passwordSubmit = action.value, ) } } @@ -119,7 +123,7 @@ class AuthViewModel( } .onEach { setLoadingState(ScreenLoadingState.Content) - navigate(NavigationScreen.Home) + navigator.navigate(NavigationScreen.Home) } .launchIn(viewModelScope) } diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthBottomText.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthBottomText.kt new file mode 100644 index 0000000..2893b7b --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthBottomText.kt @@ -0,0 +1,34 @@ +package com.stslex.aproselection.feature.auth.ui.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import com.stslex.aproselection.feature.auth.ui.model.AuthFieldsState + +@Composable +fun AuthBottomText( + authFieldsState: AuthFieldsState, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val haptic = LocalHapticFeedback.current + TextButton( + modifier = modifier, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onClick() + } + ) { + Text( + text = stringResource( + id = authFieldsState.inverse.buttonResId + ), + style = MaterialTheme.typography.titleMedium + ) + } +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthFieldsColumn.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthFieldsColumn.kt new file mode 100644 index 0000000..a3b6695 --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthFieldsColumn.kt @@ -0,0 +1,88 @@ +package com.stslex.aproselection.feature.auth.ui.components + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.stslex.aproselection.core.ui.theme.AppDimens +import com.stslex.aproselection.core.ui.theme.AppTheme +import com.stslex.aproselection.feature.auth.R +import com.stslex.aproselection.feature.auth.ui.AuthUsernameTextField +import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenState +import com.stslex.aproselection.feature.auth.ui.model.screen.AuthScreenState +import com.stslex.aproselection.feature.auth.ui.model.screen.rememberAuthScreenState +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +fun AuthFieldsColumn( + state: AuthScreenState, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.padding( + horizontal = AppDimens.Padding.big + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AuthUsernameTextField(state.usernameState) + Spacer(Modifier.height(AppDimens.Padding.medium)) + AuthPasswordTextField(state.passwordEnterState) + AnimatedVisibility( + visible = state.isRegisterState + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(AppDimens.Padding.small)) + AuthPasswordTextField(state.passwordSubmitState) + } + } + Spacer(Modifier.height(AppDimens.Padding.big)) + AuthSubmitButton( + isValid = state.isFieldsValid, + onClick = state::onSubmitClicked, + ) + } +} + +@Preview( + device = "id:pixel_6a", showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL +) +@Composable +fun AuthFieldsColumnPreview() { + AppTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background( + MaterialTheme.colorScheme.background + ) + ) { + AuthFieldsColumn( + modifier = Modifier.align(Alignment.Center), + state = rememberAuthScreenState( + screenStateFlow = { + MutableStateFlow(ScreenState()) + }, + screenEventFlow = { + MutableSharedFlow() + }, + processAction = {} + ) + ) + } + } +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthPasswordTextField.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthPasswordTextField.kt new file mode 100644 index 0000000..69069c0 --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthPasswordTextField.kt @@ -0,0 +1,99 @@ +package com.stslex.aproselection.feature.auth.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import com.stslex.aproselection.core.ui.theme.AppTheme +import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.base.PasswordTextFieldState + +@Composable +fun AuthPasswordTextField( + state: PasswordTextFieldState, + modifier: Modifier = Modifier +) { + TextField( + modifier = modifier + .fillMaxWidth(), + value = state.text, + onValueChange = state::onTextChange, + singleLine = true, + supportingText = { + Box(modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier.align(Alignment.CenterStart), + text = stringResource(id = state.hint) + ) + Text( + modifier = Modifier.align(Alignment.CenterEnd), + text = state.supportingEndText + ) + } + }, + visualTransformation = state.visualTransformation.value, + label = { + Text(text = stringResource(state.label)) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next + ), + trailingIcon = { + IconButton( + onClick = state::onPasswordHideClicked + ) { + Icon( + painter = painterResource(id = state.trailingIconRes.value), + contentDescription = null + ) + } + }, + ) +} + +@Preview( + device = "id:pixel_6", showSystemUi = true, showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL +) +@Composable +fun AuthPasswordTextFieldPreview() { + AppTheme { + var text by remember { + mutableStateOf("text") + } + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { +// AuthPasswordTextField( +// modifier = Modifier.align(Alignment.Center), +// inputPassword = text, +// onTextChange = { value -> +// text = value +// }, +// hint = "enterPassword", +// supportingEndText = "4/10" +// ) + } + } +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthSubmitButton.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthSubmitButton.kt new file mode 100644 index 0000000..2d685bb --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthSubmitButton.kt @@ -0,0 +1,59 @@ +package com.stslex.aproselection.feature.auth.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.tooling.preview.Preview +import com.stslex.aproselection.core.ui.theme.AppTheme + +@Composable +fun AuthSubmitButton( + isValid: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val haptic = LocalHapticFeedback.current + FilledTonalButton( + modifier = modifier, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onClick() + }, + enabled = isValid + ) { + Text( + text = "submit", + style = MaterialTheme.typography.headlineSmall + ) + } +} + +@Preview( + device = "id:pixel_6a", showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL +) +@Composable +fun AuthSubmitButtonPreview() { + AppTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center + ) { + AuthSubmitButton( + isValid = true, + onClick = { /*TODO*/ } + ) + } + } +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthTitle.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthTitle.kt new file mode 100644 index 0000000..723badf --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthTitle.kt @@ -0,0 +1,53 @@ +package com.stslex.aproselection.feature.auth.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.stslex.aproselection.core.ui.theme.AppDimens +import com.stslex.aproselection.core.ui.theme.AppTheme +import com.stslex.aproselection.feature.auth.ui.model.AuthFieldsState + +@Composable +fun AuthTitle( + state: AuthFieldsState, + modifier: Modifier = Modifier +) { + Text( + modifier = modifier.padding( + top = AppDimens.Padding.big + ), + text = stringResource(id = state.titleResId), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1 + ) +} + +@Preview( + device = "id:pixel_6", showSystemUi = true, showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL +) +@Composable +fun AuthScreenPreview() { + AppTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + AuthTitle( + modifier = Modifier.align(Alignment.TopCenter), + state = AuthFieldsState.AUTH + ) + } + } +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthFieldsState.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthFieldsState.kt index 934331a..fb5f35a 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthFieldsState.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthFieldsState.kt @@ -4,10 +4,17 @@ import androidx.annotation.StringRes import com.stslex.aproselection.feature.auth.R enum class AuthFieldsState( - @StringRes val buttonResId: Int + @StringRes val buttonResId: Int, + @StringRes val titleResId: Int ) { - AUTH(R.string.auth), - REGISTER(R.string.register); + AUTH( + buttonResId = R.string.auth_button_choose_log_in, + titleResId = R.string.auth_title_auth + ), + REGISTER( + buttonResId = R.string.auth_button_choose_register, + titleResId = R.string.auth_title_register + ); val inverse: AuthFieldsState get() = when (this) { diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/mvi/ScreenAction.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/mvi/ScreenAction.kt index cd09784..c64c1e8 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/mvi/ScreenAction.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/mvi/ScreenAction.kt @@ -2,19 +2,24 @@ package com.stslex.aproselection.feature.auth.ui.model.mvi sealed interface ScreenAction { - data class UsernameInput( - val value: String - ) : ScreenAction + data object OnSubmitClicked : ScreenAction - data class PasswordInput( - val value: String - ) : ScreenAction + data object OnAuthFieldChange : ScreenAction - data class PasswordSubmitInput( - val value: String - ) : ScreenAction + sealed class InputAction( + open val value: String + ) : ScreenAction { - data object OnSubmitClicked : ScreenAction + data class UsernameInput( + override val value: String + ) : InputAction(value) - data object OnAuthFieldChange : ScreenAction + data class PasswordInput( + override val value: String + ) : InputAction(value) + + data class PasswordSubmitInput( + override val value: String + ) : InputAction(value) + } } \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/AuthScreenState.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/AuthScreenState.kt index 7c63abe..220299c 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/AuthScreenState.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/AuthScreenState.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.SoftwareKeyboardController import com.stslex.aproselection.core.ui.ext.CollectAsEvent @@ -16,6 +17,12 @@ import com.stslex.aproselection.feature.auth.ui.model.ScreenLoadingState import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenAction import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenEvent import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenState +import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.PasswordInputTextFieldState +import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.PasswordSubmitTextFieldState +import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.UsernameTextFieldState +import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.rememberPasswordInputTextFieldState +import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.rememberPasswordSubmitTextFieldState +import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.rememberUsernameTextFieldState import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -23,31 +30,25 @@ import kotlinx.coroutines.flow.StateFlow @Stable data class AuthScreenState( val screenLoadingState: ScreenLoadingState = ScreenLoadingState.Content, - val username: String = "", - val password: String = "", - val passwordSubmit: String = "", + val usernameState: UsernameTextFieldState, + val passwordEnterState: PasswordInputTextFieldState, + val passwordSubmitState: PasswordSubmitTextFieldState, val authFieldsState: AuthFieldsState = AuthFieldsState.AUTH, val snackbarHostState: SnackbarHostState, private val processAction: (ScreenAction) -> Unit, private val keyboardController: SoftwareKeyboardController? = null ) { - val isFieldsValid = username.length >= 4 && password.length >= 4 - val isRegisterState = authFieldsState == AuthFieldsState.REGISTER - - fun onUsernameChange(username: String) { - if (this.username == username) return - processAction(ScreenAction.UsernameInput(username)) - } - fun onPasswordChange(password: String) { - if (this.password == password) return - processAction(ScreenAction.PasswordInput(password)) - } + val isFieldsValid: Boolean + get() { + val isCorrectLength = usernameState.text.length >= 4 && + passwordEnterState.text.length >= 4 + val isEqualsPasswords = passwordEnterState.text == passwordSubmitState.text + val isRegisterPassword = authFieldsState == AuthFieldsState.AUTH || isEqualsPasswords + return isCorrectLength && isRegisterPassword + } - fun onPasswordSubmitChange(passwordSubmit: String) { - if (this.passwordSubmit == passwordSubmit) return - processAction(ScreenAction.PasswordSubmitInput(passwordSubmit)) - } + val isRegisterState = authFieldsState == AuthFieldsState.REGISTER fun onSubmitClicked() { keyboardController?.hide() @@ -66,6 +67,8 @@ fun rememberAuthScreenState( screenEventFlow: () -> SharedFlow, processAction: (ScreenAction) -> Unit, ): AuthScreenState { + val keyboardController = LocalSoftwareKeyboardController.current + val hapticFeedback = LocalHapticFeedback.current val screenState by remember { screenStateFlow() @@ -87,18 +90,33 @@ fun rememberAuthScreenState( } } - val keyboardController = LocalSoftwareKeyboardController.current + val usernameTextFieldState = rememberUsernameTextFieldState( + text = screenState.username, + processAction = processAction + ) + + val passwordEnterState = rememberPasswordInputTextFieldState( + hapticFeedback = hapticFeedback, + processAction = processAction, + text = screenState.password + ) + + val passwordSubmitState = rememberPasswordSubmitTextFieldState( + hapticFeedback = hapticFeedback, + processAction = processAction, + text = screenState.passwordSubmit + ) return remember(screenState) { AuthScreenState( screenLoadingState = screenState.screenLoadingState, - username = screenState.username, - password = screenState.password, - passwordSubmit = screenState.passwordSubmit, + passwordEnterState = passwordEnterState, + passwordSubmitState = passwordSubmitState, authFieldsState = screenState.authFieldsState, snackbarHostState = snackbarHostState, processAction = processAction, - keyboardController = keyboardController + keyboardController = keyboardController, + usernameState = usernameTextFieldState ) } } diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/PasswordInputTextFieldState.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/PasswordInputTextFieldState.kt new file mode 100644 index 0000000..9688807 --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/PasswordInputTextFieldState.kt @@ -0,0 +1,46 @@ +package com.stslex.aproselection.feature.auth.ui.model.screen.text_field + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.hapticfeedback.HapticFeedback +import com.stslex.aproselection.feature.auth.R +import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenAction.InputAction.PasswordInput +import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.base.PasswordTextFieldState +import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.hidden.HiddenState + +@Stable +data class PasswordInputTextFieldState( + private val hapticFeedback: HapticFeedback, + private val processAction: (PasswordInput) -> Unit, + private val hiddenState: HiddenState, + override val text: String, +) : PasswordTextFieldState( + hapticFeedback = hapticFeedback, + hiddenState = hiddenState +) { + + override val hint: Int = R.string.auth_password_enter_hint_text + + override val sendAction: (text: String) -> Unit + get() = { value -> + processAction(PasswordInput(value)) + } +} + +@Composable +fun rememberPasswordInputTextFieldState( + hapticFeedback: HapticFeedback, + processAction: (PasswordInput) -> Unit, + text: String, +): PasswordInputTextFieldState { + val hiddenState = remember { HiddenState() } + return remember(text) { + PasswordInputTextFieldState( + hapticFeedback = hapticFeedback, + processAction = processAction, + hiddenState = hiddenState, + text = text + ) + } +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/PasswordSubmitTextFieldState.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/PasswordSubmitTextFieldState.kt new file mode 100644 index 0000000..8aaea8b --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/PasswordSubmitTextFieldState.kt @@ -0,0 +1,48 @@ +package com.stslex.aproselection.feature.auth.ui.model.screen.text_field + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.hapticfeedback.HapticFeedback +import com.stslex.aproselection.feature.auth.R +import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenAction.InputAction.PasswordSubmitInput +import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.base.PasswordTextFieldState +import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.hidden.HiddenState + +@Stable +data class PasswordSubmitTextFieldState( + private val hapticFeedback: HapticFeedback, + private val processAction: (PasswordSubmitInput) -> Unit, + private val hiddenState: HiddenState, + override val text: String, +) : PasswordTextFieldState( + hapticFeedback = hapticFeedback, + hiddenState = hiddenState +) { + + override val hint: Int = R.string.auth_password_submit_hint_text + + override val sendAction: (text: String) -> Unit + get() = { value -> + processAction(PasswordSubmitInput(value)) + } +} + +@Composable +fun rememberPasswordSubmitTextFieldState( + hapticFeedback: HapticFeedback, + processAction: (PasswordSubmitInput) -> Unit, + text: String, +): PasswordSubmitTextFieldState { + val hiddenState = remember { + HiddenState() + } + return remember(text) { + PasswordSubmitTextFieldState( + hapticFeedback = hapticFeedback, + processAction = processAction, + hiddenState = hiddenState, + text = text + ) + } +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/UsernameTextFieldState.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/UsernameTextFieldState.kt new file mode 100644 index 0000000..b5dcc67 --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/UsernameTextFieldState.kt @@ -0,0 +1,33 @@ +package com.stslex.aproselection.feature.auth.ui.model.screen.text_field + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import com.stslex.aproselection.feature.auth.R +import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenAction.InputAction.UsernameInput +import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.base.AuthTextField + +@Stable +data class UsernameTextFieldState( + private val processAction: (UsernameInput) -> Unit, + override val text: String, +) : AuthTextField() { + + override val sendAction: (text: String) -> Unit + get() = { value -> + processAction(UsernameInput(value)) + } + + override val label: Int = R.string.auth_username_text +} + +@Composable +fun rememberUsernameTextFieldState( + text: String, + processAction: (UsernameInput) -> Unit +): UsernameTextFieldState = remember(text) { + UsernameTextFieldState( + text = text, + processAction = processAction + ) +} diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/base/AuthTextField.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/base/AuthTextField.kt new file mode 100644 index 0000000..d45c967 --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/base/AuthTextField.kt @@ -0,0 +1,24 @@ +package com.stslex.aproselection.feature.auth.ui.model.screen.text_field.base + +import androidx.compose.runtime.Stable + +@Stable +abstract class AuthTextField { + + abstract val text: String + abstract val label: Int + abstract val sendAction: (text: String) -> Unit + + val supportingEndText: String + get() = "${text.length}/$MAX_SYMBOL_COUNT" + + fun onTextChange(value: String) { + val currentValue = value.trim() + if (text == currentValue || currentValue.length > MAX_SYMBOL_COUNT) return + sendAction(currentValue) + } + + companion object { + private const val MAX_SYMBOL_COUNT = 10 + } +} diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/base/PasswordTextFieldState.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/base/PasswordTextFieldState.kt new file mode 100644 index 0000000..fd4add6 --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/base/PasswordTextFieldState.kt @@ -0,0 +1,42 @@ +package com.stslex.aproselection.feature.auth.ui.model.screen.text_field.base + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import com.stslex.aproselection.feature.auth.R +import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.hidden.HiddenState + +@Stable +abstract class PasswordTextFieldState( + private val hapticFeedback: HapticFeedback, + private val hiddenState: HiddenState +) : AuthTextField() { + + override val label: Int = R.string.auth_password_text + abstract val hint: Int + + val visualTransformation = derivedStateOf { + if (hiddenState.isHidden) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + } + } + + val trailingIconRes = derivedStateOf { + if (hiddenState.isHidden) { + R.drawable.baseline_visibility_off_24 + } else { + R.drawable.baseline_visibility_24 + } + } + + fun onPasswordHideClicked() { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + hiddenState.isHidden = hiddenState.isHidden.not() + } +} + diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/hidden/HiddenState.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/hidden/HiddenState.kt new file mode 100644 index 0000000..d34abb5 --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/text_field/hidden/HiddenState.kt @@ -0,0 +1,15 @@ +package com.stslex.aproselection.feature.auth.ui.model.screen.text_field.hidden + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf + +@Stable +class HiddenState { + + private val _isHidden = mutableStateOf(true) + var isHidden: Boolean + get() = _isHidden.value + set(value) { + _isHidden.value = value + } +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/navigation/AuthRouter.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/navigation/AuthRouter.kt index ab7605c..7a52558 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/navigation/AuthRouter.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/navigation/AuthRouter.kt @@ -3,24 +3,19 @@ package com.stslex.aproselection.feature.auth.ui.navigation import androidx.compose.ui.Modifier import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import com.stslex.aproselection.core.ui.navigation.AppDestination -import com.stslex.aproselection.core.ui.navigation.NavigationScreen +import com.stslex.aproselection.core.ui.navigation.destination.AppDestination import com.stslex.aproselection.feature.auth.ui.AuthScreen import com.stslex.aproselection.feature.auth.ui.AuthViewModel import com.stslex.aproselection.feature.auth.ui.model.screen.rememberAuthScreenState import org.koin.androidx.compose.koinViewModel -import org.koin.core.parameter.parametersOf fun NavGraphBuilder.authRouter( modifier: Modifier = Modifier, - navigate: (NavigationScreen) -> Unit ) { composable( route = AppDestination.AUTH.navigationRoute ) { - val viewModel: AuthViewModel = koinViewModel( - parameters = { parametersOf(navigate) } - ) + val viewModel: AuthViewModel = koinViewModel() val authScreenState = rememberAuthScreenState( screenStateFlow = viewModel::screenState, diff --git a/feature/auth/src/main/res/drawable/baseline_visibility_24.xml b/feature/auth/src/main/res/drawable/baseline_visibility_24.xml new file mode 100644 index 0000000..b923c39 --- /dev/null +++ b/feature/auth/src/main/res/drawable/baseline_visibility_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/feature/auth/src/main/res/drawable/baseline_visibility_off_24.xml b/feature/auth/src/main/res/drawable/baseline_visibility_off_24.xml new file mode 100644 index 0000000..00c8a20 --- /dev/null +++ b/feature/auth/src/main/res/drawable/baseline_visibility_off_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/feature/auth/src/main/res/values/strings.xml b/feature/auth/src/main/res/values/strings.xml index 2a54413..ddc7bec 100644 --- a/feature/auth/src/main/res/values/strings.xml +++ b/feature/auth/src/main/res/values/strings.xml @@ -1,5 +1,11 @@ - log in - register (if not have account) + log in (if not have account) + register (if not have account) + Authentication + Registration + username + password + enter password + repeat password \ No newline at end of file diff --git a/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/HomeViewModel.kt b/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/HomeViewModel.kt index 09cb017..2459951 100644 --- a/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/HomeViewModel.kt +++ b/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/HomeViewModel.kt @@ -3,12 +3,10 @@ package com.stslex.aproselection.feature.home.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.stslex.aproselection.core.datastore.AppDataStore -import com.stslex.aproselection.core.ui.navigation.NavigationScreen import kotlinx.coroutines.launch class HomeViewModel( private val appDataStore: AppDataStore, - private val navigation: (NavigationScreen) -> Unit, ) : ViewModel() { fun logOut() { diff --git a/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/navigation/HomeRouter.kt b/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/navigation/HomeRouter.kt index 02df7fd..0dc2923 100644 --- a/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/navigation/HomeRouter.kt +++ b/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/navigation/HomeRouter.kt @@ -3,23 +3,18 @@ package com.stslex.aproselection.feature.home.ui.navigation import androidx.compose.ui.Modifier import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import com.stslex.aproselection.core.ui.navigation.AppDestination -import com.stslex.aproselection.core.ui.navigation.NavigationScreen +import com.stslex.aproselection.core.ui.navigation.destination.AppDestination import com.stslex.aproselection.feature.home.ui.HomeScreen import com.stslex.aproselection.feature.home.ui.HomeViewModel import org.koin.androidx.compose.koinViewModel -import org.koin.core.parameter.parametersOf fun NavGraphBuilder.homeRouter( modifier: Modifier = Modifier, - navigate: (NavigationScreen) -> Unit ) { composable( route = AppDestination.HOME.navigationRoute ) { - val viewModel: HomeViewModel = koinViewModel( - parameters = { parametersOf(navigate) } - ) + val viewModel: HomeViewModel = koinViewModel() HomeScreen( modifier = modifier,