From f0690fadb30042f56db571d8ad204470efa743fc Mon Sep 17 00:00:00 2001 From: stslex Date: Sun, 30 Jul 2023 00:53:48 +0300 Subject: [PATCH 1/4] init registration/auth ui --- .../feature/auth/ui/AuthScreen.kt | 146 ++++++++++++++---- .../feature/auth/ui/model/AuthState.kt | 16 ++ feature/auth/src/main/res/values/strings.xml | 5 + 3 files changed, 135 insertions(+), 32 deletions(-) create mode 100644 feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthState.kt create mode 100644 feature/auth/src/main/res/values/strings.xml 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 5da2af8..b73f9ff 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,25 +1,33 @@ package com.stslex.aproselection.feature.auth.ui +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.Divider import androidx.compose.material3.ElevatedButton import androidx.compose.material3.MaterialTheme 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.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.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.stslex.aproselection.core.ui.navigation.NavigationScreen +import com.stslex.aproselection.feature.auth.ui.model.AuthState @Composable fun AuthScreen( @@ -36,6 +44,20 @@ fun AuthScreen( mutableStateOf("") } + var authState by remember { + mutableStateOf(AuthState.REGISTER) + } + + val buttonRes by remember { + derivedStateOf { authState.buttonResId } + } + + val isFieldsValid by remember { + derivedStateOf { + inputPassword.length >= 8 && inputUsername.length >= 8 + } + } + Box( modifier = modifier .fillMaxSize() @@ -46,31 +68,40 @@ fun AuthScreen( Column( horizontalAlignment = Alignment.CenterHorizontally ) { - TextField( - value = inputUsername, - onValueChange = { value -> - if (inputUsername != value) { - inputUsername = value - } - }, - maxLines = 1, + AuthUsernameTextField( + inputUsername = inputUsername, + onTextChange = { username -> + inputUsername = username + } ) Divider(Modifier.padding(16.dp)) - TextField( - value = inputPassword, - onValueChange = { value -> - if (inputPassword != value) { - inputPassword = value - } - }, - maxLines = 1, + AuthPasswordTextField( + inputPassword = inputPassword, + onTextChange = { value -> + inputPassword = value + } ) + AnimatedVisibility( + visible = authState == AuthState.REGISTER + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(8.dp)) + AuthPasswordTextField( + inputPassword = inputPassword, + onTextChange = { value -> + inputPassword = value + } + ) + } + } Divider(Modifier.padding(16.dp)) ElevatedButton( onClick = { auth(inputUsername, inputPassword) - inputUsername = "" - } + }, + enabled = isFieldsValid ) { Text( text = "submit", @@ -78,21 +109,72 @@ fun AuthScreen( ) } Divider(Modifier.padding(16.dp)) - Text( - text = text, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground - ) + TextButton( + onClick = { + authState = authState.onClick() + } + ) { + Text( + text = stringResource(id = buttonRes), + style = MaterialTheme.typography.titleMedium + ) + } } } } -//@Preview(device = "id:pixel_6", showSystemUi = true, showBackground = true) -//@Composable -//fun AuthScreenPreview() { -// AuthScreen( -// text = "text", -// navigate = {}, -// setUsername = {} -// ) -//} \ No newline at end of file +@Composable +fun AuthUsernameTextField( + inputUsername: String, + onTextChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + TextField( + modifier = modifier, + value = inputUsername, + onValueChange = { value -> + if (inputUsername != value) { + onTextChange(value) + } + }, + 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) + } + }, + singleLine = true, + supportingText = { + Text(text = "enter password") + }, + visualTransformation = PasswordVisualTransformation(), + label = { + Text(text = "password") + } + ) +} + +@Preview(device = "id:pixel_6", showSystemUi = true, showBackground = true) +@Composable +fun AuthScreenPreview() { + AuthScreen( + text = "text", + navigate = {}, + auth = { _, _ -> } + ) +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthState.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthState.kt new file mode 100644 index 0000000..7360d72 --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthState.kt @@ -0,0 +1,16 @@ +package com.stslex.aproselection.feature.auth.ui.model + +import androidx.annotation.StringRes +import com.stslex.aproselection.feature.auth.R + +enum class AuthState( + @StringRes val buttonResId: Int +) { + AUTH(R.string.auth), + REGISTER(R.string.register); + + fun onClick(): AuthState = when (this) { + AUTH -> REGISTER + REGISTER -> AUTH + } +} \ No newline at end of file diff --git a/feature/auth/src/main/res/values/strings.xml b/feature/auth/src/main/res/values/strings.xml new file mode 100644 index 0000000..2a54413 --- /dev/null +++ b/feature/auth/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + log in + register (if not have account) + \ No newline at end of file From 854587e6fc3d72a6f0962295b74c22a66ad85b49 Mon Sep 17 00:00:00 2001 From: stslex Date: Mon, 31 Jul 2023 19:57:40 +0300 Subject: [PATCH 2/4] add home screen and basic auth --- app/build.gradle.kts | 1 + .../navigation/NavigationHost.kt | 2 + .../com/stslex/aproselection/ui/InitialApp.kt | 1 - .../aproselection/core/ui/ext/CoroutineExt.kt | 25 +++ .../core/ui/navigation/AppDestination.kt | 3 +- .../core/ui/navigation/NavigationScreen.kt | 6 +- .../feature/auth/ui/AuthScreen.kt | 173 +++++++++++------- .../feature/auth/ui/AuthViewModel.kt | 47 ++++- .../feature/auth/ui/model/AuthState.kt | 9 +- .../feature/auth/ui/model/ScreenEvent.kt | 8 + .../feature/auth/ui/model/ScreenState.kt | 8 + .../feature/auth/ui/navigation/AuthRouter.kt | 29 ++- feature/home/.gitignore | 1 + feature/home/build.gradle.kts | 12 ++ feature/home/consumer-rules.pro | 0 feature/home/proguard-rules.pro | 21 +++ .../feature/home/ExampleInstrumentedTest.kt | 24 +++ feature/home/src/main/AndroidManifest.xml | 4 + .../feature/home/di/ModuleFeatureHome.kt | 9 + .../feature/home/ui/HomeScreen.kt | 34 ++++ .../feature/home/ui/HomeViewModel.kt | 21 +++ .../feature/home/ui/navigation/HomeRouter.kt | 29 +++ .../feature/home/ExampleUnitTest.kt | 17 ++ gradle/libs.versions.toml | 1 + settings.gradle.kts | 1 + 25 files changed, 403 insertions(+), 83 deletions(-) create mode 100644 core/ui/src/main/java/com/stslex/aproselection/core/ui/ext/CoroutineExt.kt create mode 100644 feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenEvent.kt create mode 100644 feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenState.kt create mode 100644 feature/home/.gitignore create mode 100644 feature/home/build.gradle.kts create mode 100644 feature/home/consumer-rules.pro create mode 100644 feature/home/proguard-rules.pro create mode 100644 feature/home/src/androidTest/java/com/stslex/aproselection/feature/home/ExampleInstrumentedTest.kt create mode 100644 feature/home/src/main/AndroidManifest.xml create mode 100644 feature/home/src/main/java/com/stslex/aproselection/feature/home/di/ModuleFeatureHome.kt create mode 100644 feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/HomeScreen.kt create mode 100644 feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/HomeViewModel.kt create mode 100644 feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/navigation/HomeRouter.kt create mode 100644 feature/home/src/test/java/com/stslex/aproselection/feature/home/ExampleUnitTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 489e6a0..d7f0550 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,4 +9,5 @@ dependencies { implementation(project(":core:datastore")) implementation(project(":core:network")) implementation(project(":feature:auth")) + implementation(project(":feature:home")) } \ 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 6b41c81..3af57dd 100644 --- a/app/src/main/java/com/stslex/aproselection/navigation/NavigationHost.kt +++ b/app/src/main/java/com/stslex/aproselection/navigation/NavigationHost.kt @@ -7,6 +7,7 @@ import androidx.navigation.compose.NavHost import com.stslex.aproselection.core.ui.navigation.AppDestination import com.stslex.aproselection.core.ui.navigation.NavigationScreen import com.stslex.aproselection.feature.auth.ui.navigation.authRouter +import com.stslex.aproselection.feature.home.ui.navigation.homeRouter @Composable fun NavigationHost( @@ -25,6 +26,7 @@ fun NavigationHost( startDestination = startDestination.route ) { authRouter(modifier, navigator) + homeRouter(modifier, navigator) } } 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 bbb3867..ba840f9 100644 --- a/app/src/main/java/com/stslex/aproselection/ui/InitialApp.kt +++ b/app/src/main/java/com/stslex/aproselection/ui/InitialApp.kt @@ -9,7 +9,6 @@ import com.stslex.aproselection.navigation.NavigationHost @Composable fun InitialApp() { val navController = rememberNavController() - NavigationHost(navController = navController) } diff --git a/core/ui/src/main/java/com/stslex/aproselection/core/ui/ext/CoroutineExt.kt b/core/ui/src/main/java/com/stslex/aproselection/core/ui/ext/CoroutineExt.kt new file mode 100644 index 0000000..8f1451a --- /dev/null +++ b/core/ui/src/main/java/com/stslex/aproselection/core/ui/ext/CoroutineExt.kt @@ -0,0 +1,25 @@ +package com.stslex.aproselection.core.ui.ext + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun SharedFlow.CollectAsEvent( + minActionState: Lifecycle.State = Lifecycle.State.STARTED, + action: suspend (value: T) -> Unit +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(Unit) { + this@CollectAsEvent + .flowWithLifecycle( + lifecycle = lifecycleOwner.lifecycle, + minActiveState = minActionState + ) + .collectLatest(action) + } +} \ No newline at end of file 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/AppDestination.kt index bd9ad1f..da91ea1 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/AppDestination.kt @@ -3,7 +3,8 @@ package com.stslex.aproselection.core.ui.navigation enum class AppDestination( vararg val argsNames: String ) { - AUTH; + AUTH, + HOME; val route: String get() = StringBuilder() 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/NavigationScreen.kt index 3826ca5..c6a9088 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/NavigationScreen.kt @@ -14,11 +14,15 @@ sealed class NavigationScreen { get() = AppArguments.Empty object Auth : NavigationScreen() { - override val screen: AppDestination = AppDestination.AUTH override val isSingleTop: Boolean = true } + object Home : NavigationScreen() { + override val screen: AppDestination = AppDestination.HOME + override val isSingleTop: Boolean = true + } + object PopBackStack : NavigationScreen() { override val screen: AppDestination = throw Exception("PopBackStack") override val appArgs: AppArguments = throw Exception("PopBackStack") 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 b73f9ff..3c543a9 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 @@ -8,9 +8,13 @@ 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.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.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField @@ -21,21 +25,64 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.stslex.aproselection.core.ui.navigation.NavigationScreen import com.stslex.aproselection.feature.auth.ui.model.AuthState +import com.stslex.aproselection.feature.auth.ui.model.ScreenState @Composable fun AuthScreen( - text: String, - navigate: (NavigationScreen) -> Unit, + screenState: ScreenState, + snackbarHostState: SnackbarHostState, auth: (String, String) -> Unit, + register: (String, String) -> Unit, modifier: Modifier = Modifier, ) { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + AuthScreenContent( + auth = auth, + register = register + ) + } + if (screenState == ScreenState.Loading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.onBackground.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + SnackbarHost( + snackbarHostState + ) { snackbarData -> + Snackbar { + Text(text = snackbarData.visuals.message) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun AuthScreenContent( + auth: (String, String) -> Unit, + register: (String, String) -> Unit, + modifier: Modifier = Modifier +) { + val keyboardController = LocalSoftwareKeyboardController.current + var inputUsername by remember { mutableStateOf("") } @@ -45,80 +92,77 @@ fun AuthScreen( } var authState by remember { - mutableStateOf(AuthState.REGISTER) + mutableStateOf(AuthState.AUTH) } val buttonRes by remember { - derivedStateOf { authState.buttonResId } + derivedStateOf { authState.inverse.buttonResId } } val isFieldsValid by remember { derivedStateOf { - inputPassword.length >= 8 && inputUsername.length >= 8 + inputPassword.length >= 8 && inputUsername.length >= 4 } } - Box( - modifier = modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - contentAlignment = Alignment.Center, + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally ) { - - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - AuthUsernameTextField( - inputUsername = inputUsername, - onTextChange = { username -> - inputUsername = username - } - ) - Divider(Modifier.padding(16.dp)) - AuthPasswordTextField( - inputPassword = inputPassword, - onTextChange = { value -> - inputPassword = value - } - ) - AnimatedVisibility( - visible = authState == AuthState.REGISTER - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(8.dp)) - AuthPasswordTextField( - inputPassword = inputPassword, - onTextChange = { value -> - inputPassword = value - } - ) - } + AuthUsernameTextField( + inputUsername = inputUsername, + onTextChange = { username -> + inputUsername = username } - Divider(Modifier.padding(16.dp)) - ElevatedButton( - onClick = { - auth(inputUsername, inputPassword) - }, - enabled = isFieldsValid + ) + Divider(Modifier.padding(16.dp)) + AuthPasswordTextField( + inputPassword = inputPassword, + onTextChange = { value -> + inputPassword = value + } + ) + AnimatedVisibility( + visible = authState == AuthState.REGISTER + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = "submit", - style = MaterialTheme.typography.headlineMedium + Spacer(modifier = Modifier.height(8.dp)) + AuthPasswordTextField( + inputPassword = inputPassword, + onTextChange = { value -> + inputPassword = value + } ) } - Divider(Modifier.padding(16.dp)) - TextButton( - onClick = { - authState = authState.onClick() + } + Divider(Modifier.padding(16.dp)) + ElevatedButton( + onClick = { + keyboardController?.hide() + when (authState) { + AuthState.REGISTER -> register(inputUsername, inputPassword) + AuthState.AUTH -> auth(inputUsername, inputPassword) } - ) { - Text( - text = stringResource(id = buttonRes), - style = MaterialTheme.typography.titleMedium - ) + }, + enabled = isFieldsValid + ) { + Text( + text = "submit", + style = MaterialTheme.typography.headlineMedium + ) + } + Divider(Modifier.padding(16.dp)) + TextButton( + onClick = { + authState = authState.inverse } + ) { + Text( + text = stringResource(id = buttonRes), + style = MaterialTheme.typography.titleMedium + ) } } } @@ -173,8 +217,11 @@ fun AuthPasswordTextField( @Composable fun AuthScreenPreview() { AuthScreen( - text = "text", - navigate = {}, - auth = { _, _ -> } + auth = { _, _ -> }, + register = { _, _ -> }, + screenState = ScreenState.Content, + snackbarHostState = remember { + SnackbarHostState() + } ) } \ 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 ecb9ab3..e2de841 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 @@ -3,8 +3,13 @@ package com.stslex.aproselection.feature.auth.ui import androidx.lifecycle.viewModelScope import com.stslex.aproselection.core.ui.base.BaseViewModel import com.stslex.aproselection.feature.auth.domain.interactor.AuthInteractor +import com.stslex.aproselection.feature.auth.ui.model.ScreenEvent +import com.stslex.aproselection.feature.auth.ui.model.ScreenState +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn @@ -14,25 +19,51 @@ class AuthViewModel( private val interactor: AuthInteractor ) : BaseViewModel() { - private val _text: MutableStateFlow = MutableStateFlow("...") + private val _screenState: MutableStateFlow = MutableStateFlow(ScreenState.Content) + val screenState: StateFlow + get() = _screenState.asStateFlow() - val text: StateFlow - get() = _text.asStateFlow() + private val _screenEvents: MutableSharedFlow = MutableSharedFlow() + val screenEvent: SharedFlow + get() = _screenEvents.asSharedFlow() + + fun register( + username: String, + password: String + ) { + _screenState.value = ScreenState.Loading + + interactor + .auth( + username = username, + password = password + ) + .catch { throwable -> + _screenEvents.emit(ScreenEvent.Error(throwable)) + _screenState.value = ScreenState.Content + handleError(throwable) + } + .onEach { + _screenState.value = ScreenState.Content + } + .launchIn(viewModelScope) + } fun auth(username: String, password: String) { - _text.value = "..." + _screenState.value = ScreenState.Loading interactor - .register( + .auth( username = username, password = password ) .catch { throwable -> - _text.emit("Error: ${throwable.localizedMessage}") + _screenEvents.emit(ScreenEvent.Error(throwable)) + _screenState.value = ScreenState.Content handleError(throwable) } - .onEach { userModel -> - _text.emit("Success: uuid--${userModel.uuid}") + .onEach { + _screenState.value = ScreenState.Content } .launchIn(viewModelScope) } diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthState.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthState.kt index 7360d72..9b61251 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthState.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthState.kt @@ -9,8 +9,9 @@ enum class AuthState( AUTH(R.string.auth), REGISTER(R.string.register); - fun onClick(): AuthState = when (this) { - AUTH -> REGISTER - REGISTER -> AUTH - } + val inverse: AuthState + get() = when (this) { + AUTH -> REGISTER + REGISTER -> AUTH + } } \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenEvent.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenEvent.kt new file mode 100644 index 0000000..fdbaa87 --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenEvent.kt @@ -0,0 +1,8 @@ +package com.stslex.aproselection.feature.auth.ui.model + +interface ScreenEvent { + + data class Error( + val throwable: Throwable + ) : ScreenEvent +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenState.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenState.kt new file mode 100644 index 0000000..3dfd657 --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenState.kt @@ -0,0 +1,8 @@ +package com.stslex.aproselection.feature.auth.ui.model + +sealed interface ScreenState { + + object Loading : ScreenState + + object Content : ScreenState +} \ 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 3273528..02ed02d 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 @@ -1,17 +1,23 @@ package com.stslex.aproselection.feature.auth.ui.navigation +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable +import com.stslex.aproselection.core.ui.ext.CollectAsEvent import com.stslex.aproselection.core.ui.navigation.AppDestination import com.stslex.aproselection.core.ui.navigation.NavigationScreen import com.stslex.aproselection.feature.auth.ui.AuthScreen import com.stslex.aproselection.feature.auth.ui.AuthViewModel +import com.stslex.aproselection.feature.auth.ui.model.ScreenEvent import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf +@OptIn(ExperimentalMaterial3Api::class) fun NavGraphBuilder.authRouter( modifier: Modifier = Modifier, navigate: (NavigationScreen) -> Unit @@ -19,15 +25,28 @@ fun NavGraphBuilder.authRouter( composable( route = AppDestination.AUTH.navigationRoute ) { - val viewModel: AuthViewModel = koinViewModel() + val viewModel: AuthViewModel = koinViewModel( + parameters = { parametersOf(navigate) } + ) + + val snackbarHostState = remember { + SnackbarHostState() + } - val text by remember { - viewModel.text + val screenState by remember { + viewModel.screenState }.collectAsState() + viewModel.screenEvent.CollectAsEvent { event -> + when (event) { + is ScreenEvent.Error -> snackbarHostState.showSnackbar(event.throwable.message.orEmpty()) + } + } + AuthScreen( - text = text, - navigate = navigate, + screenState = screenState, + snackbarHostState = snackbarHostState, + register = viewModel::register, auth = viewModel::auth, modifier = modifier ) diff --git a/feature/home/.gitignore b/feature/home/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature/home/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts new file mode 100644 index 0000000..2a95b30 --- /dev/null +++ b/feature/home/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id("aproselection.android.library") + id("aproselection.android.library.compose") +} + +dependencies { + implementation(project(":core:ui")) + implementation(project(":core:datastore")) + implementation(project(":core:network")) +} + +android.namespace = "com.stslex.aproselection.feature.home" diff --git a/feature/home/consumer-rules.pro b/feature/home/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature/home/proguard-rules.pro b/feature/home/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature/home/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/home/src/androidTest/java/com/stslex/aproselection/feature/home/ExampleInstrumentedTest.kt b/feature/home/src/androidTest/java/com/stslex/aproselection/feature/home/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..ac10328 --- /dev/null +++ b/feature/home/src/androidTest/java/com/stslex/aproselection/feature/home/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.stslex.aproselection.feature.home + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.stslex.aproselection.feature.home.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/feature/home/src/main/AndroidManifest.xml b/feature/home/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature/home/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/home/src/main/java/com/stslex/aproselection/feature/home/di/ModuleFeatureHome.kt b/feature/home/src/main/java/com/stslex/aproselection/feature/home/di/ModuleFeatureHome.kt new file mode 100644 index 0000000..8a944de --- /dev/null +++ b/feature/home/src/main/java/com/stslex/aproselection/feature/home/di/ModuleFeatureHome.kt @@ -0,0 +1,9 @@ +package com.stslex.aproselection.feature.home.di + +import com.stslex.aproselection.feature.home.ui.HomeViewModel +import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.dsl.module + +val moduleFeatureHome = module { + viewModelOf(::HomeViewModel) +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/HomeScreen.kt b/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/HomeScreen.kt new file mode 100644 index 0000000..e4ea655 --- /dev/null +++ b/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/HomeScreen.kt @@ -0,0 +1,34 @@ +package com.stslex.aproselection.feature.home.ui + +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.material3.ElevatedButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun HomeScreen( + logOut: () -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier.fillMaxSize()) { + Column { + Text( + text = "Success", + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.height(32.dp)) + ElevatedButton( + onClick = logOut + ) { + Text(text = "logout") + } + } + } +} \ 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 new file mode 100644 index 0000000..bab2a93 --- /dev/null +++ b/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/HomeViewModel.kt @@ -0,0 +1,21 @@ +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() { + viewModelScope.launch { + appDataStore.setToken("") + appDataStore.setUuid("") + navigation(NavigationScreen.PopBackStack) + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..02df7fd --- /dev/null +++ b/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/navigation/HomeRouter.kt @@ -0,0 +1,29 @@ +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.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) } + ) + + HomeScreen( + modifier = modifier, + logOut = viewModel::logOut + ) + } +} \ No newline at end of file diff --git a/feature/home/src/test/java/com/stslex/aproselection/feature/home/ExampleUnitTest.kt b/feature/home/src/test/java/com/stslex/aproselection/feature/home/ExampleUnitTest.kt new file mode 100644 index 0000000..bd523d3 --- /dev/null +++ b/feature/home/src/test/java/com/stslex/aproselection/feature/home/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.stslex.aproselection.feature.home + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c8db7f9..dec2a51 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,6 +46,7 @@ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", versi androidx-compose-activity = "androidx.activity:activity-compose:1.7.2" androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material = { group = "androidx.compose.material", name = "material" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4088e18..99483fc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,3 +23,4 @@ include(":core:network") include(":feature:auth") include(":core:ui") include(":core:datastore") +include(":feature:home") From 165d75b7dd367caf60a581fabc7c233a4bf7fa57 Mon Sep 17 00:00:00 2001 From: stslex Date: Tue, 1 Aug 2023 08:41:58 +0300 Subject: [PATCH 3/4] add home screen and basic auth --- app/build.gradle.kts | 1 - .../stslex/aproselection/SelectApplication.kt | 8 +- .../com/stslex/aproselection/di/AppModule.kt | 9 ++ .../navigation/NavigationHost.kt | 39 +++++- .../com/stslex/aproselection/ui/InitialApp.kt | 8 +- .../aproselection/ui/InitialAppViewModel.kt | 49 ++++++++ .../stslex/aproselection/ui/MainActivity.kt | 15 +-- .../com.stslex.aproselection/KotlinAndroid.kt | 36 +++--- build.gradle.kts | 2 + .../core/datastore/AppDataStoreImpl.kt | 18 ++- .../core/ui/navigation/AppDestination.kt | 3 +- .../core/ui/navigation/NavigationScreen.kt | 19 +-- feature/auth/build.gradle.kts | 2 + .../feature/auth/ui/AuthScreen.kt | 113 ++++++------------ .../feature/auth/ui/AuthViewModel.kt | 109 +++++++++++++---- .../{AuthState.kt => AuthFieldsState.kt} | 4 +- .../feature/auth/ui/model/ScreenEvent.kt | 8 -- .../auth/ui/model/ScreenLoadingState.kt | 21 ++++ .../feature/auth/ui/model/ScreenState.kt | 8 -- .../feature/auth/ui/model/mvi/ScreenAction.kt | 20 ++++ .../feature/auth/ui/model/mvi/ScreenEvent.kt | 10 ++ .../feature/auth/ui/model/mvi/ScreenState.kt | 14 +++ .../auth/ui/model/screen/AuthScreenState.kt | 104 ++++++++++++++++ .../feature/auth/ui/navigation/AuthRouter.kt | 32 ++--- .../feature/home/ui/HomeScreen.kt | 19 ++- .../feature/home/ui/HomeViewModel.kt | 1 - gradle/libs.versions.toml | 5 +- 27 files changed, 479 insertions(+), 198 deletions(-) create mode 100644 app/src/main/java/com/stslex/aproselection/di/AppModule.kt create mode 100644 app/src/main/java/com/stslex/aproselection/ui/InitialAppViewModel.kt rename feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/{AuthState.kt => AuthFieldsState.kt} (84%) delete mode 100644 feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenEvent.kt create mode 100644 feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenLoadingState.kt delete mode 100644 feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenState.kt create mode 100644 feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/mvi/ScreenAction.kt create mode 100644 feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/mvi/ScreenEvent.kt create mode 100644 feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/mvi/ScreenState.kt create mode 100644 feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/AuthScreenState.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d7f0550..65afc47 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,3 @@ -@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed plugins { id("aproselection.android.application") id("aproselection.android.application.compose") diff --git a/app/src/main/java/com/stslex/aproselection/SelectApplication.kt b/app/src/main/java/com/stslex/aproselection/SelectApplication.kt index c5f0446..4422154 100644 --- a/app/src/main/java/com/stslex/aproselection/SelectApplication.kt +++ b/app/src/main/java/com/stslex/aproselection/SelectApplication.kt @@ -3,7 +3,9 @@ package com.stslex.aproselection import android.app.Application 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.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin @@ -16,9 +18,11 @@ class SelectApplication : Application() { androidLogger() androidContext(applicationContext) modules( - moduleFeatureAuth, + appModule, moduleCoreNetwork, - coreDataStoreModule + coreDataStoreModule, + moduleFeatureAuth, + moduleFeatureHome, ) } } diff --git a/app/src/main/java/com/stslex/aproselection/di/AppModule.kt b/app/src/main/java/com/stslex/aproselection/di/AppModule.kt new file mode 100644 index 0000000..c6e6376 --- /dev/null +++ b/app/src/main/java/com/stslex/aproselection/di/AppModule.kt @@ -0,0 +1,9 @@ +package com.stslex.aproselection.di + +import com.stslex.aproselection.ui.InitialAppViewModel +import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.dsl.module + +val appModule = module { + 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 3af57dd..c41fecb 100644 --- a/app/src/main/java/com/stslex/aproselection/navigation/NavigationHost.kt +++ b/app/src/main/java/com/stslex/aproselection/navigation/NavigationHost.kt @@ -1,19 +1,29 @@ 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.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, modifier: Modifier = Modifier, - startDestination: AppDestination = AppDestination.AUTH + startDestination: AppDestination = AppDestination.SPLASH ) { val navigator: (NavigationScreen) -> Unit = { screen -> when (screen) { @@ -21,25 +31,48 @@ fun NavigationHost( 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) } } fun NavHostController.navigateScreen(screen: NavigationScreen) { + val currentRoute = this.currentDestination?.route + if (currentRoute == screen.screenRoute) return navigate(screen.screenRoute) { if (screen.isSingleTop.not()) return@navigate - graph.startDestinationRoute?.let { route -> + currentRoute?.let { route -> popUpTo(route) { inclusive = true saveState = true } } + launchSingleTop = true - restoreState = false } } \ 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 ba840f9..204e964 100644 --- a/app/src/main/java/com/stslex/aproselection/ui/InitialApp.kt +++ b/app/src/main/java/com/stslex/aproselection/ui/InitialApp.kt @@ -1,5 +1,6 @@ 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 @@ -9,7 +10,12 @@ import com.stslex.aproselection.navigation.NavigationHost @Composable fun InitialApp() { val navController = rememberNavController() - NavigationHost(navController = navController) + + Box { + NavigationHost( + navController = navController, + ) + } } @Preview(showBackground = true) diff --git a/app/src/main/java/com/stslex/aproselection/ui/InitialAppViewModel.kt b/app/src/main/java/com/stslex/aproselection/ui/InitialAppViewModel.kt new file mode 100644 index 0000000..aef9817 --- /dev/null +++ b/app/src/main/java/com/stslex/aproselection/ui/InitialAppViewModel.kt @@ -0,0 +1,49 @@ +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.core.ui.base.BaseViewModel +import com.stslex.aproselection.core.ui.navigation.NavigationScreen +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, +) : BaseViewModel() { + + private val _isAuth = MutableStateFlow(null) + val isAuth: StateFlow + get() = _isAuth.asStateFlow() + + init { + dataStore.token + .catch { error -> + handleError(error) + } + .onEach { token -> + if (token.isBlank()) { + networkClient.regenerateToken() + } + } + .launchIn(viewModelScope) + + dataStore.uuid + .catch { error -> + handleError(error) + } + .onEach { uuid -> + _isAuth.emit(uuid.isNotBlank()) + if (uuid.isBlank()) { + navigate(NavigationScreen.Auth) + } + } + .launchIn(viewModelScope) + } +} \ No newline at end of file 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 9a3d5cb..7f226d1 100644 --- a/app/src/main/java/com/stslex/aproselection/ui/MainActivity.kt +++ b/app/src/main/java/com/stslex/aproselection/ui/MainActivity.kt @@ -15,21 +15,12 @@ import org.koin.android.ext.android.inject class MainActivity : ComponentActivity() { - val dataStore by inject() - val networkClient by inject() + private val dataStore by inject() + private val networkClient by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - dataStore.token - .catch { - Log.e(it.message, javaClass.simpleName, it) - } - .onEach { - if (it.isBlank()) { - networkClient.regenerateToken() - } - } - .launchIn(lifecycleScope) + setContent { AppTheme { InitialApp() diff --git a/build-logic/dependencies/src/main/kotlin/com.stslex.aproselection/KotlinAndroid.kt b/build-logic/dependencies/src/main/kotlin/com.stslex.aproselection/KotlinAndroid.kt index d7d1da6..e268996 100644 --- a/build-logic/dependencies/src/main/kotlin/com.stslex.aproselection/KotlinAndroid.kt +++ b/build-logic/dependencies/src/main/kotlin/com.stslex.aproselection/KotlinAndroid.kt @@ -5,8 +5,11 @@ import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.plugins.ExtensionAware +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.getValue import org.gradle.kotlin.dsl.provideDelegate import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions @@ -34,9 +37,9 @@ internal fun Project.configureKotlinAndroid( targetCompatibility = JavaVersion.VERSION_11 isCoreLibraryDesugaringEnabled = true } - } - configureKotlin() + configureKotlinJvm() + } val libs = extensions.getByType().named("libs") @@ -65,20 +68,20 @@ private fun CommonExtension<*, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() (this as ExtensionAware).extensions.configure("kotlinOptions", block) } -// TODO check -///** -// * Configure base Kotlin options for JVM (non-Android) -// */ -//internal fun Project.configureKotlinJvm() { -// extensions.configure { -// // Up to Java 11 APIs are available through desugaring -// // https://developer.android.com/studio/write/java11-minimal-support-table -// sourceCompatibility = JavaVersion.VERSION_11 -// targetCompatibility = JavaVersion.VERSION_11 -// } -// -// configureKotlin() -//} +/** + * Configure base Kotlin options for JVM (non-Android) + */ +internal fun Project.configureKotlinJvm() { + extensions.configure { + // Up to Java 11 APIs are available through desugaring + // https://developer.android.com/studio/write/java11-minimal-support-table + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + configureKotlin() +} + /** * Configure base Kotlin options @@ -89,6 +92,7 @@ private fun Project.configureKotlin() { kotlinOptions { // Set JVM target to 11 jvmTarget = JavaVersion.VERSION_11.toString() + languageVersion = "1.9" // Treat all Kotlin warnings as errors (disabled by default) // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties val warningsAsErrors: String? by project diff --git a/build.gradle.kts b/build.gradle.kts index 59845ad..fa3ea69 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +@file:Suppress("UNUSED_EXPRESSION") + // Top-level build file where you can add configuration options common to all sub-projects/modules. @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed plugins { diff --git a/core/datastore/src/main/java/com/stslex/aproselection/core/datastore/AppDataStoreImpl.kt b/core/datastore/src/main/java/com/stslex/aproselection/core/datastore/AppDataStoreImpl.kt index 9bb0662..9e23cfd 100644 --- a/core/datastore/src/main/java/com/stslex/aproselection/core/datastore/AppDataStoreImpl.kt +++ b/core/datastore/src/main/java/com/stslex/aproselection/core/datastore/AppDataStoreImpl.kt @@ -5,7 +5,9 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map class AppDataStoreImpl( @@ -20,14 +22,18 @@ class AppDataStoreImpl( } override val uuid: Flow - get() = context.dataStore.data.map { prefs -> - prefs[UUID_KEY].orEmpty() - } + get() = context.dataStore.data + .map { prefs -> + prefs[UUID_KEY].orEmpty() + } + .flowOn(Dispatchers.IO) override val token: Flow - get() = context.dataStore.data.map { prefs -> - prefs[TOKEN_KEY].orEmpty() - } + get() = context.dataStore.data + .map { prefs -> + prefs[TOKEN_KEY].orEmpty() + } + .flowOn(Dispatchers.IO) override suspend fun setUuid(uuid: String) { context.dataStore.updateData { prefs -> 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/AppDestination.kt index da91ea1..d9d8d3b 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/AppDestination.kt @@ -4,7 +4,8 @@ enum class AppDestination( vararg val argsNames: String ) { AUTH, - HOME; + HOME, + SPLASH; val route: String get() = StringBuilder() 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/NavigationScreen.kt index c6a9088..2520e83 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/NavigationScreen.kt @@ -1,29 +1,34 @@ package com.stslex.aproselection.core.ui.navigation -sealed class NavigationScreen { +sealed interface NavigationScreen { - abstract val screen: AppDestination + val screen: AppDestination val screenRoute: String get() = "${screen.route}${appArgs.argumentsForRoute}" - open val isSingleTop: Boolean + val isSingleTop: Boolean get() = false - open val appArgs: AppArguments + val appArgs: AppArguments get() = AppArguments.Empty - object Auth : NavigationScreen() { + data object Auth : NavigationScreen { override val screen: AppDestination = AppDestination.AUTH override val isSingleTop: Boolean = true } - object Home : NavigationScreen() { + data object Home : NavigationScreen { override val screen: AppDestination = AppDestination.HOME override val isSingleTop: Boolean = true } - object PopBackStack : NavigationScreen() { + 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/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index 99a9ff4..68a9b2b 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { id("aproselection.android.library") id("aproselection.android.library.compose") 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 3c543a9..85689cd 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 @@ -14,33 +14,29 @@ import androidx.compose.material3.ElevatedButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState 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.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.stslex.aproselection.feature.auth.ui.model.AuthState -import com.stslex.aproselection.feature.auth.ui.model.ScreenState +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 kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow @Composable fun AuthScreen( - screenState: ScreenState, - snackbarHostState: SnackbarHostState, - auth: (String, String) -> Unit, - register: (String, String) -> Unit, + state: AuthScreenState, modifier: Modifier = Modifier, ) { Box( @@ -49,12 +45,19 @@ fun AuthScreen( .background(MaterialTheme.colorScheme.background), contentAlignment = Alignment.Center, ) { - AuthScreenContent( - auth = auth, - register = register - ) + AuthScreenContent(state) + SnackbarHost( + modifier = Modifier + .padding(16.dp) + .align(Alignment.BottomCenter), + hostState = state.snackbarHostState + ) { snackbarData -> + Snackbar { + Text(text = snackbarData.visuals.message) + } + } } - if (screenState == ScreenState.Loading) { + if (state.screenLoadingState == ScreenLoadingState.Loading) { Box( modifier = Modifier .fillMaxSize() @@ -64,45 +67,16 @@ fun AuthScreen( CircularProgressIndicator() } } - - SnackbarHost( - snackbarHostState - ) { snackbarData -> - Snackbar { - Text(text = snackbarData.visuals.message) - } - } } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun AuthScreenContent( - auth: (String, String) -> Unit, - register: (String, String) -> Unit, + state: AuthScreenState, modifier: Modifier = Modifier ) { - val keyboardController = LocalSoftwareKeyboardController.current - - var inputUsername by remember { - mutableStateOf("") - } - - var inputPassword by remember { - mutableStateOf("") - } - - var authState by remember { - mutableStateOf(AuthState.AUTH) - } val buttonRes by remember { - derivedStateOf { authState.inverse.buttonResId } - } - - val isFieldsValid by remember { - derivedStateOf { - inputPassword.length >= 8 && inputUsername.length >= 4 - } + derivedStateOf { state.authFieldsState.inverse.buttonResId } } Column( @@ -110,43 +84,31 @@ fun AuthScreenContent( horizontalAlignment = Alignment.CenterHorizontally ) { AuthUsernameTextField( - inputUsername = inputUsername, - onTextChange = { username -> - inputUsername = username - } + inputUsername = state.username, + onTextChange = state::onUsernameChange ) Divider(Modifier.padding(16.dp)) AuthPasswordTextField( - inputPassword = inputPassword, - onTextChange = { value -> - inputPassword = value - } + inputPassword = state.password, + onTextChange = state::onPasswordChange ) AnimatedVisibility( - visible = authState == AuthState.REGISTER + visible = state.isRegisterState ) { Column( horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.height(8.dp)) AuthPasswordTextField( - inputPassword = inputPassword, - onTextChange = { value -> - inputPassword = value - } + inputPassword = state.passwordSubmit, + onTextChange = state::onPasswordSubmitChange ) } } Divider(Modifier.padding(16.dp)) ElevatedButton( - onClick = { - keyboardController?.hide() - when (authState) { - AuthState.REGISTER -> register(inputUsername, inputPassword) - AuthState.AUTH -> auth(inputUsername, inputPassword) - } - }, - enabled = isFieldsValid + onClick = state::onSubmitClicked, + enabled = state.isFieldsValid ) { Text( text = "submit", @@ -155,9 +117,7 @@ fun AuthScreenContent( } Divider(Modifier.padding(16.dp)) TextButton( - onClick = { - authState = authState.inverse - } + onClick = state::onAuthFieldChange ) { Text( text = stringResource(id = buttonRes), @@ -217,11 +177,10 @@ fun AuthPasswordTextField( @Composable fun AuthScreenPreview() { AuthScreen( - auth = { _, _ -> }, - register = { _, _ -> }, - screenState = ScreenState.Content, - snackbarHostState = remember { - SnackbarHostState() - } + 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 e2de841..11a54d5 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,9 +2,14 @@ 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.feature.auth.domain.interactor.AuthInteractor -import com.stslex.aproselection.feature.auth.ui.model.ScreenEvent -import com.stslex.aproselection.feature.auth.ui.model.ScreenState +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.ScreenEvent +import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenState import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -14,12 +19,14 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update class AuthViewModel( - private val interactor: AuthInteractor + private val interactor: AuthInteractor, + private val navigate: (NavigationScreen) -> Unit ) : BaseViewModel() { - private val _screenState: MutableStateFlow = MutableStateFlow(ScreenState.Content) + private val _screenState = MutableStateFlow(ScreenState()) val screenState: StateFlow get() = _screenState.asStateFlow() @@ -27,44 +34,102 @@ class AuthViewModel( val screenEvent: SharedFlow get() = _screenEvents.asSharedFlow() - fun register( - username: String, - password: String - ) { - _screenState.value = ScreenState.Loading + fun process(action: ScreenAction) { + when (action) { + is ScreenAction.UsernameInput -> processUsernameInput(action) + is ScreenAction.OnSubmitClicked -> processSubmitClicked() + is ScreenAction.PasswordInput -> processPasswordInput(action) + is ScreenAction.PasswordSubmitInput -> processPasswordSubmitInput(action) + is ScreenAction.OnAuthFieldChange -> processAuthFieldChange() + } + } + + private fun processUsernameInput(action: ScreenAction.UsernameInput) { + _screenState.update { currentValue -> + currentValue.copy( + username = action.value + ) + } + } + + private fun processPasswordInput(action: ScreenAction.PasswordInput) { + _screenState.update { currentValue -> + currentValue.copy( + password = action.value + ) + } + } + + private fun processPasswordSubmitInput(action: ScreenAction.PasswordSubmitInput) { + _screenState.update { currentValue -> + currentValue.copy( + passwordSubmit = action.value + ) + } + } + + private fun processAuthFieldChange() { + _screenState.update { currentValue -> + currentValue.copy( + authFieldsState = currentValue.authFieldsState.inverse + ) + } + } + + private fun processSubmitClicked() { + when (screenState.value.authFieldsState) { + AuthFieldsState.AUTH -> auth() + AuthFieldsState.REGISTER -> register() + } + } + + private fun register() { + val state = screenState.value + setLoadingState(ScreenLoadingState.Loading) interactor - .auth( - username = username, - password = password + .register( + username = state.username, + password = state.password ) .catch { throwable -> - _screenEvents.emit(ScreenEvent.Error(throwable)) - _screenState.value = ScreenState.Content + _screenEvents.emit(ScreenEvent.Error(ErrorType.UnResolve(throwable))) + setLoadingState(ScreenLoadingState.Content) handleError(throwable) } .onEach { - _screenState.value = ScreenState.Content + setLoadingState(ScreenLoadingState.Content) } .launchIn(viewModelScope) } - fun auth(username: String, password: String) { - _screenState.value = ScreenState.Loading + private fun auth() { + val state = screenState.value + setLoadingState(ScreenLoadingState.Loading) interactor .auth( - username = username, - password = password + username = state.username, + password = state.password ) .catch { throwable -> - _screenEvents.emit(ScreenEvent.Error(throwable)) - _screenState.value = ScreenState.Content + _screenEvents.emit(ScreenEvent.Error(ErrorType.UnResolve(throwable))) + setLoadingState(ScreenLoadingState.Content) handleError(throwable) } .onEach { - _screenState.value = ScreenState.Content + setLoadingState(ScreenLoadingState.Content) + navigate(NavigationScreen.Home) } .launchIn(viewModelScope) } + + + private fun setLoadingState(screenLoadingState: ScreenLoadingState) { + _screenState.update { state -> + state.copy( + screenLoadingState = screenLoadingState + ) + } + } } diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthState.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthFieldsState.kt similarity index 84% rename from feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthState.kt rename to feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthFieldsState.kt index 9b61251..934331a 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthState.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/AuthFieldsState.kt @@ -3,13 +3,13 @@ package com.stslex.aproselection.feature.auth.ui.model import androidx.annotation.StringRes import com.stslex.aproselection.feature.auth.R -enum class AuthState( +enum class AuthFieldsState( @StringRes val buttonResId: Int ) { AUTH(R.string.auth), REGISTER(R.string.register); - val inverse: AuthState + val inverse: AuthFieldsState get() = when (this) { AUTH -> REGISTER REGISTER -> AUTH diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenEvent.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenEvent.kt deleted file mode 100644 index fdbaa87..0000000 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenEvent.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.stslex.aproselection.feature.auth.ui.model - -interface ScreenEvent { - - data class Error( - val throwable: Throwable - ) : ScreenEvent -} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenLoadingState.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenLoadingState.kt new file mode 100644 index 0000000..908bcd7 --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenLoadingState.kt @@ -0,0 +1,21 @@ +package com.stslex.aproselection.feature.auth.ui.model + +sealed interface ScreenLoadingState { + + data object Loading : ScreenLoadingState + + data object Content : ScreenLoadingState + + data class Error( + val error: ErrorType + ) : ScreenLoadingState +} + +sealed interface ErrorType { + + sealed interface Api : ErrorType + + data class UnResolve( + val throwable: Throwable + ) : ErrorType +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenState.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenState.kt deleted file mode 100644 index 3dfd657..0000000 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/ScreenState.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.stslex.aproselection.feature.auth.ui.model - -sealed interface ScreenState { - - object Loading : ScreenState - - object Content : ScreenState -} \ No newline at end of file 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 new file mode 100644 index 0000000..cd09784 --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/mvi/ScreenAction.kt @@ -0,0 +1,20 @@ +package com.stslex.aproselection.feature.auth.ui.model.mvi + +sealed interface ScreenAction { + + data class UsernameInput( + val value: String + ) : ScreenAction + + data class PasswordInput( + val value: String + ) : ScreenAction + + data class PasswordSubmitInput( + val value: String + ) : ScreenAction + + data object OnSubmitClicked : ScreenAction + + data object OnAuthFieldChange : ScreenAction +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/mvi/ScreenEvent.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/mvi/ScreenEvent.kt new file mode 100644 index 0000000..81739ed --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/mvi/ScreenEvent.kt @@ -0,0 +1,10 @@ +package com.stslex.aproselection.feature.auth.ui.model.mvi + +import com.stslex.aproselection.feature.auth.ui.model.ErrorType + +interface ScreenEvent { + + data class Error( + val errorType: ErrorType + ) : ScreenEvent +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/mvi/ScreenState.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/mvi/ScreenState.kt new file mode 100644 index 0000000..7d9d8e3 --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/mvi/ScreenState.kt @@ -0,0 +1,14 @@ +package com.stslex.aproselection.feature.auth.ui.model.mvi + +import androidx.compose.runtime.Stable +import com.stslex.aproselection.feature.auth.ui.model.AuthFieldsState +import com.stslex.aproselection.feature.auth.ui.model.ScreenLoadingState + +@Stable +data class ScreenState( + val screenLoadingState: ScreenLoadingState = ScreenLoadingState.Content, + val username: String = "", + val password: String = "", + val passwordSubmit: String = "", + val authFieldsState: AuthFieldsState = AuthFieldsState.AUTH +) 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 new file mode 100644 index 0000000..7c63abe --- /dev/null +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/AuthScreenState.kt @@ -0,0 +1,104 @@ +package com.stslex.aproselection.feature.auth.ui.model.screen + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +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.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.SoftwareKeyboardController +import com.stslex.aproselection.core.ui.ext.CollectAsEvent +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.ScreenEvent +import com.stslex.aproselection.feature.auth.ui.model.mvi.ScreenState +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +@OptIn(ExperimentalComposeUiApi::class) +@Stable +data class AuthScreenState( + val screenLoadingState: ScreenLoadingState = ScreenLoadingState.Content, + val username: String = "", + val password: String = "", + val passwordSubmit: String = "", + 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)) + } + + fun onPasswordSubmitChange(passwordSubmit: String) { + if (this.passwordSubmit == passwordSubmit) return + processAction(ScreenAction.PasswordSubmitInput(passwordSubmit)) + } + + fun onSubmitClicked() { + keyboardController?.hide() + processAction(ScreenAction.OnSubmitClicked) + } + + fun onAuthFieldChange() { + processAction(ScreenAction.OnAuthFieldChange) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun rememberAuthScreenState( + screenStateFlow: () -> StateFlow, + screenEventFlow: () -> SharedFlow, + processAction: (ScreenAction) -> Unit, +): AuthScreenState { + + val screenState by remember { + screenStateFlow() + }.collectAsState() + + val snackbarHostState = remember { + SnackbarHostState() + } + + screenEventFlow().CollectAsEvent { event -> + when (event) { + is ScreenEvent.Error -> when (val error = event.errorType) { + is ErrorType.UnResolve -> { + snackbarHostState.showSnackbar(error.throwable.message.orEmpty()) + } + + is ErrorType.Api -> Unit // TODO add api parse error logic + } + } + } + + val keyboardController = LocalSoftwareKeyboardController.current + + return remember(screenState) { + AuthScreenState( + screenLoadingState = screenState.screenLoadingState, + username = screenState.username, + password = screenState.password, + passwordSubmit = screenState.passwordSubmit, + authFieldsState = screenState.authFieldsState, + snackbarHostState = snackbarHostState, + processAction = processAction, + keyboardController = keyboardController + ) + } +} 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 02ed02d..ab7605c 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 @@ -1,23 +1,16 @@ package com.stslex.aproselection.feature.auth.ui.navigation -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import com.stslex.aproselection.core.ui.ext.CollectAsEvent import com.stslex.aproselection.core.ui.navigation.AppDestination import com.stslex.aproselection.core.ui.navigation.NavigationScreen import com.stslex.aproselection.feature.auth.ui.AuthScreen import com.stslex.aproselection.feature.auth.ui.AuthViewModel -import com.stslex.aproselection.feature.auth.ui.model.ScreenEvent +import com.stslex.aproselection.feature.auth.ui.model.screen.rememberAuthScreenState import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf -@OptIn(ExperimentalMaterial3Api::class) fun NavGraphBuilder.authRouter( modifier: Modifier = Modifier, navigate: (NavigationScreen) -> Unit @@ -29,25 +22,14 @@ fun NavGraphBuilder.authRouter( parameters = { parametersOf(navigate) } ) - val snackbarHostState = remember { - SnackbarHostState() - } - - val screenState by remember { - viewModel.screenState - }.collectAsState() - - viewModel.screenEvent.CollectAsEvent { event -> - when (event) { - is ScreenEvent.Error -> snackbarHostState.showSnackbar(event.throwable.message.orEmpty()) - } - } + val authScreenState = rememberAuthScreenState( + screenStateFlow = viewModel::screenState, + screenEventFlow = viewModel::screenEvent, + processAction = viewModel::process + ) AuthScreen( - screenState = screenState, - snackbarHostState = snackbarHostState, - register = viewModel::register, - auth = viewModel::auth, + state = authScreenState, modifier = modifier ) } diff --git a/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/HomeScreen.kt b/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/HomeScreen.kt index e4ea655..f2d1bbb 100644 --- a/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/HomeScreen.kt +++ b/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/HomeScreen.kt @@ -1,5 +1,6 @@ package com.stslex.aproselection.feature.home.ui +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -7,8 +8,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.material3.ElevatedButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -17,14 +20,22 @@ fun HomeScreen( logOut: () -> Unit, modifier: Modifier = Modifier ) { - Box(modifier.fillMaxSize()) { - Column { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { Text( text = "Success", - style = MaterialTheme.typography.headlineMedium + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground ) Spacer(modifier = Modifier.height(32.dp)) - ElevatedButton( + OutlinedButton( onClick = logOut ) { Text(text = "logout") 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 bab2a93..09cb017 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 @@ -15,7 +15,6 @@ class HomeViewModel( viewModelScope.launch { appDataStore.setToken("") appDataStore.setUuid("") - navigation(NavigationScreen.PopBackStack) } } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dec2a51..9241798 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] androidDesugarJdkLibs = "2.0.3" -kotlin = "1.8.22" +kotlin = "1.9.0" +kotlinLanguage = "1.9" androidGradlePlugin = "8.0.2" ktx = "1.10.1" @@ -8,7 +9,7 @@ material = "1.9.0" appcompat = "1.6.1" immutableCollection = "0.3.5" -composeCompiler = "1.4.8" +composeCompiler = "1.5.1" composeBom = "2023.06.01" composeNavigation = "2.6.0" accompanist = "0.30.0" From 34bebce0a09ec7426dd390c56bee30df6cd4b8a5 Mon Sep 17 00:00:00 2001 From: stslex Date: Tue, 1 Aug 2023 08:51:55 +0300 Subject: [PATCH 4/4] check unit test --- .../aproselection/ui/InitialAppViewModel.kt | 1 + .../stslex/aproselection/DiKoinModuleTest.kt | 19 ++++++++++++++++--- .../core/datastore/AppDataStoreImpl.kt | 1 - .../core/network/client/NetworkClientImpl.kt | 1 + .../clients/auth/AuthNetworkClientImpl.kt | 1 - .../data/repository/AuthRepositoryImpl.kt | 1 - 6 files changed, 18 insertions(+), 6 deletions(-) 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 aef9817..1a784c2 100644 --- a/app/src/main/java/com/stslex/aproselection/ui/InitialAppViewModel.kt +++ b/app/src/main/java/com/stslex/aproselection/ui/InitialAppViewModel.kt @@ -22,6 +22,7 @@ class InitialAppViewModel( val isAuth: StateFlow get() = _isAuth.asStateFlow() + // TODO (вынести из init блока) init { dataStore.token .catch { error -> diff --git a/app/src/test/java/com/stslex/aproselection/DiKoinModuleTest.kt b/app/src/test/java/com/stslex/aproselection/DiKoinModuleTest.kt index 2194669..df90d9c 100644 --- a/app/src/test/java/com/stslex/aproselection/DiKoinModuleTest.kt +++ b/app/src/test/java/com/stslex/aproselection/DiKoinModuleTest.kt @@ -2,12 +2,15 @@ package com.stslex.aproselection import android.content.Context import com.stslex.aproselection.core.datastore.coreDataStoreModule -import com.stslex.aproselection.core.network.di.ModuleCoreNetwork import com.stslex.aproselection.core.network.di.ModuleCoreNetwork.moduleCoreNetwork +import com.stslex.aproselection.core.ui.navigation.NavigationScreen +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.junit.Test import org.koin.android.ext.koin.androidContext import org.koin.dsl.koinApplication +import org.koin.dsl.module import org.koin.test.KoinTest import org.koin.test.check.checkModules import org.mockito.Mockito @@ -16,12 +19,22 @@ class DiKoinModuleTest : KoinTest { @Test fun checkKoinModules() { + val navigator: (screen: NavigationScreen) -> Unit = {} + koinApplication { + androidContext(Mockito.mock(Context::class.java)) modules( - moduleFeatureAuth, + module { + single { + navigator + } + }, + appModule, moduleCoreNetwork, - coreDataStoreModule + coreDataStoreModule, + moduleFeatureAuth, + moduleFeatureHome, ) checkModules() } diff --git a/core/datastore/src/main/java/com/stslex/aproselection/core/datastore/AppDataStoreImpl.kt b/core/datastore/src/main/java/com/stslex/aproselection/core/datastore/AppDataStoreImpl.kt index 9e23cfd..87c2d8a 100644 --- a/core/datastore/src/main/java/com/stslex/aproselection/core/datastore/AppDataStoreImpl.kt +++ b/core/datastore/src/main/java/com/stslex/aproselection/core/datastore/AppDataStoreImpl.kt @@ -43,7 +43,6 @@ class AppDataStoreImpl( } } - override suspend fun setToken(token: String) { context.dataStore.updateData { prefs -> prefs.toMutablePreferences().apply { diff --git a/core/network/src/main/java/com/stslex/aproselection/core/network/client/NetworkClientImpl.kt b/core/network/src/main/java/com/stslex/aproselection/core/network/client/NetworkClientImpl.kt index c10685f..77935d8 100644 --- a/core/network/src/main/java/com/stslex/aproselection/core/network/client/NetworkClientImpl.kt +++ b/core/network/src/main/java/com/stslex/aproselection/core/network/client/NetworkClientImpl.kt @@ -42,6 +42,7 @@ class NetworkClientImpl( private val _uuid = MutableStateFlow("") private val uuid = _uuid.asStateFlow() + // TODO (вынести из init блока) init { dataStore.token .onEach { diff --git a/core/network/src/main/java/com/stslex/aproselection/core/network/clients/auth/AuthNetworkClientImpl.kt b/core/network/src/main/java/com/stslex/aproselection/core/network/clients/auth/AuthNetworkClientImpl.kt index 2ee7d23..748dd9d 100644 --- a/core/network/src/main/java/com/stslex/aproselection/core/network/clients/auth/AuthNetworkClientImpl.kt +++ b/core/network/src/main/java/com/stslex/aproselection/core/network/clients/auth/AuthNetworkClientImpl.kt @@ -30,7 +30,6 @@ class AuthNetworkClientImpl( .body() } - override fun auth( user: UserAuthSendModel ): Flow = flow { diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/data/repository/AuthRepositoryImpl.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/data/repository/AuthRepositoryImpl.kt index a46d124..abab613 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/data/repository/AuthRepositoryImpl.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/data/repository/AuthRepositoryImpl.kt @@ -14,7 +14,6 @@ class AuthRepositoryImpl( private val dataSource: AppDataStore ) : AuthRepository { - override fun auth( username: String, password: String