Initial commit
Some checks failed
Merge core/template-android-project to this repo / merge-if-needed (push) Has been cancelled
Some checks failed
Merge core/template-android-project to this repo / merge-if-needed (push) Has been cancelled
This commit is contained in:
@@ -36,10 +36,5 @@ android {
|
||||
|
||||
dependencies {
|
||||
defaultComposeLibrary()
|
||||
val ktor = "3.3.1"
|
||||
implementation("io.ktor:ktor-client-core:$ktor")
|
||||
implementation("io.ktor:ktor-client-cio:$ktor")
|
||||
implementation("io.ktor:ktor-client-content-negotiation:$ktor")
|
||||
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
implementation("androidx.compose.material:material-icons-extended:1.7.8")
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".ui.root.RootActivity"
|
||||
android:name=".ui.RootActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:label="@string/title_activity_root">
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package ru.myitschool.work.core
|
||||
|
||||
object Constants {
|
||||
const val HOST = "http://10.0.2.2:8080"
|
||||
const val USER_URL = "/user"
|
||||
const val FULL_USER_URL = "$HOST$USER_URL"
|
||||
const val BOOK_URL = "/book"
|
||||
const val FULL_BOOK_URL = "$HOST$BOOK_URL"
|
||||
}
|
||||
13
app/src/main/java/ru/myitschool/work/core/TestIds.kt
Normal file
13
app/src/main/java/ru/myitschool/work/core/TestIds.kt
Normal file
@@ -0,0 +1,13 @@
|
||||
package ru.myitschool.work.core
|
||||
|
||||
object TestIds {
|
||||
const val AUTH_TITLE = "auth_title"
|
||||
const val AUTH_INPUT_EMAIL = "auth_input_email"
|
||||
const val AUTH_INPUT_PASSWORD = "auth_input_password"
|
||||
const val AUTH_INPUT_PASSWORD_VISIBILITY = "auth_input_password_visibility"
|
||||
const val AUTH_LOGIN = "auth_login"
|
||||
const val AUTH_ERROR = "auth_error"
|
||||
const val AUTH_FORGOT_PASS = "auth_forgot_pass"
|
||||
const val AUTH_CREATE_ACCOUNT = "auth_create_account"
|
||||
const val AUTH_SUCCESS = "auth_success"
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package ru.myitschool.work.data
|
||||
|
||||
import ru.myitschool.work.data.source.NetworkDataSource
|
||||
import ru.myitschool.work.domain.entities.BookingEntity
|
||||
import ru.myitschool.work.domain.entities.UserEntity
|
||||
|
||||
object AppRepository {
|
||||
suspend fun loadData(): Result<UserEntity> {
|
||||
return NetworkDataSource.getUser().map { dto ->
|
||||
UserEntity(
|
||||
name = dto.name,
|
||||
bookingList = dto.booking.map { bookingDto ->
|
||||
BookingEntity(
|
||||
roomName = bookingDto.room,
|
||||
time = bookingDto.time
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addBook(room: String, time: String): Result<Unit> {
|
||||
return NetworkDataSource.addBook(room, time)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package ru.myitschool.work.data.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ErrorDto(
|
||||
@SerialName("error")
|
||||
val error: String,
|
||||
)
|
||||
@@ -1,20 +0,0 @@
|
||||
package ru.myitschool.work.data.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserDto(
|
||||
@SerialName("name")
|
||||
val name: String,
|
||||
@SerialName("booking")
|
||||
val booking: List<BookingDto>
|
||||
) {
|
||||
@Serializable
|
||||
data class BookingDto(
|
||||
@SerialName("room")
|
||||
val room: String,
|
||||
@SerialName("time")
|
||||
val time: String,
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package ru.myitschool.work.data.source
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.forms.FormPart
|
||||
import io.ktor.client.request.forms.MultiPartFormDataContent
|
||||
import io.ktor.client.request.forms.formData
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.myitschool.work.core.Constants
|
||||
import ru.myitschool.work.data.dto.ErrorDto
|
||||
import ru.myitschool.work.data.dto.UserDto
|
||||
|
||||
object NetworkDataSource {
|
||||
private val client by lazy {
|
||||
HttpClient(CIO) {
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getUser(): Result<UserDto> = withContext(Dispatchers.IO) {
|
||||
return@withContext runCatching {
|
||||
val response = client.get(Constants.FULL_USER_URL)
|
||||
if (response.status == HttpStatusCode.OK) {
|
||||
response.body<UserDto>()
|
||||
} else {
|
||||
error(response.body<ErrorDto>().error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addBook(
|
||||
room: String,
|
||||
time: String,
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
return@withContext runCatching {
|
||||
val response = client.post(Constants.FULL_BOOK_URL) {
|
||||
setBody(
|
||||
MultiPartFormDataContent(
|
||||
formData(
|
||||
FormPart("room", room),
|
||||
FormPart("time", time),
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (response.status == HttpStatusCode.OK) {
|
||||
Unit
|
||||
} else {
|
||||
error(response.body<ErrorDto>().error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package ru.myitschool.work.domain
|
||||
|
||||
import ru.myitschool.work.data.AppRepository
|
||||
|
||||
class AddBookUseCase(
|
||||
private val repository: AppRepository
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
room: String,
|
||||
time: String,
|
||||
): Result<Unit> {
|
||||
return repository.addBook(
|
||||
room = room,
|
||||
time = time,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package ru.myitschool.work.domain
|
||||
|
||||
import ru.myitschool.work.data.AppRepository
|
||||
import ru.myitschool.work.domain.entities.UserEntity
|
||||
|
||||
class GetUserDataUseCase(
|
||||
private val repository: AppRepository
|
||||
) {
|
||||
suspend operator fun invoke(): Result<UserEntity> {
|
||||
return repository.loadData()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package ru.myitschool.work.domain.entities
|
||||
|
||||
data class BookingEntity(
|
||||
val roomName: String,
|
||||
val time: String,
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
package ru.myitschool.work.domain.entities
|
||||
|
||||
data class UserEntity(
|
||||
val name: String,
|
||||
val bookingList: List<BookingEntity>
|
||||
)
|
||||
58
app/src/main/java/ru/myitschool/work/ui/RootActivity.kt
Normal file
58
app/src/main/java/ru/myitschool/work/ui/RootActivity.kt
Normal file
@@ -0,0 +1,58 @@
|
||||
package ru.myitschool.work.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ru.myitschool.work.ui.auth.AuthScreen
|
||||
import ru.myitschool.work.ui.auth.AuthViewModel
|
||||
import ru.myitschool.work.ui.theme.WorkTheme
|
||||
|
||||
/**
|
||||
* Этот файл не меняем!
|
||||
*
|
||||
* Здесь уже подключена ViewModel, которая управляет логикой состояний:
|
||||
* 1) uiState: текущее состояние (Default, Loading, Error, Success)
|
||||
* 2) email/password: тексты из полей
|
||||
* 3) isButtonEnabled: доступность кнопки "Войти"
|
||||
*
|
||||
* ВАЖНО: Дизайнерам редактировать только функцию AuthScreen().
|
||||
* Всё остальное — готовая логика, менять ничего не нужно.
|
||||
*/
|
||||
class RootActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
WorkTheme {
|
||||
Scaffold(modifier = Modifier.Companion.fillMaxSize()) { innerPadding ->
|
||||
val viewModel: AuthViewModel = viewModel()
|
||||
val uiState = viewModel.uiState.collectAsState().value
|
||||
val email = viewModel.email.collectAsState().value
|
||||
val password = viewModel.password.collectAsState().value
|
||||
val isButtonEnabled = viewModel.isButtonEnabled.collectAsState().value
|
||||
AuthScreen(
|
||||
modifier = Modifier.Companion
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
uiState = uiState,
|
||||
email = email,
|
||||
password = password,
|
||||
isButtonEnabled = isButtonEnabled,
|
||||
onEmailChange = viewModel::onEmailChange,
|
||||
onPasswordChange = viewModel::onPasswordChange,
|
||||
onLoginClick = viewModel::onLoginClick,
|
||||
onForgotPasswordClick = viewModel::onForgotPasswordClick,
|
||||
onCreateAccountClick = viewModel::onCreateAccountClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
213
app/src/main/java/ru/myitschool/work/ui/auth/AuthScreen.kt
Normal file
213
app/src/main/java/ru/myitschool/work/ui/auth/AuthScreen.kt
Normal file
@@ -0,0 +1,213 @@
|
||||
package ru.myitschool.work.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ru.myitschool.work.R
|
||||
import ru.myitschool.work.core.TestIds
|
||||
|
||||
/**
|
||||
* Дизайнеры реализуют UI в функции AuthContent.
|
||||
*
|
||||
* Входные параметры:
|
||||
* - uiState — текущее состояние экрана (Default / Loading / Error / Success)
|
||||
* - email / password — тексты, которые сейчас в полях
|
||||
* - isButtonEnabled — нужно визуально показать активность кнопки
|
||||
*
|
||||
* ️ Колбэки:
|
||||
* - onEmailChange() — вызывается при изменении email
|
||||
* - onPasswordChange() — вызывается при изменении пароля
|
||||
* - onLoginClick() — вызывается при нажатии кнопки "Войти"
|
||||
* - onForgotPasswordClick() — "Забыли пароль?"
|
||||
* - onCreateAccountClick() — "Создать аккаунт"
|
||||
*
|
||||
* ВАЖНО:
|
||||
* - Никакой логики добавлять не нужно!
|
||||
* - Только визуальное оформление Compose по заданию олимпиады.
|
||||
*/
|
||||
@Composable
|
||||
fun AuthScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
uiState: AuthUiState,
|
||||
email: String,
|
||||
password: String,
|
||||
isButtonEnabled: Boolean,
|
||||
onEmailChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onLoginClick: () -> Unit,
|
||||
onForgotPasswordClick: () -> Unit,
|
||||
onCreateAccountClick: () -> Unit,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
when (uiState) {
|
||||
|
||||
is AuthUiState.Default,
|
||||
is AuthUiState.Error -> {
|
||||
// Форма видна и в Default, и при ошибке
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.testTag(TestIds.AUTH_TITLE),
|
||||
text = stringResource(R.string.auth_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
// Email
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestIds.AUTH_INPUT_EMAIL),
|
||||
value = email,
|
||||
onValueChange = onEmailChange,
|
||||
label = { Text(stringResource(R.string.auth_input_email_title)) },
|
||||
placeholder = { Text(stringResource(R.string.auth_input_email_placeholder)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
isError = uiState is AuthUiState.Error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Пароль
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestIds.AUTH_INPUT_PASSWORD),
|
||||
value = password,
|
||||
onValueChange = onPasswordChange,
|
||||
label = { Text(stringResource(R.string.auth_input_password_title)) },
|
||||
placeholder = { Text(stringResource(R.string.auth_input_password_placeholder)) },
|
||||
singleLine = true,
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
modifier = Modifier
|
||||
.testTag(TestIds.AUTH_INPUT_PASSWORD_VISIBILITY),
|
||||
onClick = { passwordVisible = !passwordVisible }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||
contentDescription = stringResource(
|
||||
if (passwordVisible) {
|
||||
R.string.auth_input_password_visibility_hide
|
||||
} else {
|
||||
R.string.auth_input_password_visibility_show
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
isError = uiState is AuthUiState.Error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
if (uiState is AuthUiState.Error) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, bottom = 8.dp)
|
||||
.testTag(TestIds.AUTH_ERROR),
|
||||
text = stringResource(R.string.auth_error),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Кнопка Войти
|
||||
Button(
|
||||
onClick = onLoginClick,
|
||||
enabled = isButtonEnabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.testTag(TestIds.AUTH_LOGIN)
|
||||
) {
|
||||
Text(stringResource(R.string.auth_login))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Ссылки
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(
|
||||
modifier = Modifier.testTag(TestIds.AUTH_FORGOT_PASS),
|
||||
onClick = onForgotPasswordClick
|
||||
) {
|
||||
Text(stringResource(R.string.auth_forgot_pass))
|
||||
}
|
||||
TextButton(
|
||||
modifier = Modifier.testTag(TestIds.AUTH_CREATE_ACCOUNT),
|
||||
onClick = onCreateAccountClick
|
||||
) {
|
||||
Text(stringResource(R.string.auth_create_account))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is AuthUiState.Loading -> {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
|
||||
is AuthUiState.Success -> {
|
||||
Text(
|
||||
text = stringResource(R.string.auth_success),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.testTag(TestIds.AUTH_SUCCESS),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
app/src/main/java/ru/myitschool/work/ui/auth/AuthUiState.kt
Executable file
26
app/src/main/java/ru/myitschool/work/ui/auth/AuthUiState.kt
Executable file
@@ -0,0 +1,26 @@
|
||||
package ru.myitschool.work.ui.auth
|
||||
|
||||
/**
|
||||
* Этот файл не меняем!
|
||||
* AuthUiState — это "состояния" экрана авторизации.
|
||||
*
|
||||
* Что это значит:
|
||||
* Экран может быть в одном из четырёх состояний,
|
||||
* ViewModel (логика) будет переключать эти состояния,
|
||||
* дизайнеры — отображать соответствующий интерфейс.
|
||||
*
|
||||
* Состояния:
|
||||
* - Default -> Обычная форма входа (поля, кнопки, ссылки)
|
||||
* - Loading -> Показать крутящийся индикатор (ожидание)
|
||||
* - Error -> Показать сообщение об ошибке
|
||||
* - Success -> Показать сообщение об успешном входе
|
||||
*
|
||||
* Задача дизайнеров — в зависимости от состояния uiState
|
||||
* отрисовать нужный экран в Compose.
|
||||
*/
|
||||
sealed interface AuthUiState {
|
||||
data object Default : AuthUiState
|
||||
data object Loading : AuthUiState
|
||||
data object Error : AuthUiState
|
||||
data object Success : AuthUiState
|
||||
}
|
||||
98
app/src/main/java/ru/myitschool/work/ui/auth/AuthViewModel.kt
Executable file
98
app/src/main/java/ru/myitschool/work/ui/auth/AuthViewModel.kt
Executable file
@@ -0,0 +1,98 @@
|
||||
package ru.myitschool.work.ui.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Этот файл не меняем!
|
||||
*
|
||||
* AuthViewModel — это логика, которая управляет экраном авторизации.
|
||||
*
|
||||
* Дизайнерам важно понимать:
|
||||
* - ViewModel не рисует интерфейс, она только "сообщает", что нужно показать.
|
||||
* - Все нужные данные (email, пароль, состояние экрана, активность кнопки)
|
||||
* передаются в ваш Composable через параметры.
|
||||
* - Вам не нужно ничего здесь менять.
|
||||
*
|
||||
* Основная идея:
|
||||
* - Пользователь вводит email и пароль.
|
||||
* - ViewModel проверяет, можно ли активировать кнопку "Войти".
|
||||
* - При нажатии "Войти" ViewModel меняет состояние на Loading, потом Success или Error.
|
||||
*/
|
||||
class AuthViewModel : ViewModel() {
|
||||
|
||||
// uiState — текущее состояние экрана (Default / Loading / Error / Success)
|
||||
private val _uiState = MutableStateFlow<AuthUiState>(AuthUiState.Default)
|
||||
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
|
||||
|
||||
// email и password — значения, введённые пользователем в поля ввода
|
||||
private val _email = MutableStateFlow("")
|
||||
val email: StateFlow<String> = _email.asStateFlow()
|
||||
|
||||
private val _password = MutableStateFlow("")
|
||||
val password: StateFlow<String> = _password.asStateFlow()
|
||||
|
||||
// isButtonEnabled — активна ли кнопка "Войти"
|
||||
// (true, если оба поля не пустые)
|
||||
private val _isButtonEnabled = MutableStateFlow(false)
|
||||
val isButtonEnabled: StateFlow<Boolean> = _isButtonEnabled.asStateFlow()
|
||||
|
||||
/**
|
||||
* Функции onEmailChange / onPasswordChange
|
||||
* вызываются каждый раз, когда пользователь что-то вводит в поля.
|
||||
* Они обновляют значения email / passwor
|
||||
* и проверяют, можно ли активировать кнопку.
|
||||
*/
|
||||
fun onEmailChange(newEmail: String) {
|
||||
_email.value = newEmail
|
||||
validate()
|
||||
}
|
||||
|
||||
fun onPasswordChange(newPassword: String) {
|
||||
_password.value = newPassword
|
||||
validate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка, можно ли нажимать кнопку "Войти".
|
||||
* Если оба поля не пустые → isButtonEnabled = true
|
||||
*/
|
||||
private fun validate() {
|
||||
_isButtonEnabled.value = _email.value.isNotBlank() && _password.value.isNotBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* При нажатии на кнопку "Войти"
|
||||
* ViewModel запускает имитацию сетевого запроса:
|
||||
* 1. Меняет состояние на Loading -> дизайнеры должны показать индикатор.
|
||||
* 2. Через 1.5 секунды:
|
||||
* - Если логин "user@example.com" и пароль "1234" -> Success
|
||||
* - Иначе → Error
|
||||
*
|
||||
* Это имитация. В реальном приложении здесь будет запрос на сервер.
|
||||
*/
|
||||
fun onLoginClick() {
|
||||
_uiState.value = AuthUiState.Loading
|
||||
|
||||
// имитация запроса — здесь только логика, без дизайна
|
||||
viewModelScope.launch {
|
||||
delay(1500)
|
||||
_uiState.value = if (_email.value == "user@example.com" && _password.value == "1234") {
|
||||
AuthUiState.Success
|
||||
} else {
|
||||
AuthUiState.Error
|
||||
}
|
||||
}
|
||||
}
|
||||
fun resetToDefault() {
|
||||
_uiState.value = AuthUiState.Default
|
||||
}
|
||||
fun onForgotPasswordClick() { /* TODO */ }
|
||||
fun onCreateAccountClick() { /* TODO */ }
|
||||
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package ru.myitschool.work.ui.root
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ru.myitschool.work.ui.theme.WorkTheme
|
||||
|
||||
class RootActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
WorkTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
Screen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Screen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RootViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
when (val currentState = state) {
|
||||
is RootState.Content -> {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = currentState.userEntity.name,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
ButtonGetData(viewModel)
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
items(currentState.userEntity.bookingList) { book ->
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 16.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = book.roomName,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = book.time,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
var roomText by remember { mutableStateOf("") }
|
||||
var timeText by remember { mutableStateOf("") }
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
TextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = roomText,
|
||||
onValueChange = { roomText = it },
|
||||
label = { Text("Room") }
|
||||
)
|
||||
TextField(
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
value = timeText,
|
||||
onValueChange = { timeText = it },
|
||||
label = { Text("Time") }
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.onIntent(
|
||||
RootIntent.AddBook(room = roomText, time = timeText)
|
||||
)
|
||||
roomText = ""
|
||||
timeText = ""
|
||||
}
|
||||
) {
|
||||
Text(text = "Add")
|
||||
}
|
||||
if (currentState.errorText != null) {
|
||||
Text(text = currentState.errorText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
is RootState.Error -> {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = currentState.message)
|
||||
ButtonGetData(viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
is RootState.Loading -> {
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(64.dp))
|
||||
}
|
||||
}
|
||||
|
||||
is RootState.NotLoaded -> {
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ButtonGetData(viewModel)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ButtonGetData(
|
||||
viewModel: RootViewModel
|
||||
) {
|
||||
Button(onClick = { viewModel.onIntent(RootIntent.LoadData) }) {
|
||||
Text(text = "Get load")
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package ru.myitschool.work.ui.root
|
||||
|
||||
sealed interface RootIntent {
|
||||
data object LoadData: RootIntent
|
||||
data class AddBook(
|
||||
val room: String,
|
||||
val time: String
|
||||
): RootIntent
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package ru.myitschool.work.ui.root
|
||||
|
||||
import ru.myitschool.work.domain.entities.UserEntity
|
||||
|
||||
sealed interface RootState {
|
||||
data object NotLoaded: RootState
|
||||
data object Loading: RootState
|
||||
data class Error(val message: String): RootState
|
||||
data class Content(
|
||||
val userEntity: UserEntity,
|
||||
val errorText: String?,
|
||||
): RootState
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package ru.myitschool.work.ui.root
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.myitschool.work.data.AppRepository
|
||||
import ru.myitschool.work.domain.AddBookUseCase
|
||||
import ru.myitschool.work.domain.GetUserDataUseCase
|
||||
|
||||
class RootViewModel : ViewModel() {
|
||||
private val getUserDataUseCase by lazy {
|
||||
GetUserDataUseCase(
|
||||
repository = AppRepository
|
||||
)
|
||||
}
|
||||
private val addBookUseCase by lazy {
|
||||
AddBookUseCase(
|
||||
repository = AppRepository
|
||||
)
|
||||
}
|
||||
private val _uiState = MutableStateFlow<RootState>(RootState.NotLoaded)
|
||||
val uiState: StateFlow<RootState> = _uiState.asStateFlow()
|
||||
|
||||
fun onIntent(intent: RootIntent) {
|
||||
when (intent) {
|
||||
is RootIntent.LoadData -> loadData()
|
||||
is RootIntent.AddBook -> addBook(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadData() {
|
||||
viewModelScope.launch {
|
||||
_uiState.emit(RootState.Loading)
|
||||
getUserDataUseCase.invoke().fold(
|
||||
onSuccess = { value ->
|
||||
_uiState.emit(RootState.Content(userEntity = value, errorText = null))
|
||||
},
|
||||
onFailure = { error ->
|
||||
_uiState.emit(RootState.Error(error.message.orEmpty()))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addBook(intent: RootIntent.AddBook) {
|
||||
viewModelScope.launch {
|
||||
addBookUseCase.invoke(
|
||||
room = intent.room,
|
||||
time = intent.time
|
||||
).fold(
|
||||
onSuccess = {
|
||||
loadData()
|
||||
},
|
||||
onFailure = { error ->
|
||||
_uiState.update { state ->
|
||||
(state as? RootState.Content)?.copy(
|
||||
errorText = error.message
|
||||
) ?: state
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,17 @@
|
||||
<resources>
|
||||
<string name="app_name">Work</string>
|
||||
<string name="title_activity_root">RootActivity</string>
|
||||
|
||||
<string name="auth_title">Вход в систему</string>
|
||||
<string name="auth_input_email_title">Email</string>
|
||||
<string name="auth_input_email_placeholder">Введите email</string>
|
||||
<string name="auth_input_password_title">Пароль</string>
|
||||
<string name="auth_input_password_placeholder">Введите пароль</string>
|
||||
<string name="auth_input_password_visibility_show">Показать пароль</string>
|
||||
<string name="auth_input_password_visibility_hide">Скрыть пароль</string>
|
||||
<string name="auth_login">Войти</string>
|
||||
<string name="auth_error">Ошибка: неверный логин или пароль</string>
|
||||
<string name="auth_forgot_pass">Забыли пароль?</string>
|
||||
<string name="auth_create_account">Создать аккаунт</string>
|
||||
<string name="auth_success">Добро пожаловать!</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user