simple login screen with simple viewmodel's logic

This commit is contained in:
2024-08-05 02:52:58 +03:00
parent 471af18f5e
commit 2c91f6bb62
11 changed files with 144 additions and 33 deletions
+4 -3
View File
@@ -1,6 +1,6 @@
import com.android.build.api.dsl.ManagedVirtualDevice
import org.jetbrains.compose.ExperimentalComposeLibrary import org.jetbrains.compose.ExperimentalComposeLibrary
import org.jetbrains.compose.desktop.application.dsl.TargetFormat 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.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree
@@ -55,9 +55,11 @@ kotlin {
implementation(compose.runtime) implementation(compose.runtime)
implementation(compose.foundation) implementation(compose.foundation)
implementation(compose.material3) implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation(compose.components.resources) implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview) implementation(compose.components.uiToolingPreview)
implementation(libs.voyager.navigator) implementation(libs.voyager.navigator)
implementation(libs.voyager.transitions)
implementation(libs.coil) implementation(libs.coil)
implementation(libs.coil.network.ktor) implementation(libs.coil.network.ktor)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
@@ -68,8 +70,7 @@ kotlin {
implementation(libs.koin.core) implementation(libs.koin.core)
implementation(libs.koin.compose) implementation(libs.koin.compose)
api("dev.icerock.moko:mvvm-compose:0.16.1") implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0")
api("dev.icerock.moko:mvvm-flow-compose:0.16.1")
} }
commonTest.dependencies { commonTest.dependencies {
@@ -5,6 +5,7 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.transitions.FadeTransition
import dev.meloda.overseerr.screens.url.presentation.UrlScreen import dev.meloda.overseerr.screens.url.presentation.UrlScreen
import dev.meloda.overseerr.theme.AppTheme import dev.meloda.overseerr.theme.AppTheme
import org.koin.compose.KoinContext import org.koin.compose.KoinContext
@@ -13,7 +14,9 @@ import org.koin.compose.KoinContext
internal fun App() = KoinContext { internal fun App() = KoinContext {
AppTheme { AppTheme {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
Navigator(UrlScreen()) Navigator(UrlScreen()) { navigator ->
FadeTransition(navigator)
}
} }
} }
} }
@@ -0,0 +1,9 @@
package dev.meloda.overseerr.ext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
fun <T> MutableStateFlow<T>.setValue(function: (T) -> T) {
val newValue = function(value)
update { newValue }
}
@@ -1,9 +1,43 @@
package dev.meloda.overseerr.screens.login 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<LoginScreenState>
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)
}
}
} }
@@ -1,9 +1,7 @@
package dev.meloda.overseerr.screens.login.di 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 import org.koin.dsl.module
val loginModule = module { val loginModule = module {
viewModelFactory(::LoginViewModel)
} }
@@ -1,4 +1,19 @@
package dev.meloda.overseerr.screens.login.model 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
)
}
} }
@@ -1,20 +1,29 @@
package dev.meloda.overseerr.screens.login.presentation package dev.meloda.overseerr.screens.login.presentation
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack 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.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow 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 { class LoginScreen : Screen {
@@ -22,12 +31,14 @@ class LoginScreen : Screen {
@Composable @Composable
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val viewModel: LoginViewModel = viewModel { LoginViewModelImpl() }
val screenState: LoginScreenState by viewModel.screenState.collectAsState()
var loginValue by rememberSaveable { var loginValue by rememberSaveable {
mutableStateOf("") mutableStateOf(screenState.login)
} }
var passwordValue by rememberSaveable { var passwordValue by rememberSaveable {
mutableStateOf("") mutableStateOf(screenState.password)
} }
Scaffold( Scaffold(
@@ -54,13 +65,16 @@ class LoginScreen : Screen {
.padding(padding), .padding(padding),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
TextField( TextField(
modifier = Modifier.fillMaxWidth(0.9f), modifier = Modifier.fillMaxWidth(0.9f),
value = loginValue, value = loginValue,
onValueChange = { newText -> loginValue = newText }, onValueChange = { newText ->
placeholder = { Text(text = "Login") } loginValue = newText
viewModel.onLoginInputChanged(newText)
},
placeholder = { Text(text = "Login") },
isError = screenState.isLoginEmptyError,
singleLine = true
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -68,8 +82,35 @@ class LoginScreen : Screen {
TextField( TextField(
modifier = Modifier.fillMaxWidth(0.9f), modifier = Modifier.fillMaxWidth(0.9f),
value = passwordValue, value = passwordValue,
onValueChange = { newText -> passwordValue = newText }, onValueChange = { newText ->
placeholder = { Text(text = "Password") } 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)) Spacer(modifier = Modifier.height(16.dp))
@@ -80,6 +121,11 @@ class LoginScreen : Screen {
} }
) { ) {
Text(text = "Authorize") Text(text = "Authorize")
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowForward,
contentDescription = null
)
} }
} }
} }
@@ -1,6 +1,6 @@
package dev.meloda.overseerr.screens.url package dev.meloda.overseerr.screens.url
import dev.icerock.moko.mvvm.viewmodel.ViewModel import androidx.lifecycle.ViewModel
class UrlViewModel : ViewModel() { class UrlViewModel : ViewModel() {
} }
@@ -1,11 +1,7 @@
package dev.meloda.overseerr.screens.url.di 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 import org.koin.dsl.module
val urlModule = module { val urlModule = module {
viewModelFactory {
UrlViewModel()
}
} }
@@ -2,23 +2,31 @@ package dev.meloda.overseerr.screens.url.presentation
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import dev.meloda.overseerr.screens.login.presentation.LoginScreen import dev.meloda.overseerr.screens.login.presentation.LoginScreen
import dev.meloda.overseerr.screens.url.UrlViewModel
class UrlScreen : Screen { class UrlScreen : Screen {
@Composable @Composable
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
Scaffold(modifier = Modifier.fillMaxSize()) { val viewModel: UrlViewModel = viewModel { UrlViewModel() }
Column(modifier = Modifier.fillMaxSize()) {
Scaffold(modifier = Modifier.fillMaxSize()) { padding ->
Column(
modifier = Modifier.fillMaxSize()
.padding(padding)
) {
Text(text = "Input url screen") Text(text = "Input url screen")
Button( Button(
onClick = { onClick = {
+1
View File
@@ -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-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" } 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-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 = { 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" } 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" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }