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"