diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 417c59f..0371108 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,6 +1,6 @@ +import com.android.build.api.dsl.ManagedVirtualDevice import org.jetbrains.compose.ExperimentalComposeLibrary import org.jetbrains.compose.desktop.application.dsl.TargetFormat -import com.android.build.api.dsl.ManagedVirtualDevice import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree @@ -55,9 +55,11 @@ kotlin { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) + implementation(compose.materialIconsExtended) implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) implementation(libs.voyager.navigator) + implementation(libs.voyager.transitions) implementation(libs.coil) implementation(libs.coil.network.ktor) implementation(libs.kotlinx.coroutines.core) @@ -68,8 +70,7 @@ kotlin { implementation(libs.koin.core) implementation(libs.koin.compose) - api("dev.icerock.moko:mvvm-compose:0.16.1") - api("dev.icerock.moko:mvvm-flow-compose:0.16.1") + implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0") } commonTest.dependencies { diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/App.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/App.kt index 76dafa1..e614d65 100644 --- a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/App.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.transitions.FadeTransition import dev.meloda.overseerr.screens.url.presentation.UrlScreen import dev.meloda.overseerr.theme.AppTheme import org.koin.compose.KoinContext @@ -13,7 +14,9 @@ import org.koin.compose.KoinContext internal fun App() = KoinContext { AppTheme { Surface(modifier = Modifier.fillMaxSize()) { - Navigator(UrlScreen()) + Navigator(UrlScreen()) { navigator -> + FadeTransition(navigator) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/ext/Extensions.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/ext/Extensions.kt new file mode 100644 index 0000000..dbcb59d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/ext/Extensions.kt @@ -0,0 +1,9 @@ +package dev.meloda.overseerr.ext + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +fun MutableStateFlow.setValue(function: (T) -> T) { + val newValue = function(value) + update { newValue } +} diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/LoginViewModel.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/LoginViewModel.kt index 65e4d3d..3f1e2d9 100644 --- a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/LoginViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/LoginViewModel.kt @@ -1,9 +1,43 @@ package dev.meloda.overseerr.screens.login -import dev.icerock.moko.mvvm.viewmodel.ViewModel +import androidx.lifecycle.ViewModel +import dev.meloda.overseerr.ext.setValue +import dev.meloda.overseerr.screens.login.model.LoginScreenState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow -class LoginViewModel : ViewModel() { - - - +interface LoginViewModel { + val screenState: StateFlow + + fun onLoginInputChanged(newLogin: String) + fun onPasswordInputChanged(newPassword: String) + fun onAuthorizeButtonClicked() + fun onPasswordVisibilityButtonClicked() +} + +class LoginViewModelImpl : ViewModel(), LoginViewModel { + + override val screenState = MutableStateFlow(LoginScreenState.EMPTY) + + override fun onLoginInputChanged(newLogin: String) { + screenState.setValue { old -> + old.copy(login = newLogin) + } + } + + override fun onPasswordInputChanged(newPassword: String) { + screenState.setValue { old -> + old.copy(password = newPassword) + } + } + + override fun onAuthorizeButtonClicked() { + // TODO: 05/08/2024, Danil Nikolaev: add logger + } + + override fun onPasswordVisibilityButtonClicked() { + screenState.setValue { old -> + old.copy(isPasswordVisible = !old.isPasswordVisible) + } + } } diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/di/LoginModule.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/di/LoginModule.kt index ff6a35f..2892c3d 100644 --- a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/di/LoginModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/di/LoginModule.kt @@ -1,9 +1,7 @@ package dev.meloda.overseerr.screens.login.di -import dev.icerock.moko.mvvm.compose.viewModelFactory -import dev.meloda.overseerr.screens.login.LoginViewModel import org.koin.dsl.module val loginModule = module { - viewModelFactory(::LoginViewModel) + } diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/model/LoginScreenState.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/model/LoginScreenState.kt index a966512..a018095 100644 --- a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/model/LoginScreenState.kt +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/model/LoginScreenState.kt @@ -1,4 +1,19 @@ package dev.meloda.overseerr.screens.login.model -class LoginScreenState { +data class LoginScreenState( + val login: String, + val password: String, + val isPasswordVisible: Boolean, + val isLoginEmptyError: Boolean, + val isPasswordEmptyError: Boolean +) { + companion object { + val EMPTY: LoginScreenState = LoginScreenState( + login = "", + password = "", + isPasswordVisible = false, + isLoginEmptyError = false, + isPasswordEmptyError = false + ) + } } diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/presentation/LoginScreen.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/presentation/LoginScreen.kt index 6bdf9ba..4a40117 100644 --- a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/presentation/LoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/login/presentation/LoginScreen.kt @@ -1,20 +1,29 @@ package dev.meloda.overseerr.screens.login.presentation import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow +import dev.meloda.overseerr.screens.login.LoginViewModel +import dev.meloda.overseerr.screens.login.LoginViewModelImpl +import dev.meloda.overseerr.screens.login.model.LoginScreenState class LoginScreen : Screen { @@ -22,12 +31,14 @@ class LoginScreen : Screen { @Composable override fun Content() { val navigator = LocalNavigator.currentOrThrow + val viewModel: LoginViewModel = viewModel { LoginViewModelImpl() } + val screenState: LoginScreenState by viewModel.screenState.collectAsState() var loginValue by rememberSaveable { - mutableStateOf("") + mutableStateOf(screenState.login) } var passwordValue by rememberSaveable { - mutableStateOf("") + mutableStateOf(screenState.password) } Scaffold( @@ -54,13 +65,16 @@ class LoginScreen : Screen { .padding(padding), horizontalAlignment = Alignment.CenterHorizontally ) { - - TextField( modifier = Modifier.fillMaxWidth(0.9f), value = loginValue, - onValueChange = { newText -> loginValue = newText }, - placeholder = { Text(text = "Login") } + onValueChange = { newText -> + loginValue = newText + viewModel.onLoginInputChanged(newText) + }, + placeholder = { Text(text = "Login") }, + isError = screenState.isLoginEmptyError, + singleLine = true ) Spacer(modifier = Modifier.height(16.dp)) @@ -68,8 +82,35 @@ class LoginScreen : Screen { TextField( modifier = Modifier.fillMaxWidth(0.9f), value = passwordValue, - onValueChange = { newText -> passwordValue = newText }, - placeholder = { Text(text = "Password") } + onValueChange = { newText -> + passwordValue = newText + viewModel.onPasswordInputChanged(newText) + }, + placeholder = { Text(text = "Password") }, + isError = screenState.isPasswordEmptyError, + trailingIcon = { + IconButton(onClick = viewModel::onPasswordVisibilityButtonClicked) { + Icon( + imageVector = if (screenState.isPasswordVisible) { + Icons.Rounded.VisibilityOff + } else { + Icons.Rounded.Visibility + }, + contentDescription = if (screenState.isPasswordVisible) "Password visible icon" + else "Password invisible icon" + ) + } + }, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Go, + keyboardType = KeyboardType.Password + ), + visualTransformation = if (screenState.isPasswordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + singleLine = true ) Spacer(modifier = Modifier.height(16.dp)) @@ -80,6 +121,11 @@ class LoginScreen : Screen { } ) { Text(text = "Authorize") + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowForward, + contentDescription = null + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/UrlViewModel.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/UrlViewModel.kt index 5c959d1..8b163f6 100644 --- a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/UrlViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/UrlViewModel.kt @@ -1,6 +1,6 @@ package dev.meloda.overseerr.screens.url -import dev.icerock.moko.mvvm.viewmodel.ViewModel +import androidx.lifecycle.ViewModel class UrlViewModel : ViewModel() { } diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/di/UrlModule.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/di/UrlModule.kt index d0e304d..ea56935 100644 --- a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/di/UrlModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/di/UrlModule.kt @@ -1,11 +1,7 @@ package dev.meloda.overseerr.screens.url.di -import dev.icerock.moko.mvvm.compose.viewModelFactory -import dev.meloda.overseerr.screens.url.UrlViewModel import org.koin.dsl.module val urlModule = module { - viewModelFactory { - UrlViewModel() - } + } diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/presentation/UrlScreen.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/presentation/UrlScreen.kt index 5e70afe..485aac9 100644 --- a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/presentation/UrlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/presentation/UrlScreen.kt @@ -2,23 +2,31 @@ package dev.meloda.overseerr.screens.url.presentation import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import dev.meloda.overseerr.screens.login.presentation.LoginScreen +import dev.meloda.overseerr.screens.url.UrlViewModel class UrlScreen : Screen { @Composable override fun Content() { val navigator = LocalNavigator.currentOrThrow - Scaffold(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxSize()) { + val viewModel: UrlViewModel = viewModel { UrlViewModel() } + + Scaffold(modifier = Modifier.fillMaxSize()) { padding -> + Column( + modifier = Modifier.fillMaxSize() + .padding(padding) + ) { Text(text = "Input url screen") Button( onClick = { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80d744a..59aaf99 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ androidx-activityCompose = { module = "androidx.activity:activity-compose", vers androidx-uitest-testManifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-uiTest" } androidx-uitest-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidx-uiTest" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } +voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } coil = { module = "io.coil-kt.coil3:coil-compose-core", version.ref = "coil" } coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }