diff --git a/app/src/main/java/ru/otus/composehomework/ui/task1/Task1Screen.kt b/app/src/main/java/ru/otus/composehomework/ui/task1/Task1Screen.kt index baea737..f8d0062 100644 --- a/app/src/main/java/ru/otus/composehomework/ui/task1/Task1Screen.kt +++ b/app/src/main/java/ru/otus/composehomework/ui/task1/Task1Screen.kt @@ -1,6 +1,15 @@ package ru.otus.composehomework.ui.task1 -import androidx.compose.runtime.Composable +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp /** * Задание 1: Переписывание XML на Compose @@ -22,5 +31,31 @@ import androidx.compose.runtime.Composable */ @Composable fun Task1Screen() { - // TODO: Реализуйте экран на Compose + var welcomeText by remember { mutableStateOf("Добро пожаловать!") } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(32.dp) + ) { + Text( + text = welcomeText, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + + Image( + painter = painterResource(id = android.R.drawable.ic_dialog_info), + contentDescription = "Welcome", + modifier = Modifier.size(120.dp) + ) + + Button( + onClick = { welcomeText = "Кнопка нажата!" } + ) { + Text("Нажми меня") + } + } } diff --git a/app/src/main/java/ru/otus/composehomework/ui/task2/Task2Screen.kt b/app/src/main/java/ru/otus/composehomework/ui/task2/Task2Screen.kt index 9b625d4..c95c5eb 100644 --- a/app/src/main/java/ru/otus/composehomework/ui/task2/Task2Screen.kt +++ b/app/src/main/java/ru/otus/composehomework/ui/task2/Task2Screen.kt @@ -1,6 +1,15 @@ package ru.otus.composehomework.ui.task2 -import androidx.compose.runtime.Composable +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp /** * Задание 2: Добавление состояния @@ -20,5 +29,34 @@ import androidx.compose.runtime.Composable */ @Composable fun Task2Screen() { - // TODO: Реализуйте экран с состоянием счетчика + var count by remember { mutableStateOf(0) } + val welcomeText = "Кнопка нажата $count раз!" + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(32.dp) + ) { + Text( + text = welcomeText, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + + Image( + painter = painterResource(id = android.R.drawable.ic_dialog_info), + contentDescription = "Welcome", + modifier = Modifier.size(120.dp) + ) + + Text(text = "Счетчик: $count") + + Button( + onClick = { count++ } + ) { + Text("Нажми меня") + } + } } diff --git a/app/src/main/java/ru/otus/composehomework/ui/task3/Task3Screen.kt b/app/src/main/java/ru/otus/composehomework/ui/task3/Task3Screen.kt index 8cffe89..1a63a09 100644 --- a/app/src/main/java/ru/otus/composehomework/ui/task3/Task3Screen.kt +++ b/app/src/main/java/ru/otus/composehomework/ui/task3/Task3Screen.kt @@ -1,19 +1,46 @@ package ru.otus.composehomework.ui.task3 +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import ru.otus.composehomework.R /** * Задание 3: Подключение ViewModel и комплексное состояние (MVI паттерн) - * + * * Развивайте Task2Screen, подключив ViewModel для управления состоянием. - * + * * MVI (Model-View-Intent) паттерн: * - State - единое состояние UI (Task3State) - определено в Task3Contracts.kt * - Intent - действия пользователя (Task3Intent) - определено в Task3Contracts.kt * - ViewModel обрабатывает Intent и обновляет State - * + * * Все контракты MVI (State, Intent, UiState) находятся в файле Task3Contracts.kt - * + * * Требования: * 1. Скопируйте код из Task2Screen.kt (или начните с Task1Screen, если нужно) * 2. Подключите ViewModel через: val viewModel: Task3ViewModel = viewModel() @@ -33,7 +60,7 @@ import androidx.compose.runtime.Composable * - UiState.Success -> показать результат * - UiState.Error -> показать сообщение об ошибке * - UiState.ValidationError -> показать ошибки валидации для каждого поля - * + * * Подсказки: * - Используйте TextField с параметрами value и onValueChange * - Для ошибок валидации используйте isError и supportingText @@ -44,5 +71,247 @@ import androidx.compose.runtime.Composable */ @Composable fun Task3Screen() { - // TODO: Реализуйте экран с ViewModel и формой + val viewModel: Task3ViewModel = viewModel() + val state by viewModel.state.collectAsState() + + FeedbackFormLayout( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + header = { + FeedbackHeader() + }, + fields = { + FeedbackFields( + state = state, + onNameChange = { viewModel.handleIntent(Task3Intent.NameChanged(it)) }, + onEmailChange = { viewModel.handleIntent(Task3Intent.EmailChanged(it)) }, + onMessageChange = { viewModel.handleIntent(Task3Intent.MessageChanged(it)) } + ) + }, + actions = { + FeedbackActions( + uiState = state.uiState, + onSubmit = { viewModel.handleIntent(Task3Intent.SubmitClicked) }, + onClear = { viewModel.handleIntent(Task3Intent.ClearClicked) } + ) + }, + status = { + FeedbackStatus( + uiState = state.uiState, + onRetry = { viewModel.handleIntent(Task3Intent.RetryClicked) } + ) + } + ) +} + +/** + * Fixed: layout slot формы, который раскладывает элементы экрана по зонам. + * Рекомендация: такой подход позволяет вынести детали в отдельные composable + * и избегать "простыней" в коде. + */ +@Composable +private fun FeedbackFormLayout( + modifier: Modifier = Modifier, + header: @Composable () -> Unit, + fields: @Composable ColumnScope.() -> Unit, + actions: @Composable ColumnScope.() -> Unit, + status: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + header() + fields() + actions() + status() + } +} + +/** + * Заголовок формы. + */ +@Composable +private fun FeedbackHeader() { + Text( + text = "Форма обратной связи", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) +} + +/** + * Блок полей формы. Работа с вводом и ошибками валидации. + */ +@Composable +private fun ColumnScope.FeedbackFields( + state: Task3State, + onNameChange: (String) -> Unit, + onEmailChange: (String) -> Unit, + onMessageChange: (String) -> Unit +) { + TextField( + value = state.name, + onValueChange = onNameChange, + label = { Text("Имя") }, + placeholder = { Text(stringResource(R.string.name_hint)) }, + modifier = Modifier.fillMaxWidth(), + isError = state.validationErrors?.errors?.get(FieldType.NAME) != null, + supportingText = state.validationErrors?.errors?.get(FieldType.NAME)?.let { + { Text(it) } + }, + enabled = state.uiState !is UiState.Loading + ) + + TextField( + value = state.email, + onValueChange = onEmailChange, + label = { Text("Email") }, + placeholder = { Text(stringResource(R.string.email_hint)) }, + modifier = Modifier.fillMaxWidth(), + isError = state.validationErrors?.errors?.get(FieldType.EMAIL) != null, + supportingText = state.validationErrors?.errors?.get(FieldType.EMAIL)?.let { + { Text(it) } + }, + enabled = state.uiState !is UiState.Loading + ) + + TextField( + value = state.message, + onValueChange = onMessageChange, + label = { Text("Сообщение") }, + placeholder = { Text(stringResource(R.string.message_hint)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + isError = state.validationErrors?.errors?.get(FieldType.MESSAGE) != null, + supportingText = state.validationErrors?.errors?.get(FieldType.MESSAGE)?.let { + { Text(it) } + }, + enabled = state.uiState !is UiState.Loading + ) +} + +/** + * Блок с действиями - Отправка, очистка. + */ +@Composable +private fun ColumnScope.FeedbackActions( + uiState: UiState, + onSubmit: () -> Unit, + onClear: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onSubmit, + enabled = uiState !is UiState.Loading, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.submit)) + } + + OutlinedButton( + onClick = onClear, + enabled = uiState !is UiState.Loading, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.clear)) + } + } +} + +/** + * Визуализация состояния отправки формы (загрузка, успех, ошибка). + * Вся работа с UiState сконцентрирована в одном месте. + */ +@Composable +private fun ColumnScope.FeedbackStatus( + uiState: UiState, + onRetry: () -> Unit +) { + when (uiState) { + is UiState.Loading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + is UiState.Success -> { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Успешно!", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "Форма успешно отправлена!", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "ID: ${uiState.result.id}", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + is UiState.Error -> { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Ошибка", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + Text( + text = uiState.message, + style = MaterialTheme.typography.bodyMedium + ) + Button( + onClick = onRetry, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.retry)) + } + } + } + } + + is UiState.ValidationError -> { + // Ошибки валидации уже подсвечены возле полей ввода + } + + is UiState.Idle -> { + // Форма готова к заполнению + } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 26e74fc..c9469be 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,4 +13,5 @@ Отправить Повторить Загрузка... + Очистить