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 {
|
dependencies {
|
||||||
defaultComposeLibrary()
|
defaultComposeLibrary()
|
||||||
val ktor = "3.3.1"
|
implementation("androidx.compose.material:material-icons-extended:1.7.8")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.root.RootActivity"
|
android:name=".ui.RootActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:label="@string/title_activity_root">
|
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>
|
<resources>
|
||||||
<string name="app_name">Work</string>
|
<string name="app_name">Work</string>
|
||||||
<string name="title_activity_root">RootActivity</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>
|
</resources>
|
||||||
Reference in New Issue
Block a user