From 3746c87bb2fb0d3b3aea8fbf026f6e1033c20cf5 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:49:04 +0300 Subject: [PATCH 01/13] add: validation of date and time on HelpRequestCreateScreen --- ...kotlin-compiler-5509758565167498145.salive | 0 app/build.gradle | 8 + .../auth/presentation/AuthViewModel.kt | 4 + .../modules/auth/screens/LoginScreen.kt | 91 +++++----- .../screens/HelpRequestCreateScreen.kt | 159 +++++++++++++++--- 5 files changed, 195 insertions(+), 67 deletions(-) delete mode 100644 .kotlin/sessions/kotlin-compiler-5509758565167498145.salive diff --git a/.kotlin/sessions/kotlin-compiler-5509758565167498145.salive b/.kotlin/sessions/kotlin-compiler-5509758565167498145.salive deleted file mode 100644 index e69de29..0000000 diff --git a/app/build.gradle b/app/build.gradle index 5e205fe..75dc485 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -97,7 +97,15 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + testImplementation 'com.squareup.okhttp3:okhttp:4.12.0' + testImplementation 'org.robolectric:robolectric:4.9.2' + testImplementation 'io.mockk:mockk:1.13.5' + testImplementation 'io.mockk:mockk-android:1.13.5' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:5.12.0' + testImplementation 'org.mockito:mockito-inline:5.2.0' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/auth/presentation/AuthViewModel.kt b/app/src/main/java/com/example/goodroad/modules/auth/presentation/AuthViewModel.kt index 70ab736..51a4265 100644 --- a/app/src/main/java/com/example/goodroad/modules/auth/presentation/AuthViewModel.kt +++ b/app/src/main/java/com/example/goodroad/modules/auth/presentation/AuthViewModel.kt @@ -116,6 +116,10 @@ class AuthViewModel(private val context: Context) : ViewModel() { } } + fun setError(message: String) { + _error.value = message + } + private enum class AuthAction { LOGIN, REGISTER, diff --git a/app/src/main/java/com/example/goodroad/modules/auth/screens/LoginScreen.kt b/app/src/main/java/com/example/goodroad/modules/auth/screens/LoginScreen.kt index 1e5511c..0574111 100644 --- a/app/src/main/java/com/example/goodroad/modules/auth/screens/LoginScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/auth/screens/LoginScreen.kt @@ -1,6 +1,5 @@ package com.example.goodroad.modules.auth.screens -import android.util.Log import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -8,7 +7,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.example.goodroad.ui.theme.UrbanBrown import com.example.goodroad.modules.auth.presentation.AuthViewModel import com.example.goodroad.modules.auth.data.AuthResp @@ -19,7 +17,6 @@ import com.example.goodroad.ui.AuthStatusText import com.example.goodroad.ui.fields.* import com.example.goodroad.validation.PHONE_FORMAT_WARNING import com.example.goodroad.validation.formatPhoneForRequest -import com.example.goodroad.validation.isAllowedDigitsInput import com.example.goodroad.validation.isValidRussianPhoneDigits import com.example.goodroad.validation.normalizeRequiredRussianPhone @@ -32,19 +29,22 @@ fun LoginScreen( ) { var phone by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } - var phoneWarning by rememberSaveable { mutableStateOf(null) } - var errorText by rememberSaveable { mutableStateOf(null) } - val loginResult by viewModel.loginResult.observeAsState() val error by viewModel.error.observeAsState() val loading by viewModel.isLoading.observeAsState(initial = false) + val loginResult by viewModel.loginResult.observeAsState() LaunchedEffect(loginResult) { - Log.d("LOGIN_DEBUG", "RAW RESPONSE = $loginResult") - Log.d("LOGIN_DEBUG", "ROLE = ${loginResult?.user?.role}") + loginResult?.let { onLoginSuccess(it) } + } - loginResult?.let { resp -> - onLoginSuccess(resp) + val phoneValidation = remember(phone) { + when { + phone.isEmpty() -> PhoneValidation.Empty + !isValidRussianPhoneDigits(phone.trim()) -> PhoneValidation.InvalidFormat + phone.length > 11 -> PhoneValidation.InvalidFormat + phone.first() !in listOf('7', '8') -> PhoneValidation.InvalidFormat + else -> PhoneValidation.Valid(normalizeRequiredRussianPhone(phone)!!) } } @@ -55,20 +55,26 @@ fun LoginScreen( text = if (loading) "Входим..." else "Войти", enabled = !loading ) { - val phoneDigits = normalizeRequiredRussianPhone(phone) - - if (phoneDigits == null || password.isBlank()) { - phoneWarning = - if (phone.isNotBlank() && !isValidRussianPhoneDigits(phone.trim())) { - PHONE_FORMAT_WARNING - } else null - - errorText = "Заполните телефон и пароль" - return@PrimaryButton + when (phoneValidation) { + is PhoneValidation.Empty -> { + viewModel.setError("Введите номер телефона") + return@PrimaryButton + } + is PhoneValidation.InvalidFormat -> { + viewModel.setError("Введите корректный номер телефона") + return@PrimaryButton + } + is PhoneValidation.Valid -> { + if (password.isBlank()) { + viewModel.setError("Введите пароль") + return@PrimaryButton + } + viewModel.login( + formatPhoneForRequest(phoneValidation.phoneDigits), + password + ) + } } - - errorText = null - viewModel.login(formatPhoneForRequest(phoneDigits), password) } }, footer = { @@ -79,31 +85,27 @@ fun LoginScreen( ) } ) { - PhoneField( value = phone, - onValueChange = { value -> - when { - !isAllowedDigitsInput(value) -> phoneWarning = PHONE_FORMAT_WARNING - value.length > 11 -> phoneWarning = PHONE_FORMAT_WARNING - value.isNotEmpty() && value.first() !in listOf('7', '8') -> - phoneWarning = PHONE_FORMAT_WARNING - - else -> { - phone = value - phoneWarning = null - } - } + onValueChange = { + phone = it + viewModel.clearError() }, label = "Телефон", - warning = phoneWarning + warning = when (phoneValidation) { + is PhoneValidation.InvalidFormat -> PHONE_FORMAT_WARNING + else -> null + } ) Spacer(Modifier.height(12.dp)) PasswordField( value = password, - onValueChange = { password = it }, + onValueChange = { + password = it + viewModel.clearError() + }, label = "Пароль" ) @@ -119,11 +121,14 @@ fun LoginScreen( } AuthStatusText( - text = error ?: errorText, - onTimeout = { - errorText = null - viewModel.clearError() - } + text = error, + onTimeout = viewModel::clearError ) } +} + +sealed class PhoneValidation { + object Empty : PhoneValidation() + object InvalidFormat : PhoneValidation() + data class Valid(val phoneDigits: String) : PhoneValidation() } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/volunteer/screens/HelpRequestCreateScreen.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/HelpRequestCreateScreen.kt index 7cf5dfe..6246adb 100644 --- a/app/src/main/java/com/example/goodroad/modules/volunteer/screens/HelpRequestCreateScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/HelpRequestCreateScreen.kt @@ -29,6 +29,8 @@ import com.example.goodroad.modules.volunteer.presentation.VolunteerViewModel import com.example.goodroad.ui.UserDecor import com.example.goodroad.ui.buttons.PrimaryButton import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.validation.isValidRussianPhoneDigits +import java.time.LocalDateTime @Composable fun HelpRequestCreateScreen( @@ -52,6 +54,7 @@ fun HelpRequestCreateScreen( var meetingDateError by rememberSaveable { mutableStateOf(null) } var meetingTimeError by rememberSaveable { mutableStateOf(null) } var contactError by rememberSaveable { mutableStateOf(null) } + var socialNicknameError by rememberSaveable { mutableStateOf(null) } var commentError by rememberSaveable { mutableStateOf(null) } val scrollState = rememberScrollState() @@ -69,20 +72,51 @@ fun HelpRequestCreateScreen( "Обязательное поле" } else null - meetingDateError = if (meetingDate.length != 8) { - valid = false - "Введите дату полностью" - } else null + meetingDateError = when { + meetingDate.length != 8 -> { + valid = false + "Введите дату полностью (ДДММГГГГ)" + } + !isValidDate(meetingDate) -> { + valid = false + "Некорректная дата" + } + else -> null + } - meetingTimeError = if (meetingTime.length != 4) { - valid = false - "Введите время полностью" - } else null + meetingTimeError = when { + meetingTime.length != 4 -> { + valid = false + "Введите время полностью (ЧЧММ)" + } + !isValidTime(meetingTime) -> { + valid = false + "Некорректное время" + } + else -> null + } - contactError = if (contact.isBlank()) { - valid = false - "Обязательное поле" - } else null + if (meetingDateError == null && meetingTimeError == null) { + val dateTimeError = isDateTimeInPast(meetingDate, meetingTime) + if (dateTimeError != null) { + valid = false + meetingDateError = dateTimeError + } + } + + contactError = when { + contact.isBlank() -> { + valid = false + "Обязательное поле" + } + !isValidRussianPhoneDigits(contact.trim()) -> { + valid = false + "Введите корректный номер телефона" + } + else -> null + } + + socialNicknameError = null commentError = if (comment.isBlank()) { valid = false @@ -179,28 +213,29 @@ fun HelpRequestCreateScreen( Spacer(Modifier.height(12.dp)) - OutlinedTextField( + PhoneFieldForRequest( value = contact, onValueChange = { - contact = it.filter { ch -> - ch.isLetterOrDigit() || ch in "+@._-() " - } + contact = it contactError = null }, - label = { Text("Номер телефона *") }, + label = "Номер телефона *", isError = contactError != null, - supportingText = { contactError?.let { Text(it) } }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - singleLine = true + supportingText = contactError, + modifier = Modifier.fillMaxWidth() ) Spacer(Modifier.height(12.dp)) OutlinedTextField( value = socialNickname, - onValueChange = { socialNickname = it }, + onValueChange = { + socialNickname = it + socialNicknameError = null + }, label = { Text("Telegram / ВК / доп. контакт") }, + isError = socialNicknameError != null, + supportingText = { socialNicknameError?.let { Text(it) } }, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, singleLine = true @@ -245,14 +280,12 @@ fun HelpRequestCreateScreen( onClick = { if (!validate()) return@PrimaryButton - // Форматируем дату из ДДММГГГГ в ДД-ММ-ГГГГ val formattedDate = if (meetingDate.length == 8) { "${meetingDate.substring(0, 2)}-${meetingDate.substring(2, 4)}-${meetingDate.substring(4, 8)}" } else { meetingDate } - // Форматируем время из ЧЧММ в ЧЧ:ММ val formattedTime = if (meetingTime.length == 4) { "${meetingTime.substring(0, 2)}:${meetingTime.substring(2, 4)}" } else { @@ -277,6 +310,28 @@ fun HelpRequestCreateScreen( } } +@Composable +fun PhoneFieldForRequest( + value: String, + onValueChange: (String) -> Unit, + label: String, + isError: Boolean = false, + supportingText: String? = null, + modifier: Modifier = Modifier +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + isError = isError, + supportingText = { supportingText?.let { Text(it) } }, + modifier = modifier, + shape = MaterialTheme.shapes.large, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone) + ) +} + private object DateVisualTransformation : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { val digits = text.text.filter { it.isDigit() }.take(8) @@ -345,6 +400,62 @@ private object TimeVisualTransformation : VisualTransformation { } } +private fun isValidDate(dateStr: String): Boolean { + if (dateStr.length != 8) return false + return try { + val day = dateStr.substring(0, 2).toInt() + val month = dateStr.substring(2, 4).toInt() + val year = dateStr.substring(4, 8).toInt() + + if (year !in 2020..2100 || month !in 1..12) return false + + val daysInMonth = when (month) { + 1, 3, 5, 7, 8, 10, 12 -> 31 + 4, 6, 9, 11 -> 30 + 2 -> if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) 29 else 28 + else -> return false + } + + day in 1..daysInMonth + } catch (_: Exception) { + false + } +} + +private fun isValidTime(timeStr: String): Boolean { + if (timeStr.length != 4) return false + return try { + val hour = timeStr.substring(0, 2).toInt() + val minute = timeStr.substring(2, 4).toInt() + hour in 0..23 && minute in 0..59 + } catch (_: Exception) { + false + } +} + +private fun isDateTimeInPast(dateStr: String, timeStr: String): String? { + if (dateStr.length != 8 || timeStr.length != 4) return null + + return try { + val day = dateStr.substring(0, 2).toInt() + val month = dateStr.substring(2, 4).toInt() + val year = dateStr.substring(4, 8).toInt() + val hour = timeStr.substring(0, 2).toInt() + val minute = timeStr.substring(2, 4).toInt() + + val dateTime = LocalDateTime.of(year, month, day, hour, minute) + val now = LocalDateTime.now() + + when { + dateTime.isBefore(now) -> "Дата и время не могут быть в прошлом" + dateTime.isBefore(now.plusMinutes(5)) -> "Выберите время с запасом минимум 5 минут" + else -> null + } + } catch (_: Exception) { + null + } +} + private fun mapErrorToUserMessage(error: String?): String { val msg = error?.lowercase() ?: return "Произошла неизвестная ошибка" From 990a4f68e2ead19cf5f754707c6f7ae2509f7f70 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:34:52 +0300 Subject: [PATCH 02/13] refactor: design of ReviewForm --- .../maps/screens/ObstacleSelectScreen.kt | 102 ++++++----- .../review/screens/ReviewFormScreen.kt | 167 +++++++++++++++--- 2 files changed, 193 insertions(+), 76 deletions(-) diff --git a/app/src/main/java/com/example/goodroad/modules/maps/screens/ObstacleSelectScreen.kt b/app/src/main/java/com/example/goodroad/modules/maps/screens/ObstacleSelectScreen.kt index 1fbea17..a99d3f5 100644 --- a/app/src/main/java/com/example/goodroad/modules/maps/screens/ObstacleSelectScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/maps/screens/ObstacleSelectScreen.kt @@ -151,62 +151,66 @@ fun ObstacleSelectScreen( } if (checked) { - Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = Modifier.padding(start = 18.dp) + ) { + Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Максимальная допустимая тяжесть", - style = MaterialTheme.typography.bodyMedium, - color = UrbanBrown - ) + Text( + text = "Максимальная допустимая тяжесть", + style = MaterialTheme.typography.bodyMedium, + color = UrbanBrown + ) - val severityDescription = when (obstacle.obstacleType) { - "STAIRS" -> "1 — 1-3 ступеньки, 2 — 4-10 ступенек, 3 — более 10 ступенек" - "CURB" -> "1 — маленький бордюр, 2 — обычный бордюр, 3 — высокий бордюр" - "ROAD_SLOPE" -> "1 — незначительный подъём, 2 — заметный подъём, 3 — крутой подъём" - "POTHOLES" -> "1 — маленькая яма, 2 — обычная яма, 3 — большая яма" - "SAND", "GRAVEL" -> "1 — укатанный, 2 — немного рыхлый, 3 — сильно рыхлый" - else -> "1 — слабая, 2 — средняя, 3 — сильная" - } + val severityDescription = when (obstacle.obstacleType) { + "STAIRS" -> "1 — 1-3 ступеньки, 2 — 4-10 ступенек, 3 — более 10 ступенек" + "CURB" -> "1 — маленький бордюр, 2 — обычный бордюр, 3 — высокий бордюр" + "ROAD_SLOPE" -> "1 — незначительный подъём, 2 — заметный подъём, 3 — крутой подъём" + "POTHOLES" -> "1 — маленькая яма, 2 — обычная яма, 3 — большая яма" + "SAND", "GRAVEL" -> "1 — укатанный, 2 — немного рыхлый, 3 — сильно рыхлый" + else -> "1 — слабая, 2 — средняя, 3 — сильная" + } - Text( - text = severityDescription, - style = MaterialTheme.typography.bodySmall, - color = UrbanBrown.copy(alpha = 0.7f), - fontSize = 11.sp - ) + Text( + text = severityDescription, + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown.copy(alpha = 0.7f), + fontSize = 11.sp + ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - (1..3).forEach { value -> - FilterChip( - selected = severity == value, - onClick = { - severityMap[obstacle.obstacleType] = value - mapsViewModel.clearMessages() - }, - label = { - Text( - text = value.toString(), - style = MaterialTheme.typography.bodyLarge - ) - }, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = SafeGreen.copy(alpha = 0.18f), - selectedLabelColor = SafeGreen, - containerColor = BackgroundLight, - labelColor = UrbanBrown - ), - border = FilterChipDefaults.filterChipBorder( - enabled = true, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + (1..3).forEach { value -> + FilterChip( selected = severity == value, - borderColor = if (severity == value) SafeGreen else BorderWarm, - selectedBorderColor = SafeGreen + onClick = { + severityMap[obstacle.obstacleType] = value + mapsViewModel.clearMessages() + }, + label = { + Text( + text = value.toString(), + style = MaterialTheme.typography.bodyLarge + ) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = SafeGreen.copy(alpha = 0.18f), + selectedLabelColor = SafeGreen, + containerColor = BackgroundLight, + labelColor = UrbanBrown + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = severity == value, + borderColor = if (severity == value) SafeGreen else BorderWarm, + selectedBorderColor = SafeGreen + ) ) - ) + } } } } diff --git a/app/src/main/java/com/example/goodroad/modules/review/screens/ReviewFormScreen.kt b/app/src/main/java/com/example/goodroad/modules/review/screens/ReviewFormScreen.kt index 8faf23a..53929c6 100644 --- a/app/src/main/java/com/example/goodroad/modules/review/screens/ReviewFormScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/review/screens/ReviewFormScreen.kt @@ -22,6 +22,10 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Photo import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface @@ -38,10 +42,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.example.goodroad.modules.review.data.ReviewAddress import com.example.goodroad.modules.review.data.ReviewCardResp import com.example.goodroad.modules.review.data.ReviewObstacle @@ -59,6 +65,7 @@ import com.example.goodroad.ui.theme.BorderWarm import com.example.goodroad.ui.theme.SafeGreen import com.example.goodroad.ui.theme.TextPrimary import com.example.goodroad.ui.theme.UrbanBrown +import com.example.goodroad.ui.theme.WhiteSoft import com.example.goodroad.validation.COMMENT_MAX_LENGTH import com.example.goodroad.validation.COORDINATE_MAX_LENGTH import com.example.goodroad.validation.PLACE_NAME_MAX_LENGTH @@ -82,9 +89,6 @@ fun ReviewFormScreen( val isEdit = initialReview != null val reviewKey = initialReview?.id ?: "new" - //var placeName by remember(reviewKey) { mutableStateOf(initialReview?.address?.placeName ?: "") } - //var latitude by remember(reviewKey) { mutableStateOf(initialReview?.latitude?.toString() ?: "") } - //var longitude by remember(reviewKey) { mutableStateOf(initialReview?.longitude?.toString() ?: "") } var rating by remember(reviewKey) { mutableStateOf(initialReview?.rating?.toInt()) } var comment by remember(reviewKey) { mutableStateOf(initialReview?.comment ?: "") } var formError by remember(reviewKey) { mutableStateOf(null) } @@ -105,15 +109,28 @@ fun ReviewFormScreen( } } + val obstacleSelected = remember(reviewKey) { + mutableStateMapOf().apply { + ReviewObstacleTypes.forEach { type -> + val initialSeverity = initialReview?.obstacles + ?.firstOrNull { it.obstacleType == type } + ?.severity + ?.toInt() + ?: 0 + put(type, initialSeverity > 0) + } + } + } + val obstacleSeverities = remember(reviewKey) { mutableStateMapOf().apply { ReviewObstacleTypes.forEach { type -> - val current = initialReview?.obstacles + val initialSeverity = initialReview?.obstacles ?.firstOrNull { it.obstacleType == type } ?.severity ?.toInt() ?: 0 - put(type, current) + put(type, if (initialSeverity > 0) initialSeverity else 1) } } } @@ -240,30 +257,120 @@ fun ReviewFormScreen( ) Spacer(Modifier.height(8.dp)) Text( - text = "0 — нет такого препятствия, 1 — слабая тяжесть, 2 — средняя тяжесть, 3 — сильная тяжесть.", + text = "Если чекбокс не выбран — препятствия нет. При выборе укажите тяжесть: 1 — слабая, 2 — средняя, 3 — сильная.", style = MaterialTheme.typography.bodySmall, color = UrbanBrown ) Spacer(Modifier.height(4.dp)) Text( - text = "Хотя бы у одного препятствия тяжесть должна быть больше 0.", + text = "Хотя бы у одного препятствия должна быть выбрана тяжесть.", style = MaterialTheme.typography.bodySmall, color = UrbanBrown ) ReviewObstacleTypes.forEach { type -> - Spacer(Modifier.height(12.dp)) - Text( - text = obstacleLabel(type), - style = MaterialTheme.typography.bodyLarge, - color = TextPrimary - ) - Spacer(Modifier.height(6.dp)) - SeveritySelector( - value = obstacleSeverities[type] ?: 0, - range = 0..3, - onValueChange = { obstacleSeverities[type] = it } - ) + val selected = obstacleSelected[type] == true + val severity = obstacleSeverities[type] ?: 1 + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = selected, + onCheckedChange = { isChecked -> + obstacleSelected[type] = isChecked + if (!isChecked) { + obstacleSeverities[type] = 1 + } + formError = null + }, + colors = CheckboxDefaults.colors( + checkedColor = UrbanBrown, + uncheckedColor = UrbanBrown, + checkmarkColor = WhiteSoft + ) + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Text( + text = obstacleLabel(type), + style = MaterialTheme.typography.bodyLarge, + color = UrbanBrown + ) + } + + if (selected) { + Column( + modifier = Modifier.padding(start = 18.dp) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Максимальная допустимая тяжесть", + style = MaterialTheme.typography.bodyMedium, + color = UrbanBrown + ) + + val severityDescription = when (type) { + "STAIRS" -> "1 — 1-3 ступеньки, 2 — 4-10 ступенек, 3 — более 10 ступенек" + "CURB" -> "1 — маленький бордюр, 2 — обычный бордюр, 3 — высокий бордюр" + "ROAD_SLOPE" -> "1 — незначительный подъём, 2 — заметный подъём, 3 — крутой подъём" + "POTHOLES" -> "1 — маленькая яма, 2 — обычная яма, 3 — большая яма" + "SAND", "GRAVEL" -> "1 — укатанный, 2 — немного рыхлый, 3 — сильно рыхлый" + else -> "1 — слабая, 2 — средняя, 3 — сильная" + } + + Text( + text = severityDescription, + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown.copy(alpha = 0.7f), + fontSize = 11.sp + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + (1..3).forEach { value -> + FilterChip( + selected = severity == value, + onClick = { + obstacleSeverities[type] = value + formError = null + }, + label = { + Text( + text = value.toString(), + style = MaterialTheme.typography.bodyLarge + ) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = SafeGreen.copy(alpha = 0.18f), + selectedLabelColor = SafeGreen, + containerColor = BackgroundLight, + labelColor = UrbanBrown + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = severity == value, + borderColor = if (severity == value) SafeGreen else BorderWarm, + selectedBorderColor = SafeGreen + ) + ) + } + } + } + } + } } Spacer(Modifier.height(20.dp)) @@ -359,6 +466,7 @@ fun ReviewFormScreen( latitude = latitude, longitude = longitude, rating = rating, + obstacleSelected = obstacleSelected, obstacleSeverities = obstacleSeverities ) @@ -381,17 +489,21 @@ fun ReviewFormScreen( formError = null reviewsViewModel.clearMessages() + val obstacles = ReviewObstacleTypes.map { type -> + ReviewObstacle( + obstacleType = type, + severity = if (obstacleSelected[type] == true) { + (obstacleSeverities[type] ?: 1).toShort() + } else 0 + ) + } + val request = UpsertReviewReq( latitude = lat, longitude = lon, address = generatedAddress, rating = rating!!.toShort(), - obstacles = ReviewObstacleTypes.map { type -> - ReviewObstacle( - obstacleType = type, - severity = (obstacleSeverities[type] ?: 0).toShort() - ) - }, + obstacles = obstacles, comment = comment.trim().ifBlank { null }, photoUrls = photoUrls.filter { it.isNotBlank() } ) @@ -418,6 +530,7 @@ private fun validateReviewForm( latitude: String, longitude: String, rating: Int?, + obstacleSelected: Map, obstacleSeverities: Map ): String? { val lat = latitude.trim().replace(',', '.').toDoubleOrNull() @@ -431,8 +544,8 @@ private fun validateReviewForm( if (rating == null) { return "Поставьте оценку отзыву" } - if (obstacleSeverities.values.none { it > 0 }) { - return "Укажите тяжесть хотя бы для одного препятствия" + if (obstacleSelected.values.none { it }) { + return "Выберите хотя бы одно препятствие" } return null } From 8b0bb70c460c893e715393626d1abcd4e204583a Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:44:00 +0300 Subject: [PATCH 03/13] delete: comments on screens --- .../main/java/com/example/goodroad/ui/ReviewComponents.kt | 5 ----- .../java/com/example/goodroad/ui/map/PlaceInfoBottomSheet.kt | 5 ----- 2 files changed, 10 deletions(-) diff --git a/app/src/main/java/com/example/goodroad/ui/ReviewComponents.kt b/app/src/main/java/com/example/goodroad/ui/ReviewComponents.kt index c7d0844..94693c0 100644 --- a/app/src/main/java/com/example/goodroad/ui/ReviewComponents.kt +++ b/app/src/main/java/com/example/goodroad/ui/ReviewComponents.kt @@ -229,11 +229,6 @@ fun ReviewPhotosStrip( onRemove: ((String) -> Unit)? = null ) { if (photoUrls.isEmpty()) { - Text( - text = "Фото не добавлены", - style = MaterialTheme.typography.bodyMedium, - color = UrbanBrown - ) return } diff --git a/app/src/main/java/com/example/goodroad/ui/map/PlaceInfoBottomSheet.kt b/app/src/main/java/com/example/goodroad/ui/map/PlaceInfoBottomSheet.kt index 70fe9b3..28b6484 100644 --- a/app/src/main/java/com/example/goodroad/ui/map/PlaceInfoBottomSheet.kt +++ b/app/src/main/java/com/example/goodroad/ui/map/PlaceInfoBottomSheet.kt @@ -87,11 +87,6 @@ fun PlaceInfoBottomSheet( fontWeight = FontWeight.Bold, color = UrbanBrown ) - Text( - text = "Вы можете оставить отзыв об этом месте", - fontSize = 14.sp, - color = UrbanBrown.copy(alpha = 0.7f) - ) } } From ed3d2572f991157b219e49ed3189ab31db75b26f Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:09:54 +0300 Subject: [PATCH 04/13] add: box with all statuses when click on your status --- .../rewards/screens/RewardsShopScreen.kt | 457 +++++++++++------- 1 file changed, 281 insertions(+), 176 deletions(-) diff --git a/app/src/main/java/com/example/goodroad/modules/rewards/screens/RewardsShopScreen.kt b/app/src/main/java/com/example/goodroad/modules/rewards/screens/RewardsShopScreen.kt index d01ad0f..705eee6 100644 --- a/app/src/main/java/com/example/goodroad/modules/rewards/screens/RewardsShopScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/rewards/screens/RewardsShopScreen.kt @@ -1,9 +1,11 @@ package com.example.goodroad.modules.rewards.screens +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.History @@ -12,18 +14,23 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.goodroad.modules.rewards.presentation.RewardsViewModel import com.example.goodroad.modules.rewards.data.RewardOffer import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.ui.theme.SafeGreen +import com.example.goodroad.ui.theme.SafeRoute import com.example.goodroad.ui.theme.TextPrimary import com.example.goodroad.ui.theme.UrbanBrown import com.example.goodroad.ui.theme.SurfaceWarm -import com.example.goodroad.ui.theme.SafeGreen +import com.example.goodroad.ui.theme.WhiteSoft import kotlinx.coroutines.delay @Composable @@ -41,6 +48,8 @@ fun RewardsShopScreen( val loading = state.loading val error = state.error + var showStatusPopup by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { viewModel.loadAccount() viewModel.loadRewards() @@ -55,217 +64,313 @@ fun RewardsShopScreen( } } - Surface( - modifier = Modifier.fillMaxSize(), - color = BackgroundLight + Box( + modifier = Modifier.fillMaxSize() ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp) + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) ) { - Text( - text = "Награды и баллы", - style = MaterialTheme.typography.headlineLarge, - color = TextPrimary, - modifier = Modifier.weight(1f) - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Награды и баллы", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary, + modifier = Modifier.weight(1f) + ) - Row { - IconButton(onClick = onNavigateToHistory) { - Icon( - imageVector = Icons.Default.History, - contentDescription = "История", - tint = UrbanBrown.copy(alpha = 0.7f) - ) - } - IconButton(onClick = onNavigateToLeaderboard) { - Icon( - imageVector = Icons.Default.Leaderboard, - contentDescription = "Лидеры", - tint = UrbanBrown.copy(alpha = 0.7f) - ) - } - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = "Назад", - tint = UrbanBrown.copy(alpha = 0.7f) - ) + Row { + IconButton(onClick = onNavigateToHistory) { + Icon( + imageVector = Icons.Default.History, + contentDescription = "История", + tint = UrbanBrown.copy(alpha = 0.7f) + ) + } + IconButton(onClick = onNavigateToLeaderboard) { + Icon( + imageVector = Icons.Default.Leaderboard, + contentDescription = "Лидеры", + tint = UrbanBrown.copy(alpha = 0.7f) + ) + } + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Назад", + tint = UrbanBrown.copy(alpha = 0.7f) + ) + } } } - } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - if (account != null) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = TextPrimary.copy(alpha = 0.08f) - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + if (account != null) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = TextPrimary.copy(alpha = 0.08f) + ) ) { - Column { - Text( - text = "Мои баллы", - fontSize = 18.sp, - color = UrbanBrown.copy(alpha = 1.5f) - ) - Text( - text = "${account.balance}", - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - color = UrbanBrown.copy(alpha = 1.7f) - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Мои баллы", + fontSize = 18.sp, + color = UrbanBrown.copy(alpha = 1.5f) + ) + Text( + text = "${account.balance}", + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = UrbanBrown.copy(alpha = 1.7f) + ) + Text( + text = account.title, + fontSize = 16.sp, + color = UrbanBrown.copy(alpha = 1.5f), + modifier = Modifier.clickable { + showStatusPopup = true + } + ) + } Text( - text = account.title, - fontSize = 16.sp, - color = UrbanBrown.copy(alpha = 1.5f) + text = "⭐", + fontSize = 48.sp ) } - Text( - text = "⭐", - fontSize = 48.sp - ) + } + } else if (loading) { + Box( + modifier = Modifier.fillMaxWidth().height(100.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } } - } else if (loading) { - Box( - modifier = Modifier.fillMaxWidth().height(100.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - var selectedSort by remember { mutableStateOf("price_asc") } + var selectedSort by remember { mutableStateOf("price_asc") } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilterChip( - selected = selectedSort == "price_asc", - onClick = { - selectedSort = "price_asc" - viewModel.loadRewards(sort = "price_asc") - }, - label = { - Text( - text = "По возрастанию", - fontSize = 16.sp + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = selectedSort == "price_asc", + onClick = { + selectedSort = "price_asc" + viewModel.loadRewards(sort = "price_asc") + }, + label = { + Text( + text = "По возрастанию", + fontSize = 16.sp + ) + }, + modifier = Modifier.weight(1f), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = UrbanBrown.copy(alpha = 0.15f), + selectedLabelColor = UrbanBrown.copy(alpha = 0.8f), + containerColor = Color.Transparent, + labelColor = UrbanBrown.copy(alpha = 0.6f) ) - }, - modifier = Modifier.weight(1f), - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = UrbanBrown.copy(alpha = 0.15f), - selectedLabelColor = UrbanBrown.copy(alpha = 0.8f), - containerColor = Color.Transparent, - labelColor = UrbanBrown.copy(alpha = 0.6f) ) - ) - FilterChip( - selected = selectedSort == "price_desc", - onClick = { - selectedSort = "price_desc" - viewModel.loadRewards(sort = "price_desc") - }, - label = { - Text( - text = "По убыванию", - fontSize = 16.sp + FilterChip( + selected = selectedSort == "price_desc", + onClick = { + selectedSort = "price_desc" + viewModel.loadRewards(sort = "price_desc") + }, + label = { + Text( + text = "По убыванию", + fontSize = 16.sp + ) + }, + modifier = Modifier.weight(1f), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = UrbanBrown.copy(alpha = 0.15f), + selectedLabelColor = UrbanBrown.copy(alpha = 0.8f), + containerColor = Color.Transparent, + labelColor = UrbanBrown.copy(alpha = 0.6f) ) - }, - modifier = Modifier.weight(1f), - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = UrbanBrown.copy(alpha = 0.15f), - selectedLabelColor = UrbanBrown.copy(alpha = 0.8f), - containerColor = Color.Transparent, - labelColor = UrbanBrown.copy(alpha = 0.6f) ) - ) - } + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - when { - loading && rewards.isEmpty() -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() + when { + loading && rewards.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } } - } - error != null && rewards.isEmpty() -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("❌", fontSize = 48.sp) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = error, - color = MaterialTheme.colorScheme.error - ) - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { viewModel.loadRewards() }) { - Text("Повторить") + error != null && rewards.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("❌", fontSize = 48.sp) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { viewModel.loadRewards() }) { + Text("Повторить") + } } } } - } - rewards.isEmpty() -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("🎁", fontSize = 48.sp) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Пока нет доступных наград", - fontWeight = FontWeight.Medium, - color = TextPrimary - ) - Text( - text = "Загляните позже!", - fontSize = 12.sp, - color = UrbanBrown.copy(alpha = 0.5f) - ) + rewards.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("🎁", fontSize = 48.sp) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Пока нет доступных наград", + fontWeight = FontWeight.Medium, + color = TextPrimary + ) + Text( + text = "Загляните позже!", + fontSize = 12.sp, + color = UrbanBrown.copy(alpha = 0.5f) + ) + } + } + } + + else -> { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(rewards) { reward -> + RewardCard( + reward = reward, + onClick = { onRewardClick(reward) } + ) + } } } } + } + } + + if (showStatusPopup && account != null) { + StatusPopup( + status = account.title, + onDismiss = { showStatusPopup = false } + ) + } + } +} - else -> { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(12.dp) +@Composable +private fun StatusPopup( + status: String, + onDismiss: () -> Unit +) { + val allStatuses = listOf( + "Новичок GoodRoad" to "0 - 99 ⭐", + "Разведчик тротуаров" to "100 - 499 ⭐", + "Проводник добра" to "500 - 999 ⭐", + "Герой района" to "1000 - 1499 ⭐", + "Хранитель маршрутов" to "1500 - 1999 ⭐", + "Навигатор перемен" to "2000 - 2499 ⭐", + "Мастер доступного города" to "2500 - 2999 ⭐", + "Легенда добрых маршрутов" to "3000+ ⭐" + ) + + Popup( + alignment = Alignment.Center, + onDismissRequest = onDismiss, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.85f) + .wrapContentHeight() + .clip(RoundedCornerShape(16.dp)), + colors = CardDefaults.cardColors( + containerColor = SafeRoute.copy(alpha = 0.92f) ), + elevation = CardDefaults.cardElevation( + defaultElevation = 12.dp + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(16.dp)) + + allStatuses.forEach { (statusName, range) -> + val isCurrent = statusName == status + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .then( + if (isCurrent) { + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(WhiteSoft.copy(alpha = 0.15f)) + .padding(8.dp) + } else Modifier + ), + horizontalArrangement = Arrangement.SpaceBetween ) { - items(rewards) { reward -> - RewardCard( - reward = reward, - onClick = { onRewardClick(reward) } - ) - } + Text( + text = statusName, + fontSize = if (isCurrent) 17.sp else 15.sp, + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.SemiBold, + color = if (isCurrent) WhiteSoft else WhiteSoft.copy(alpha = 0.8f) + ) + Text( + text = range, + fontSize = if (isCurrent) 17.sp else 15.sp, + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.SemiBold, + color = if (isCurrent) WhiteSoft else WhiteSoft.copy(alpha = 0.8f) + ) } } + + Spacer(modifier = Modifier.height(8.dp)) } } } From 18e270d56e24d5c55c1c0bf25869c315b4a9dae7 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:53:04 +0300 Subject: [PATCH 05/13] add: screen for display coupons --- .../modules/rewards/screens/CouponsScreen.kt | 146 ++++++++++++++++++ .../rewards/screens/RewardsShopScreen.kt | 9 +- .../modules/user/navigation/UserNav.kt | 9 +- 3 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/example/goodroad/modules/rewards/screens/CouponsScreen.kt diff --git a/app/src/main/java/com/example/goodroad/modules/rewards/screens/CouponsScreen.kt b/app/src/main/java/com/example/goodroad/modules/rewards/screens/CouponsScreen.kt new file mode 100644 index 0000000..a11ef96 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/rewards/screens/CouponsScreen.kt @@ -0,0 +1,146 @@ +package com.example.goodroad.modules.rewards.screens + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.ui.theme.TextPrimary +import com.example.goodroad.ui.theme.UrbanBrown +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue + +@Composable +fun CouponsScreen( + onBack: () -> Unit +) { + var selectedFilter by remember { mutableStateOf("active") } + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Мои купоны", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary + ) + + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Назад", + tint = UrbanBrown.copy(alpha = 0.7f) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = selectedFilter == "active", + onClick = { selectedFilter = "active" }, + label = { + Text( + text = "Активные", + fontSize = 16.sp + ) + }, + modifier = Modifier.weight(1f), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = UrbanBrown.copy(alpha = 0.2f), + selectedLabelColor = UrbanBrown, + containerColor = Color.Transparent, + labelColor = UrbanBrown.copy(alpha = 0.6f) + ) + ) + FilterChip( + selected = selectedFilter == "inactive", + onClick = { selectedFilter = "inactive" }, + label = { + Text( + text = "Неактивные", + fontSize = 16.sp + ) + }, + modifier = Modifier.weight(1f), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = UrbanBrown.copy(alpha = 0.2f), + selectedLabelColor = UrbanBrown, + containerColor = Color.Transparent, + labelColor = UrbanBrown.copy(alpha = 0.6f) + ) + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "🎫", + fontSize = 64.sp + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "У вас пока нет купонов", + fontSize = 20.sp, + fontWeight = FontWeight.Medium, + color = TextPrimary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Зарабатывайте баллы и обменивайте их на награды!", + fontSize = 16.sp, + color = UrbanBrown.copy(alpha = 0.7f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/rewards/screens/RewardsShopScreen.kt b/app/src/main/java/com/example/goodroad/modules/rewards/screens/RewardsShopScreen.kt index 705eee6..f0c432a 100644 --- a/app/src/main/java/com/example/goodroad/modules/rewards/screens/RewardsShopScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/rewards/screens/RewardsShopScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Leaderboard +import androidx.compose.material.icons.filled.CardGiftcard import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -25,7 +26,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.goodroad.modules.rewards.presentation.RewardsViewModel import com.example.goodroad.modules.rewards.data.RewardOffer import com.example.goodroad.ui.theme.BackgroundLight -import com.example.goodroad.ui.theme.SafeGreen import com.example.goodroad.ui.theme.SafeRoute import com.example.goodroad.ui.theme.TextPrimary import com.example.goodroad.ui.theme.UrbanBrown @@ -39,6 +39,7 @@ fun RewardsShopScreen( onRewardClick: (RewardOffer) -> Unit, onNavigateToHistory: () -> Unit, onNavigateToLeaderboard: () -> Unit, + onNavigateToCoupons: () -> Unit, onBack: () -> Unit ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -103,10 +104,10 @@ fun RewardsShopScreen( tint = UrbanBrown.copy(alpha = 0.7f) ) } - IconButton(onClick = onBack) { + IconButton(onClick = onNavigateToCoupons) { Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = "Назад", + imageVector = Icons.Default.CardGiftcard, + contentDescription = "Купоны", tint = UrbanBrown.copy(alpha = 0.7f) ) } diff --git a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt index 00bc9b8..31aa399 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt @@ -47,6 +47,7 @@ import com.example.goodroad.modules.tasks.screens.TasksScreen import com.example.goodroad.modules.tasks.data.TaskViewDto import com.example.goodroad.modules.tasks.screens.CompletedTasksHistoryScreen import com.example.goodroad.modules.tasks.screens.TaskExecutionScreen +import com.example.goodroad.modules.rewards.screens.CouponsScreen enum class BottomTab { MAP, @@ -73,7 +74,8 @@ enum class OverlayScreen { LEADERBOARD, TASKS_SHOP, TASK_DETAIL, - TASKS_HISTORY + TASKS_HISTORY, + COUPONS } @Composable @@ -294,6 +296,10 @@ fun UserNav( } } + OverlayScreen.COUPONS -> CouponsScreen( + onBack = { overlayScreen = OverlayScreen.REWARDS_SHOP } + ) + OverlayScreen.OBSTACLES -> ObstacleSelectScreen( mapsViewModel = mapsViewModel, onBackToProfile = { overlayScreen = OverlayScreen.NONE }, @@ -334,6 +340,7 @@ fun UserNav( }, onNavigateToHistory = { overlayScreen = OverlayScreen.REWARDS_HISTORY }, onNavigateToLeaderboard = { overlayScreen = OverlayScreen.LEADERBOARD }, + onNavigateToCoupons = { overlayScreen = OverlayScreen.COUPONS }, onBack = { overlayScreen = OverlayScreen.NONE } ) From e5ee8f43f40b972dc8365082de427fb90adc8340 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:42:44 +0300 Subject: [PATCH 06/13] add: logic om CouponsScreen and error mapping on RewarDetailsScreen --- .../screens => }/RewardDetailsScreen.kt | 123 +++++- .../modules/rewards/data/RewardsApi.kt | 3 + .../modules/rewards/data/RewardsModels.kt | 19 + .../modules/rewards/data/RewardsRepository.kt | 4 + .../rewards/presentation/RewardsViewModel.kt | 58 ++- .../modules/rewards/screens/CouponsScreen.kt | 388 +++++++++++++++--- .../rewards/screens/RewardsShopScreen.kt | 3 +- .../modules/user/navigation/UserNav.kt | 1 + 8 files changed, 515 insertions(+), 84 deletions(-) rename app/src/main/java/{com/example/goodroad/modules/rewards/screens => }/RewardDetailsScreen.kt (77%) diff --git a/app/src/main/java/com/example/goodroad/modules/rewards/screens/RewardDetailsScreen.kt b/app/src/main/java/RewardDetailsScreen.kt similarity index 77% rename from app/src/main/java/com/example/goodroad/modules/rewards/screens/RewardDetailsScreen.kt rename to app/src/main/java/RewardDetailsScreen.kt index 8e51a00..3e57116 100644 --- a/app/src/main/java/com/example/goodroad/modules/rewards/screens/RewardDetailsScreen.kt +++ b/app/src/main/java/RewardDetailsScreen.kt @@ -19,10 +19,10 @@ import com.example.goodroad.ui.theme.BackgroundLight import com.example.goodroad.ui.theme.UrbanBrown import com.example.goodroad.ui.theme.SafeGreen import com.example.goodroad.ui.theme.AlertRed -import androidx.compose.ui.graphics.Color import com.example.goodroad.ui.buttons.PrimaryButton import com.example.goodroad.ui.theme.TextPrimary + @Composable fun RewardDetailScreen( viewModel: RewardsViewModel, @@ -31,16 +31,15 @@ fun RewardDetailScreen( onBack: () -> Unit ) { val state by viewModel.state.collectAsStateWithLifecycle() + var showConfirmation by remember { mutableStateOf(false) } var isPurchasing by remember { mutableStateOf(false) } val account = state.account - LaunchedEffect(state.purchaseResult) { - if (state.purchaseResult != null && isPurchasing) { + LaunchedEffect(state.error, state.purchaseResult) { + if (state.error != null || state.purchaseResult != null) { isPurchasing = false - onPurchaseComplete() - viewModel.clearPurchaseResult() } } @@ -53,6 +52,7 @@ fun RewardDetailScreen( .fillMaxSize() .padding(24.dp) ) { + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -84,11 +84,14 @@ fun RewardDetailScreen( colors = CardDefaults.cardColors( containerColor = UrbanBrown.copy(alpha = 0.06f) ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + elevation = CardDefaults.cardElevation( + defaultElevation = 0.dp + ) ) { Column( modifier = Modifier.padding(24.dp) ) { + Text( text = "ПАРТНЁР", fontSize = 14.sp, @@ -96,7 +99,9 @@ fun RewardDetailScreen( color = UrbanBrown.copy(alpha = 1.5f), letterSpacing = 1.sp ) + Spacer(modifier = Modifier.height(4.dp)) + Text( text = reward.partnerName, fontSize = 20.sp, @@ -120,7 +125,9 @@ fun RewardDetailScreen( color = UrbanBrown.copy(alpha = 1.5f), letterSpacing = 1.sp ) + Spacer(modifier = Modifier.height(8.dp)) + Text( text = reward.description ?: "Нет описания", fontSize = 16.sp, @@ -142,6 +149,7 @@ fun RewardDetailScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { + Text( text = "СТОИМОСТЬ", fontSize = 14.sp, @@ -149,6 +157,7 @@ fun RewardDetailScreen( color = UrbanBrown.copy(alpha = 1.6f), letterSpacing = 1.sp ) + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) @@ -157,6 +166,7 @@ fun RewardDetailScreen( text = "⭐", fontSize = 28.sp ) + Text( text = "${reward.price}", fontSize = 32.sp, @@ -171,6 +181,7 @@ fun RewardDetailScreen( Spacer(modifier = Modifier.height(24.dp)) if (account != null) { + Card( modifier = Modifier .fillMaxWidth() @@ -178,7 +189,9 @@ fun RewardDetailScreen( colors = CardDefaults.cardColors( containerColor = SafeGreen.copy(alpha = 0.08f) ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + elevation = CardDefaults.cardElevation( + defaultElevation = 0.dp + ) ) { Row( modifier = Modifier @@ -187,12 +200,14 @@ fun RewardDetailScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { + Text( text = "Ваш баланс", fontSize = 16.sp, fontWeight = FontWeight.Medium, color = UrbanBrown ) + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) @@ -201,6 +216,7 @@ fun RewardDetailScreen( text = "⭐", fontSize = 20.sp ) + Text( text = "${account.balance}", fontSize = 24.sp, @@ -216,6 +232,7 @@ fun RewardDetailScreen( val canBuy = account.balance >= reward.price if (isPurchasing) { + Box( modifier = Modifier .fillMaxWidth() @@ -228,23 +245,37 @@ fun RewardDetailScreen( color = UrbanBrown ) } + } else { + PrimaryButton( - text = if (canBuy) "Купить за ${reward.price} баллов" else "Недостаточно баллов", - backgroundColor = if (canBuy) SafeGreen else UrbanBrown, + text = if (canBuy) { + "Купить за ${reward.price} баллов" + } else { + "Недостаточно баллов" + }, + backgroundColor = if (canBuy) { + SafeGreen + } else { + UrbanBrown + }, modifier = Modifier .fillMaxWidth() .height(56.dp), enabled = canBuy, - onClick = { showConfirmation = true } + onClick = { + showConfirmation = true + } ) } } if (state.error != null) { + Spacer(modifier = Modifier.height(12.dp)) + Text( - text = state.error ?: "Ошибка", + text = mapRewardError(state.error), color = AlertRed, fontSize = 14.sp, modifier = Modifier.fillMaxWidth() @@ -254,8 +285,12 @@ fun RewardDetailScreen( } if (showConfirmation) { + AlertDialog( - onDismissRequest = { showConfirmation = false }, + onDismissRequest = { + showConfirmation = false + }, + title = { Text( text = "Подтверждение покупки", @@ -263,12 +298,14 @@ fun RewardDetailScreen( color = UrbanBrown ) }, + text = { Text( text = "Вы уверены, что хотите купить \"${reward.title}\" за ${reward.price} баллов?", color = UrbanBrown.copy(alpha = 0.8f) ) }, + confirmButton = { TextButton( onClick = { @@ -277,15 +314,69 @@ fun RewardDetailScreen( viewModel.purchaseReward(reward.id) } ) { - Text("Купить", color = SafeGreen, fontWeight = FontWeight.SemiBold) + Text( + text = "Купить", + color = SafeGreen, + fontWeight = FontWeight.SemiBold + ) } }, + dismissButton = { - TextButton(onClick = { showConfirmation = false }) { - Text("Отмена", color = UrbanBrown) + TextButton( + onClick = { + showConfirmation = false + } + ) { + Text( + text = "Отмена", + color = UrbanBrown + ) } }, + shape = RoundedCornerShape(16.dp) ) } -} \ No newline at end of file +} + + +private fun mapRewardError(error: String?): String { + return when (error) { + "REWARD_OUT_OF_STOCK" -> + "Эта награда закончилась" + + "REWARD_NOT_FOUND" -> + "Награда не найдена" + + "REWARD_PURCHASE_NOT_CONFIRMED" -> + "Подтвердите покупку награды" + + "USER_REWARD_NOT_FOUND" -> + "Купленная награда не найдена" + + "USER_REWARD_NOT_ACTIVE" -> + "Эта награда уже неактивна" + + "USER_REWARD_EXPIRED" -> + "Срок действия награды истёк" + + "INSUFFICIENT_POINTS" -> + "Недостаточно баллов для покупки" + + "USER_PHONE_NOT_FOUND" -> + "Пользователь не найден" + + "REWARD_ID_INVALID" -> + "Некорректный идентификатор награды" + + "REWARD_PRICE_FILTER_INVALID" -> + "Указан некорректный диапазон стоимости" + + "REWARD_SORT_INVALID" -> + "Некорректный тип сортировки" + + else -> + error ?: "Не удалось выполнить покупку" + } +} diff --git a/app/src/main/java/com/example/goodroad/modules/rewards/data/RewardsApi.kt b/app/src/main/java/com/example/goodroad/modules/rewards/data/RewardsApi.kt index b06462e..aa6e2e6 100644 --- a/app/src/main/java/com/example/goodroad/modules/rewards/data/RewardsApi.kt +++ b/app/src/main/java/com/example/goodroad/modules/rewards/data/RewardsApi.kt @@ -29,4 +29,7 @@ interface RewardsApi { @GET("rewards/leaderboard") suspend fun getLeaderboard(): List + + @GET("rewards/me") + suspend fun getCurrentUserRewards(): UserRewardsResp } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/rewards/data/RewardsModels.kt b/app/src/main/java/com/example/goodroad/modules/rewards/data/RewardsModels.kt index fe226ba..211957b 100644 --- a/app/src/main/java/com/example/goodroad/modules/rewards/data/RewardsModels.kt +++ b/app/src/main/java/com/example/goodroad/modules/rewards/data/RewardsModels.kt @@ -41,4 +41,23 @@ data class PointTransaction( val details: String?, val balanceAfter: Int, val createdAt: String +) + +data class UserRewardsResp( + val active: List, + val inactive: List +) + +data class UserRewardView( + val id: String?, + val offerId: String, + val partnerName: String, + val title: String, + val rewardType: String, + val code: String, + val pricePaid: Int, + val status: String, + val purchasedAt: String?, + val expiresAt: String?, + val redeemedAt: String? ) \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/rewards/data/RewardsRepository.kt b/app/src/main/java/com/example/goodroad/modules/rewards/data/RewardsRepository.kt index e66d792..499ba9b 100644 --- a/app/src/main/java/com/example/goodroad/modules/rewards/data/RewardsRepository.kt +++ b/app/src/main/java/com/example/goodroad/modules/rewards/data/RewardsRepository.kt @@ -30,4 +30,8 @@ class RewardsRepository( suspend fun getLeaderboard(): List { return api.getLeaderboard() } + + suspend fun getCurrentUserRewards(): UserRewardsResp { + return api.getCurrentUserRewards() + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/rewards/presentation/RewardsViewModel.kt b/app/src/main/java/com/example/goodroad/modules/rewards/presentation/RewardsViewModel.kt index f506ee6..fe746d5 100644 --- a/app/src/main/java/com/example/goodroad/modules/rewards/presentation/RewardsViewModel.kt +++ b/app/src/main/java/com/example/goodroad/modules/rewards/presentation/RewardsViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import com.example.goodroad.modules.rewards.data.UserRewardView class RewardsViewModel( private val repository: RewardsRepository @@ -24,7 +25,9 @@ class RewardsViewModel( val history: List = emptyList(), val leaderboard: List = emptyList(), val purchaseResult: PurchaseResponse? = null, - val error: String? = null + val error: String? = null, + val activeCoupons: List = emptyList(), + val inactiveCoupons: List = emptyList(), ) private val _state = MutableStateFlow(UiState()) @@ -95,13 +98,60 @@ class RewardsViewModel( repository.purchaseReward(rewardId) }.onSuccess { _state.value = _state.value.copy( - purchaseResult = it + purchaseResult = it, + error = null ) + loadAccount() loadHistory() - }.onFailure { + loadCurrentUserRewards() + }.onFailure { throwable -> + + val errorCode = if (throwable is retrofit2.HttpException) { + runCatching { + val errorBody = throwable + .response() + ?.errorBody() + ?.string() + + if (!errorBody.isNullOrBlank()) { + org.json.JSONObject(errorBody) + .optString("code") + .takeIf { it.isNotBlank() } + } else { + null + } + }.getOrNull() + } else { + null + } + _state.value = _state.value.copy( - error = it.message + error = errorCode ?: throwable.message + ) + } + } + } + + fun loadCurrentUserRewards() { + viewModelScope.launch { + runCatching { + _state.value = _state.value.copy( + loading = true, + error = null + ) + + repository.getCurrentUserRewards() + }.onSuccess { + _state.value = _state.value.copy( + loading = false, + activeCoupons = it.active, + inactiveCoupons = it.inactive + ) + }.onFailure { throwable -> + _state.value = _state.value.copy( + loading = false, + error = throwable.message ) } } diff --git a/app/src/main/java/com/example/goodroad/modules/rewards/screens/CouponsScreen.kt b/app/src/main/java/com/example/goodroad/modules/rewards/screens/CouponsScreen.kt index a11ef96..a07b5d5 100644 --- a/app/src/main/java/com/example/goodroad/modules/rewards/screens/CouponsScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/rewards/screens/CouponsScreen.kt @@ -1,5 +1,6 @@ package com.example.goodroad.modules.rewards.screens +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -9,36 +10,56 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.goodroad.modules.rewards.data.UserRewardView +import com.example.goodroad.modules.rewards.presentation.RewardsViewModel import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.ui.theme.SafeRoute +import com.example.goodroad.ui.theme.SurfaceWarm import com.example.goodroad.ui.theme.TextPrimary import com.example.goodroad.ui.theme.UrbanBrown -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter @Composable fun CouponsScreen( + viewModel: RewardsViewModel, onBack: () -> Unit ) { - var selectedFilter by remember { mutableStateOf("active") } + val state by viewModel.state.collectAsStateWithLifecycle() + + val coupons = state.activeCoupons + state.inactiveCoupons + + LaunchedEffect(Unit) { + viewModel.loadCurrentUserRewards() + } Surface( modifier = Modifier.fillMaxSize(), @@ -49,6 +70,7 @@ fun CouponsScreen( .fillMaxSize() .padding(24.dp) ) { + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -69,78 +91,318 @@ fun CouponsScreen( } } + Spacer(modifier = Modifier.height(24.dp)) + + when { + state.loading && coupons.isEmpty() -> { + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + state.error != null && coupons.isEmpty() -> { + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "❌", + fontSize = 48.sp + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = state.error ?: "Не удалось загрузить купоны", + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = { + viewModel.loadCurrentUserRewards() + } + ) { + Text("Повторить") + } + } + } + } + + coupons.isEmpty() -> { + EmptyCoupons() + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items( + items = coupons, + key = { it.id ?: "${it.offerId}-${it.code}" } + ) { coupon -> + CouponCard(coupon = coupon) + } + } + } + } + } + } +} + +@Composable +private fun EmptyCoupons() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "🎫", + fontSize = 64.sp + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "У вас пока нет купонов", + fontSize = 20.sp, + fontWeight = FontWeight.Medium, + color = TextPrimary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Зарабатывайте баллы и обменивайте их на награды!", + fontSize = 16.sp, + color = UrbanBrown.copy(alpha = 0.7f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +private fun CouponCard( + coupon: UserRewardView +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = SurfaceWarm + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 2.dp + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top ) { - FilterChip( - selected = selectedFilter == "active", - onClick = { selectedFilter = "active" }, - label = { - Text( - text = "Активные", - fontSize = 16.sp - ) - }, - modifier = Modifier.weight(1f), - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = UrbanBrown.copy(alpha = 0.2f), - selectedLabelColor = UrbanBrown, - containerColor = Color.Transparent, - labelColor = UrbanBrown.copy(alpha = 0.6f) + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = coupon.title, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = TextPrimary ) - ) - FilterChip( - selected = selectedFilter == "inactive", - onClick = { selectedFilter = "inactive" }, - label = { - Text( - text = "Неактивные", - fontSize = 16.sp - ) - }, - modifier = Modifier.weight(1f), - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = UrbanBrown.copy(alpha = 0.2f), - selectedLabelColor = UrbanBrown, - containerColor = Color.Transparent, - labelColor = UrbanBrown.copy(alpha = 0.6f) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = coupon.partnerName, + fontSize = 16.sp, + color = UrbanBrown.copy(alpha = 1.5f) ) - ) - } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(6.dp)) - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { Text( - text = "🎫", - fontSize = 64.sp + text = coupon.rewardType, + fontSize = 14.sp, + color = UrbanBrown.copy(alpha = 0.7f) ) - Spacer(modifier = Modifier.height(16.dp)) + } + + Column( + horizontalAlignment = Alignment.End + ) { Text( - text = "У вас пока нет купонов", - fontSize = 20.sp, - fontWeight = FontWeight.Medium, - color = TextPrimary + text = "⭐", + fontSize = 24.sp ) - Spacer(modifier = Modifier.height(8.dp)) + Text( - text = "Зарабатывайте баллы и обменивайте их на награды!", - fontSize = 16.sp, - color = UrbanBrown.copy(alpha = 0.7f), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center + text = "${coupon.pricePaid}", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = UrbanBrown.copy(alpha = 1.7f) ) } } + + Spacer(modifier = Modifier.height(14.dp)) + + CouponCode( + code = coupon.code + ) + + Spacer(modifier = Modifier.height(12.dp)) + + if (coupon.expiresAt != null) { + Text( + text = "Действует до: ${formatDate(coupon.expiresAt)}", + fontSize = 14.sp, + color = UrbanBrown.copy(alpha = 0.75f) + ) + } + + if (coupon.redeemedAt != null) { + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "Использован: ${formatDate(coupon.redeemedAt)}", + fontSize = 14.sp, + color = UrbanBrown.copy(alpha = 0.75f) + ) + } + + if (coupon.purchasedAt != null) { + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "Куплен: ${formatDate(coupon.purchasedAt)}", + fontSize = 14.sp, + color = UrbanBrown.copy(alpha = 0.55f) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + StatusBadge( + status = coupon.status + ) + } + } +} + +@Composable +private fun CouponCode( + code: String +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background( + TextPrimary.copy(alpha = 0.06f) + ) + .padding( + horizontal = 12.dp, + vertical = 10.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = "Код купона", + fontSize = 12.sp, + color = UrbanBrown.copy(alpha = 0.6f) + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = code, + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + color = TextPrimary + ) } + + IconButton( + onClick = { + } + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Скопировать код", + tint = UrbanBrown.copy(alpha = 0.7f) + ) + } + } +} + +@Composable +private fun StatusBadge( + status: String +) { + val text = when (status) { + "ACTIVE" -> "Активен" + "EXPIRED" -> "Истёк" + "REDEEMED" -> "Использован" + else -> status + } + + val background = when (status) { + "ACTIVE" -> SafeRoute.copy(alpha = 0.15f) + else -> TextPrimary.copy(alpha = 0.08f) + } + + Row( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(background) + .padding( + horizontal = 10.dp, + vertical = 6.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = UrbanBrown + ) + } +} + +private fun formatDate(value: String): String { + return runCatching { + val instant = Instant.parse(value) + + DateTimeFormatter + .ofPattern("dd.MM.yyyy HH:mm") + .withZone(ZoneId.systemDefault()) + .format(instant) + }.getOrElse { + value } } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/rewards/screens/RewardsShopScreen.kt b/app/src/main/java/com/example/goodroad/modules/rewards/screens/RewardsShopScreen.kt index f0c432a..a926c9d 100644 --- a/app/src/main/java/com/example/goodroad/modules/rewards/screens/RewardsShopScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/rewards/screens/RewardsShopScreen.kt @@ -443,4 +443,5 @@ private fun RewardCard( } } } -} \ No newline at end of file +} + diff --git a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt index 31aa399..9c81bfb 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt @@ -297,6 +297,7 @@ fun UserNav( } OverlayScreen.COUPONS -> CouponsScreen( + viewModel = rewardsViewModel, onBack = { overlayScreen = OverlayScreen.REWARDS_SHOP } ) From 0133ef5e6c7715bc7ac16a421a516ce3fe52b561 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:55:14 +0300 Subject: [PATCH 07/13] delete unused buttons --- .../tasks/screens/TaskExecutionScreen.kt | 69 +++---------------- .../modules/tasks/screens/TaskScreen.kt | 10 --- 2 files changed, 8 insertions(+), 71 deletions(-) diff --git a/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskExecutionScreen.kt b/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskExecutionScreen.kt index ab87da2..17983b4 100644 --- a/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskExecutionScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskExecutionScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -23,18 +22,15 @@ import com.example.goodroad.ui.theme.TextPrimary import com.example.goodroad.ui.theme.UrbanBrown import com.example.goodroad.ui.theme.SurfaceWarm import com.example.goodroad.ui.theme.SafeGreen -import com.example.goodroad.ui.theme.AlertRed @Composable fun TaskExecutionScreen( task: TaskViewDto, onTargetComplete: (TargetViewDto) -> Unit, - onComplete: () -> Unit, onBack: () -> Unit ) { var targetsState by remember { mutableStateOf(task.targets.toList()) } val completedTargets = targetsState.count { it.done } - val allCompleted = completedTargets >= task.targetCount Surface( modifier = Modifier.fillMaxSize(), @@ -90,31 +86,6 @@ fun TaskExecutionScreen( fontSize = 16.sp ) } - - if (allCompleted) { - Surface( - shape = MaterialTheme.shapes.small, - color = SafeGreen.copy(alpha = 0.2f) - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = null, - tint = SafeGreen, - modifier = Modifier.size(16.dp) - ) - Text( - text = "Готово к завершению", - fontSize = 12.sp, - color = SafeGreen - ) - } - } - } } Spacer(modifier = Modifier.height(16.dp)) @@ -167,16 +138,6 @@ fun TaskExecutionScreen( ) } } - - Spacer(modifier = Modifier.height(24.dp)) - - PrimaryButton( - text = "Завершить задание", - backgroundColor = if (allCompleted) SafeGreen else UrbanBrown.copy(alpha = 0.5f), - modifier = Modifier.fillMaxWidth(), - enabled = allCompleted, - onClick = onComplete - ) } } } @@ -191,7 +152,9 @@ private fun TargetItem( Card( modifier = Modifier .fillMaxWidth() - .clickable(enabled = !isCompleted) { if (!isCompleted) onComplete() }, + .clickable(enabled = !isCompleted) { + if (!isCompleted) onComplete() + }, colors = CardDefaults.cardColors( containerColor = SurfaceWarm ) @@ -231,29 +194,13 @@ private fun TargetItem( text = target.title, fontSize = 20.sp, fontWeight = FontWeight.Medium, - color = if (isCompleted) UrbanBrown.copy(alpha = 0.6f) else TextPrimary, + color = if (isCompleted) { + UrbanBrown.copy(alpha = 0.6f) + } else { + TextPrimary + }, lineHeight = 26.sp ) - - if (target.latitude != null && target.longitude != null) { - Spacer(modifier = Modifier.height(6.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.LocationOn, - contentDescription = null, - tint = AlertRed, - modifier = Modifier.size(16.dp) - ) - Text( - text = "Отметить на карте", - fontSize = 14.sp, - color = AlertRed - ) - } - } } } diff --git a/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskScreen.kt b/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskScreen.kt index 8e4bf0f..a8e754f 100644 --- a/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskScreen.kt @@ -164,7 +164,6 @@ private fun TaskCard( onClick: () -> Unit ) { val isCompleted = task.completedCount >= task.targetCount - val isInProgress = task.completedCount > 0 && !isCompleted Card( modifier = Modifier @@ -230,15 +229,6 @@ private fun TaskCard( } Spacer(modifier = Modifier.width(16.dp)) - - if (!isCompleted) { - PrimaryButton( - text = if (isInProgress) "Продолжить" else "Начать", - backgroundColor = SafeGreen, - modifier = Modifier.width(120.dp), - onClick = onClick - ) - } } } } \ No newline at end of file From d5966cd56b8ea249d526443f410d772e22492884 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:45:53 +0300 Subject: [PATCH 08/13] feat: add navigation to review screen --- .../review/screens/ReviewFormScreen.kt | 393 ++++++++++++++---- .../tasks/screens/TaskExecutionScreen.kt | 39 +- .../modules/user/navigation/UserNav.kt | 45 +- .../example/goodroad/ui/fields/PlainField.kt | 4 +- 4 files changed, 370 insertions(+), 111 deletions(-) diff --git a/app/src/main/java/com/example/goodroad/modules/review/screens/ReviewFormScreen.kt b/app/src/main/java/com/example/goodroad/modules/review/screens/ReviewFormScreen.kt index 53929c6..bb46e4c 100644 --- a/app/src/main/java/com/example/goodroad/modules/review/screens/ReviewFormScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/review/screens/ReviewFormScreen.kt @@ -15,11 +15,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Photo import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox @@ -81,6 +83,7 @@ fun ReviewFormScreen( initialPlaceName: String = "", initialLatitude: String = "", initialLongitude: String = "", + isLocationLocked: Boolean = false, onBack: () -> Unit, onSaved: () -> Unit ) { @@ -89,16 +92,26 @@ fun ReviewFormScreen( val isEdit = initialReview != null val reviewKey = initialReview?.id ?: "new" - var rating by remember(reviewKey) { mutableStateOf(initialReview?.rating?.toInt()) } - var comment by remember(reviewKey) { mutableStateOf(initialReview?.comment ?: "") } - var formError by remember(reviewKey) { mutableStateOf(null) } + var rating by remember(reviewKey) { + mutableStateOf(initialReview?.rating?.toInt()) + } + + var comment by remember(reviewKey) { + mutableStateOf(initialReview?.comment ?: "") + } + + var formError by remember(reviewKey) { + mutableStateOf(null) + } var placeName by remember(reviewKey) { mutableStateOf(initialReview?.address?.placeName ?: initialPlaceName) } + var latitude by remember(reviewKey) { mutableStateOf(initialReview?.latitude?.toString() ?: initialLatitude) } + var longitude by remember(reviewKey) { mutableStateOf(initialReview?.longitude?.toString() ?: initialLongitude) } @@ -117,6 +130,7 @@ fun ReviewFormScreen( ?.severity ?.toInt() ?: 0 + put(type, initialSeverity > 0) } } @@ -130,7 +144,11 @@ fun ReviewFormScreen( ?.severity ?.toInt() ?: 0 - put(type, if (initialSeverity > 0) initialSeverity else 1) + + put( + type, + if (initialSeverity > 0) initialSeverity else 1 + ) } } } @@ -139,7 +157,10 @@ fun ReviewFormScreen( val isPhotoUploading by reviewsViewModel.isPhotoUploading val serverError by reviewsViewModel.errorMessage - var isPreparingSubmit by remember(reviewKey) { mutableStateOf(false) } + var isPreparingSubmit by remember(reviewKey) { + mutableStateOf(false) + } + val submitInProgress = isSubmitting || isPreparingSubmit LaunchedEffect(isSubmitting) { @@ -170,27 +191,36 @@ fun ReviewFormScreen( .verticalScroll(rememberScrollState()) .padding(24.dp) ) { + Text( - text = if (isEdit) "Редактирование отзыва" else "Новый отзыв", + text = if (isEdit) { + "Редактирование отзыва" + } else { + "Новый отзыв" + }, style = MaterialTheme.typography.headlineLarge, color = TextPrimary ) Spacer(Modifier.height(20.dp)) + /* + * Адрес и координаты. + * + * Если отзыв создаётся из выполнения цели, + * isLocationLocked = true и менять эти поля нельзя. + */ + PlainField( value = placeName, - onValueChange = { placeName = it }, + onValueChange = { + if (!isLocationLocked) { + placeName = it + } + }, label = "Название места", - maxLength = PLACE_NAME_MAX_LENGTH - ) - - Spacer(Modifier.height(8.dp)) - - Text( - text = "Название места можно не заполнять.", - style = MaterialTheme.typography.bodySmall, - color = UrbanBrown + maxLength = PLACE_NAME_MAX_LENGTH, + readOnly = isLocationLocked ) Spacer(Modifier.height(16.dp)) @@ -199,40 +229,83 @@ fun ReviewFormScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { + PlainField( value = latitude, - onValueChange = { latitude = it }, + onValueChange = { + if (!isLocationLocked) { + latitude = it + } + }, label = "Широта", maxLength = COORDINATE_MAX_LENGTH, modifier = Modifier.weight(1f), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal) + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal + ), + readOnly = isLocationLocked ) PlainField( value = longitude, - onValueChange = { longitude = it }, + onValueChange = { + if (!isLocationLocked) { + longitude = it + } + }, label = "Долгота", maxLength = COORDINATE_MAX_LENGTH, modifier = Modifier.weight(1f), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal) + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal + ), + readOnly = isLocationLocked ) } Spacer(Modifier.height(12.dp)) - Text( - text = "Адрес будет определен автоматически по введенным координатам.", - style = MaterialTheme.typography.bodySmall, - color = UrbanBrown - ) + if (isLocationLocked) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + tint = UrbanBrown, + modifier = Modifier + .padding(top = 2.dp) + .size(18.dp) + ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.width(8.dp)) - Text( - text = "Координаты должны быть в числовом формате. Можно использовать точку или запятую.", - style = MaterialTheme.typography.bodySmall, - color = UrbanBrown - ) + Text( + text = "Адрес и координаты нельзя изменить, " + + "поскольку отзыв создаётся для конкретной " + + "цели задания. Местоположение уже задано " + + "этой целью и должно оставаться неизменным.", + style = MaterialTheme.typography.bodySmall, + color = TextPrimary + ) + } + } else { + Text( + text = "Адрес будет определен автоматически по введенным координатам.", + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Координаты должны быть в числовом формате. " + + "Можно использовать точку или запятую.", + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown + ) + } Spacer(Modifier.height(20.dp)) @@ -241,7 +314,9 @@ fun ReviewFormScreen( style = MaterialTheme.typography.titleMedium, color = UrbanBrown ) + Spacer(Modifier.height(8.dp)) + SeveritySelector( value = rating, range = 1..5, @@ -255,13 +330,19 @@ fun ReviewFormScreen( style = MaterialTheme.typography.titleMedium, color = UrbanBrown ) + Spacer(Modifier.height(8.dp)) + Text( - text = "Если чекбокс не выбран — препятствия нет. При выборе укажите тяжесть: 1 — слабая, 2 — средняя, 3 — сильная.", + text = "Если чекбокс не выбран — препятствия нет. " + + "При выборе укажите тяжесть: 1 — слабая, " + + "2 — средняя, 3 — сильная.", style = MaterialTheme.typography.bodySmall, color = UrbanBrown ) + Spacer(Modifier.height(4.dp)) + Text( text = "Хотя бы у одного препятствия должна быть выбрана тяжесть.", style = MaterialTheme.typography.bodySmall, @@ -269,6 +350,7 @@ fun ReviewFormScreen( ) ReviewObstacleTypes.forEach { type -> + val selected = obstacleSelected[type] == true val severity = obstacleSeverities[type] ?: 1 @@ -277,17 +359,21 @@ fun ReviewFormScreen( .fillMaxWidth() .padding(vertical = 8.dp) ) { + Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { + Checkbox( checked = selected, onCheckedChange = { isChecked -> obstacleSelected[type] = isChecked + if (!isChecked) { obstacleSeverities[type] = 1 } + formError = null }, colors = CheckboxDefaults.colors( @@ -307,9 +393,11 @@ fun ReviewFormScreen( } if (selected) { + Column( modifier = Modifier.padding(start = 18.dp) ) { + Spacer(modifier = Modifier.height(8.dp)) Text( @@ -319,12 +407,28 @@ fun ReviewFormScreen( ) val severityDescription = when (type) { - "STAIRS" -> "1 — 1-3 ступеньки, 2 — 4-10 ступенек, 3 — более 10 ступенек" - "CURB" -> "1 — маленький бордюр, 2 — обычный бордюр, 3 — высокий бордюр" - "ROAD_SLOPE" -> "1 — незначительный подъём, 2 — заметный подъём, 3 — крутой подъём" - "POTHOLES" -> "1 — маленькая яма, 2 — обычная яма, 3 — большая яма" - "SAND", "GRAVEL" -> "1 — укатанный, 2 — немного рыхлый, 3 — сильно рыхлый" - else -> "1 — слабая, 2 — средняя, 3 — сильная" + "STAIRS" -> + "1 — 1-3 ступеньки, 2 — 4-10 ступенек, " + + "3 — более 10 ступенек" + + "CURB" -> + "1 — маленький бордюр, 2 — обычный бордюр, " + + "3 — высокий бордюр" + + "ROAD_SLOPE" -> + "1 — незначительный подъём, " + + "2 — заметный подъём, 3 — крутой подъём" + + "POTHOLES" -> + "1 — маленькая яма, 2 — обычная яма, " + + "3 — большая яма" + + "SAND", "GRAVEL" -> + "1 — укатанный, 2 — немного рыхлый, " + + "3 — сильно рыхлый" + + else -> + "1 — слабая, 2 — средняя, 3 — сильная" } Text( @@ -340,7 +444,9 @@ fun ReviewFormScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp) ) { + (1..3).forEach { value -> + FilterChip( selected = severity == value, onClick = { @@ -354,17 +460,24 @@ fun ReviewFormScreen( ) }, colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = SafeGreen.copy(alpha = 0.18f), + selectedContainerColor = + SafeGreen.copy(alpha = 0.18f), selectedLabelColor = SafeGreen, containerColor = BackgroundLight, labelColor = UrbanBrown ), - border = FilterChipDefaults.filterChipBorder( - enabled = true, - selected = severity == value, - borderColor = if (severity == value) SafeGreen else BorderWarm, - selectedBorderColor = SafeGreen - ) + border = + FilterChipDefaults.filterChipBorder( + enabled = true, + selected = severity == value, + borderColor = + if (severity == value) { + SafeGreen + } else { + BorderWarm + }, + selectedBorderColor = SafeGreen + ) ) } } @@ -391,7 +504,9 @@ fun ReviewFormScreen( color = UrbanBrown ) }, - textStyle = MaterialTheme.typography.bodyLarge.copy(color = TextPrimary), + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = TextPrimary + ), singleLine = false, minLines = 1, maxLines = 5, @@ -409,7 +524,9 @@ fun ReviewFormScreen( Spacer(Modifier.height(16.dp)) OutlinedButton( - onClick = { photoPickerLauncher.launch("image/*") }, + onClick = { + photoPickerLauncher.launch("image/*") + }, modifier = Modifier .fillMaxWidth() .height(56.dp), @@ -419,14 +536,21 @@ fun ReviewFormScreen( ), border = BorderStroke(1.dp, BorderWarm) ) { + Icon( imageVector = Icons.Filled.Photo, contentDescription = null, tint = UrbanBrown ) + Spacer(Modifier.width(12.dp)) + Text( - text = if (isPhotoUploading) "Загружаем фото..." else "Добавить фотографии", + text = if (isPhotoUploading) { + "Загружаем фото..." + } else { + "Добавить фотографии" + }, color = UrbanBrown ) } @@ -457,11 +581,14 @@ fun ReviewFormScreen( }, enabled = !submitInProgress && !isPhotoUploading ) { + scope.launch { + isPreparingSubmit = true var sentToViewModel = false try { + val validationError = validateReviewForm( latitude = latitude, longitude = longitude, @@ -475,8 +602,15 @@ fun ReviewFormScreen( return@launch } - val lat = latitude.trim().replace(',', '.').toDouble() - val lon = longitude.trim().replace(',', '.').toDouble() + val lat = latitude + .trim() + .replace(',', '.') + .toDouble() + + val lon = longitude + .trim() + .replace(',', '.') + .toDouble() val generatedAddress = resolveReviewAddress( context = context, @@ -490,11 +624,14 @@ fun ReviewFormScreen( reviewsViewModel.clearMessages() val obstacles = ReviewObstacleTypes.map { type -> + ReviewObstacle( obstacleType = type, severity = if (obstacleSelected[type] == true) { (obstacleSeverities[type] ?: 1).toShort() - } else 0 + } else { + 0 + } ) } @@ -511,11 +648,20 @@ fun ReviewFormScreen( sentToViewModel = true if (isEdit) { - reviewsViewModel.updateReview(initialReview!!.id, request, onSaved) + reviewsViewModel.updateReview( + initialReview!!.id, + request, + onSaved + ) } else { - reviewsViewModel.createReview(request, onSaved) + reviewsViewModel.createReview( + request, + onSaved + ) } + } finally { + if (!sentToViewModel) { isPreparingSubmit = false } @@ -533,20 +679,35 @@ private fun validateReviewForm( obstacleSelected: Map, obstacleSeverities: Map ): String? { - val lat = latitude.trim().replace(',', '.').toDoubleOrNull() - val lon = longitude.trim().replace(',', '.').toDoubleOrNull() + + val lat = latitude + .trim() + .replace(',', '.') + .toDoubleOrNull() + + val lon = longitude + .trim() + .replace(',', '.') + .toDoubleOrNull() + if (lat == null || lon == null) { return "Введите корректные координаты" } - if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) { + + if (lat < -90.0 || lat > 90.0 || + lon < -180.0 || lon > 180.0 + ) { return "Координаты выходят за допустимый диапазон" } + if (rating == null) { return "Поставьте оценку отзыву" } + if (obstacleSelected.values.none { it }) { return "Выберите хотя бы одно препятствие" } + return null } @@ -557,7 +718,10 @@ private suspend fun resolveReviewAddress( placeName: String, fallbackAddress: ReviewAddress? ): ReviewAddress = withContext(Dispatchers.IO) { - val normalizedPlaceName = placeName.trim().ifBlank { null } + + val normalizedPlaceName = + placeName.trim().ifBlank { null } + val baseAddress = fallbackAddress ?: ReviewAddress( country = "Россия", region = "Регион не указан", @@ -567,54 +731,103 @@ private suspend fun resolveReviewAddress( house = "Без номера", placeName = normalizedPlaceName ) - val geocoder = Geocoder(context, Locale("ru")) + + val geocoder = Geocoder( + context, + Locale("ru") + ) return@withContext try { - val rawAddress = geocoder.getFromLocation(latitude, longitude, 1)?.firstOrNull() + + val rawAddress = geocoder + .getFromLocation(latitude, longitude, 1) + ?.firstOrNull() + if (rawAddress == null) { - baseAddress.copy(placeName = normalizedPlaceName) + + baseAddress.copy( + placeName = normalizedPlaceName + ) + } else { + ReviewAddress( - country = rawAddress.countryName?.takeIf { it.isNotBlank() } - ?: baseAddress.country, - region = listOf(rawAddress.adminArea, rawAddress.subAdminArea) - .firstNotBlank() - ?: baseAddress.region, - localityType = detectLocalityType(rawAddress, baseAddress), - city = listOf( - rawAddress.locality, - rawAddress.subLocality, - rawAddress.subAdminArea, - rawAddress.adminArea - ) - .firstNotBlank() - ?: baseAddress.city, - street = listOf( - rawAddress.thoroughfare, - rawAddress.subLocality, - rawAddress.featureName - ) - .firstNotBlank() - ?: baseAddress.street, - house = listOf(rawAddress.subThoroughfare, rawAddress.premises) - .firstNotBlank() - ?: baseAddress.house, + country = + rawAddress.countryName + ?.takeIf { it.isNotBlank() } + ?: baseAddress.country, + + region = + listOf( + rawAddress.adminArea, + rawAddress.subAdminArea + ) + .firstNotBlank() + ?: baseAddress.region, + + localityType = + detectLocalityType( + rawAddress, + baseAddress + ), + + city = + listOf( + rawAddress.locality, + rawAddress.subLocality, + rawAddress.subAdminArea, + rawAddress.adminArea + ) + .firstNotBlank() + ?: baseAddress.city, + + street = + listOf( + rawAddress.thoroughfare, + rawAddress.subLocality, + rawAddress.featureName + ) + .firstNotBlank() + ?: baseAddress.street, + + house = + listOf( + rawAddress.subThoroughfare, + rawAddress.premises + ) + .firstNotBlank() + ?: baseAddress.house, + placeName = normalizedPlaceName ) } + } catch (_: Exception) { - baseAddress.copy(placeName = normalizedPlaceName) + + baseAddress.copy( + placeName = normalizedPlaceName + ) } } private fun List.firstNotBlank(): String? { - return firstOrNull { !it.isNullOrBlank() }?.trim() + return firstOrNull { + !it.isNullOrBlank() + }?.trim() } -private fun detectLocalityType(address: Address, fallbackAddress: ReviewAddress): String { +private fun detectLocalityType( + address: Address, + fallbackAddress: ReviewAddress +): String { return when { - !address.locality.isNullOrBlank() -> "город" - !address.subAdminArea.isNullOrBlank() -> "район" - else -> fallbackAddress.localityType + !address.locality.isNullOrBlank() -> + "город" + + !address.subAdminArea.isNullOrBlank() -> + "район" + + else -> + fallbackAddress.localityType } } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskExecutionScreen.kt b/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskExecutionScreen.kt index 17983b4..9bf78af 100644 --- a/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskExecutionScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskExecutionScreen.kt @@ -8,7 +8,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material3.* -import androidx.compose.runtime.* +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.text.font.FontWeight @@ -26,10 +30,13 @@ import com.example.goodroad.ui.theme.SafeGreen @Composable fun TaskExecutionScreen( task: TaskViewDto, - onTargetComplete: (TargetViewDto) -> Unit, + onTargetClick: (TargetViewDto) -> Unit, onBack: () -> Unit ) { - var targetsState by remember { mutableStateOf(task.targets.toList()) } + var targetsState by remember { + mutableStateOf(task.targets.toList()) + } + val completedTargets = targetsState.count { it.done } Surface( @@ -41,6 +48,7 @@ fun TaskExecutionScreen( .fillMaxSize() .padding(24.dp) ) { + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -81,6 +89,7 @@ fun TaskExecutionScreen( fontWeight = FontWeight.Bold, color = UrbanBrown ) + Text( text = "⭐", fontSize = 16.sp @@ -91,7 +100,13 @@ fun TaskExecutionScreen( Spacer(modifier = Modifier.height(16.dp)) LinearProgressIndicator( - progress = completedTargets.toFloat() / task.targetCount, + progress = { + if (task.targetCount > 0) { + completedTargets.toFloat() / task.targetCount + } else { + 0f + } + }, modifier = Modifier .fillMaxWidth() .height(8.dp), @@ -119,20 +134,18 @@ fun TaskExecutionScreen( Spacer(modifier = Modifier.height(12.dp)) LazyColumn( + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { itemsIndexed(targetsState) { idx, target -> + TargetItem( target = target, index = idx, isCompleted = target.done, onComplete = { if (!target.done) { - val updatedTarget = target.copy(done = true) - targetsState = targetsState.mapIndexed { index, t -> - if (index == idx) updatedTarget else t - } - onTargetComplete(updatedTarget) + onTargetClick(target) } } ) @@ -153,7 +166,9 @@ private fun TargetItem( modifier = Modifier .fillMaxWidth() .clickable(enabled = !isCompleted) { - if (!isCompleted) onComplete() + if (!isCompleted) { + onComplete() + } }, colors = CardDefaults.cardColors( containerColor = SurfaceWarm @@ -166,11 +181,13 @@ private fun TargetItem( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { + Row( modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { + if (isCompleted) { Icon( imageVector = Icons.Default.CheckCircle, @@ -210,7 +227,7 @@ private fun TargetItem( backgroundColor = SafeGreen, modifier = Modifier.width(100.dp), onClick = { - if (!isCompleted) onComplete() + onComplete() } ) } diff --git a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt index 9c81bfb..630f06b 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt @@ -48,6 +48,7 @@ import com.example.goodroad.modules.tasks.data.TaskViewDto import com.example.goodroad.modules.tasks.screens.CompletedTasksHistoryScreen import com.example.goodroad.modules.tasks.screens.TaskExecutionScreen import com.example.goodroad.modules.rewards.screens.CouponsScreen +import com.example.goodroad.modules.tasks.data.TargetViewDto enum class BottomTab { MAP, @@ -75,7 +76,8 @@ enum class OverlayScreen { TASKS_SHOP, TASK_DETAIL, TASKS_HISTORY, - COUPONS + COUPONS, + REVIEW_FORM_FROM_TASK, } @Composable @@ -139,7 +141,7 @@ fun UserNav( var selectedReview by remember { mutableStateOf(null) } var selectedReward by remember { mutableStateOf(null) } var selectedTask by remember { mutableStateOf(null) } - + var selectedTaskTarget by remember { mutableStateOf(null) } Scaffold( bottomBar = { NavigationBar { @@ -365,6 +367,31 @@ fun UserNav( } } + OverlayScreen.REVIEW_FORM_FROM_TASK -> { + val target = selectedTaskTarget + + if (target != null) { + ReviewFormScreen( + reviewsViewModel = reviewsViewModel, + initialReview = null, + initialPlaceName = target.title, + initialLatitude = target.latitude?.toString() ?: "", + initialLongitude = target.longitude?.toString() ?: "", + isLocationLocked = true, + onBack = { + selectedTaskTarget = null + overlayScreen = OverlayScreen.TASK_DETAIL + }, + onSaved = { + selectedTaskTarget = null + overlayScreen = OverlayScreen.TASK_DETAIL + } + ) + } else { + overlayScreen = OverlayScreen.TASK_DETAIL + } + } + OverlayScreen.REWARDS_HISTORY -> RewardsHistoryScreen( viewModel = rewardsViewModel, onBack = { overlayScreen = OverlayScreen.REWARDS_SHOP } @@ -392,19 +419,19 @@ fun UserNav( OverlayScreen.TASK_DETAIL -> { val task = selectedTask + if (task != null) { TaskExecutionScreen( task = task, - onTargetComplete = { target -> - tasksViewModel.completeTarget(task.id, target.id) - }, - onComplete = { - selectedTask = null - overlayScreen = OverlayScreen.TASKS_SHOP - tasksViewModel.loadTasks() + + onTargetClick = { target -> + selectedTaskTarget = target + overlayScreen = OverlayScreen.REVIEW_FORM_FROM_TASK }, + onBack = { selectedTask = null + selectedTaskTarget = null overlayScreen = OverlayScreen.TASKS_SHOP } ) diff --git a/app/src/main/java/com/example/goodroad/ui/fields/PlainField.kt b/app/src/main/java/com/example/goodroad/ui/fields/PlainField.kt index 920a6b9..23ff134 100644 --- a/app/src/main/java/com/example/goodroad/ui/fields/PlainField.kt +++ b/app/src/main/java/com/example/goodroad/ui/fields/PlainField.kt @@ -22,7 +22,8 @@ fun PlainField( trailing: @Composable (() -> Unit)? = null, prefix: @Composable (() -> Unit)? = null, warning: String? = null, - maxLength: Int = Int.MAX_VALUE + maxLength: Int = Int.MAX_VALUE, + readOnly: Boolean = false ) { TextField( value = value, @@ -34,6 +35,7 @@ fun PlainField( modifier = modifier.fillMaxWidth(), label = { Text(label, color = UrbanBrown) }, singleLine = true, + readOnly = readOnly, keyboardOptions = keyboardOptions, visualTransformation = visualTransformation, leadingIcon = icon, From e58e66728d2a7d3f20d73fad04b3841a4e9cc5e8 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:57:30 +0300 Subject: [PATCH 09/13] add: filter for tasks --- .../tasks/screens/TaskExecutionScreen.kt | 2 +- .../modules/tasks/screens/TaskScreen.kt | 177 +++++++++++++++--- 2 files changed, 157 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskExecutionScreen.kt b/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskExecutionScreen.kt index 9bf78af..3a9b70d 100644 --- a/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskExecutionScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskExecutionScreen.kt @@ -33,7 +33,7 @@ fun TaskExecutionScreen( onTargetClick: (TargetViewDto) -> Unit, onBack: () -> Unit ) { - var targetsState by remember { + var targetsState by remember(task.targets) { mutableStateOf(task.targets.toList()) } diff --git a/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskScreen.kt b/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskScreen.kt index a8e754f..cc5accb 100644 --- a/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskScreen.kt @@ -12,19 +12,20 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.example.goodroad.modules.tasks.presentation.TasksViewModel import com.example.goodroad.modules.tasks.data.TaskViewDto +import com.example.goodroad.modules.tasks.presentation.TasksViewModel import com.example.goodroad.ui.buttons.PrimaryButton +import com.example.goodroad.ui.theme.AlertRed import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.ui.theme.SafeGreen +import com.example.goodroad.ui.theme.SurfaceWarm import com.example.goodroad.ui.theme.TextPrimary import com.example.goodroad.ui.theme.UrbanBrown -import com.example.goodroad.ui.theme.SurfaceWarm -import com.example.goodroad.ui.theme.SafeGreen -import com.example.goodroad.ui.theme.AlertRed @Composable fun TasksScreen( @@ -37,8 +38,10 @@ fun TasksScreen( val loading by viewModel.loading.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - viewModel.loadTasks() + var selectedType by remember { mutableStateOf("REVIEW") } + + LaunchedEffect(selectedType) { + viewModel.loadTasks(activityType = selectedType) } Surface( @@ -50,6 +53,7 @@ fun TasksScreen( .fillMaxSize() .padding(24.dp) ) { + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -66,7 +70,7 @@ fun TasksScreen( Icon( imageVector = Icons.Default.History, contentDescription = "История заданий", - tint = UrbanBrown + tint = UrbanBrown.copy(alpha = 0.7f) ) } @@ -74,7 +78,7 @@ fun TasksScreen( Icon( imageVector = Icons.Default.ArrowBack, contentDescription = "Назад", - tint = UrbanBrown + tint = UrbanBrown.copy(alpha = 0.7f) ) } } @@ -88,8 +92,59 @@ fun TasksScreen( modifier = Modifier.padding(bottom = 16.dp) ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = selectedType == "REVIEW", + onClick = { + if (selectedType != "REVIEW") { + selectedType = "REVIEW" + } + }, + label = { + Text( + text = "Отзывы", + fontSize = 16.sp + ) + }, + modifier = Modifier.weight(1f), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = UrbanBrown.copy(alpha = 0.15f), + selectedLabelColor = UrbanBrown.copy(alpha = 0.8f), + containerColor = Color.Transparent, + labelColor = UrbanBrown.copy(alpha = 0.6f) + ) + ) + + FilterChip( + selected = selectedType == "VOLUNTEER", + onClick = { + if (selectedType != "VOLUNTEER") { + selectedType = "VOLUNTEER" + } + }, + label = { + Text( + text = "Волонтёрство", + fontSize = 16.sp + ) + }, + modifier = Modifier.weight(1f), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = UrbanBrown.copy(alpha = 0.15f), + selectedLabelColor = UrbanBrown.copy(alpha = 0.8f), + containerColor = Color.Transparent, + labelColor = UrbanBrown.copy(alpha = 0.6f) + ) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + when { - loading && tasks.isEmpty() -> { + loading -> { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -103,15 +158,30 @@ fun TasksScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("❌", fontSize = 48.sp) + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "❌", + fontSize = 48.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( text = error ?: "Ошибка загрузки", color = AlertRed ) + Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { viewModel.loadTasks() }) { + + Button( + onClick = { + viewModel.loadTasks( + activityType = selectedType + ) + } + ) { Text("Повторить") } } @@ -123,15 +193,27 @@ fun TasksScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("📋", fontSize = 48.sp) + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "📋", + fontSize = 48.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( - text = "Нет доступных заданий", + text = if (selectedType == "REVIEW") { + "Нет доступных заданий на отзывы" + } else { + "Нет доступных волонтёрских заданий" + }, fontSize = 18.sp, fontWeight = FontWeight.Medium, color = TextPrimary ) + Text( text = "Загляните позже!", fontSize = 14.sp, @@ -145,10 +227,17 @@ fun TasksScreen( LazyColumn( verticalArrangement = Arrangement.spacedBy(12.dp) ) { - items(tasks) { task -> + items( + items = tasks, + key = { task -> task.id } + ) { task -> + TaskCard( task = task, - onClick = { onTaskClick(task) } + isReview = selectedType == "REVIEW", + onClick = { + onTaskClick(task) + } ) } } @@ -161,6 +250,7 @@ fun TasksScreen( @Composable private fun TaskCard( task: TaskViewDto, + isReview: Boolean, onClick: () -> Unit ) { val isCompleted = task.completedCount >= task.targetCount @@ -168,11 +258,21 @@ private fun TaskCard( Card( modifier = Modifier .fillMaxWidth() - .clickable(enabled = !isCompleted) { onClick() }, + .clickable( + enabled = isReview && !isCompleted + ) { + onClick() + }, colors = CardDefaults.cardColors( - containerColor = if (isCompleted) SurfaceWarm.copy(alpha = 0.5f) else SurfaceWarm + containerColor = if (isCompleted) { + SurfaceWarm.copy(alpha = 0.5f) + } else { + SurfaceWarm + } ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + elevation = CardDefaults.cardElevation( + defaultElevation = 1.dp + ) ) { Row( modifier = Modifier @@ -181,18 +281,25 @@ private fun TaskCard( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { + Column( modifier = Modifier.weight(1f) ) { + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { + Text( text = task.title, fontSize = 18.sp, fontWeight = FontWeight.Bold, - color = if (isCompleted) UrbanBrown.copy(alpha = 0.6f) else TextPrimary + color = if (isCompleted) { + UrbanBrown.copy(alpha = 0.6f) + } else { + TextPrimary + } ) if (isCompleted) { @@ -210,16 +317,19 @@ private fun TaskCard( Row( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { + Text( text = "⭐ ${task.points}", fontSize = 17.sp, color = UrbanBrown ) + Text( text = "•", fontSize = 17.sp, color = UrbanBrown ) + Text( text = "Прогресс: ${task.completedCount}/${task.targetCount}", fontSize = 17.sp, @@ -229,6 +339,31 @@ private fun TaskCard( } Spacer(modifier = Modifier.width(16.dp)) + + if (!isCompleted) { + + if (isReview) { + + PrimaryButton( + text = "Выполнить", + backgroundColor = SafeGreen, + modifier = Modifier.width(100.dp), + onClick = onClick + ) + + } else { + + PrimaryButton( + text = "Выполнено", + backgroundColor = SafeGreen, + modifier = Modifier.width(110.dp), + onClick = { + + + } + ) + } + } } } } \ No newline at end of file From 87e08280be4808abcde2f40046821429d7f85fb9 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:10:50 +0300 Subject: [PATCH 10/13] add: screens for change password and phone but without logic --- .../goodroad/modules/user/data/UserApi.kt | 27 +- .../goodroad/modules/user/data/UserModels.kt | 8 +- .../modules/user/data/UserRepository.kt | 56 +++- .../modules/user/navigation/UserNav.kt | 35 +++ .../user/presentation/UserViewModel.kt | 253 +++++++++++++++--- .../user/screens/ChangePasswordScreen.kt | 165 ++++++++++++ .../modules/user/screens/ChangePhoneScreen.kt | 182 +++++++++++++ .../modules/user/screens/SecurityScreen.kt | 114 ++++++++ .../modules/user/screens/UserEditScreen.kt | 4 +- .../modules/user/screens/UserProfileScreen.kt | 72 +++-- 10 files changed, 834 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/com/example/goodroad/modules/user/screens/ChangePasswordScreen.kt create mode 100644 app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt create mode 100644 app/src/main/java/com/example/goodroad/modules/user/screens/SecurityScreen.kt diff --git a/app/src/main/java/com/example/goodroad/modules/user/data/UserApi.kt b/app/src/main/java/com/example/goodroad/modules/user/data/UserApi.kt index 189e6e1..f221776 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/data/UserApi.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/data/UserApi.kt @@ -3,13 +3,12 @@ package com.example.goodroad.modules.user.data import okhttp3.MultipartBody import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET -import retrofit2.http.HTTP import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Part -import retrofit2.http.Query interface UserApi { @@ -17,18 +16,28 @@ interface UserApi { suspend fun getCurrentUser(): Response @PUT("/users") - suspend fun updateCurrentUser(@Body req: UpdateUserReq): Response + suspend fun updateCurrentUser( + @Body req: UpdateUserReq + ): Response @POST("/users") suspend fun changePassword( - @Query("oldPassword") oldPassword: String, - @Query("newPassword") newPassword: String + @Body req: ChangePasswordReq ): Response @Multipart @POST("/users/avatar") - suspend fun uploadAvatar(@Part file: MultipartBody.Part): Response + suspend fun uploadAvatar( + @Part file: MultipartBody.Part + ): Response - @HTTP(method = "DELETE", path = "/users", hasBody = true) - suspend fun deleteCurrentUser(@Body req: DeleteAccountReq): Response -} \ No newline at end of file + @DELETE("/users") + suspend fun deleteCurrentUser( + @Body req: DeleteAccountReq + ): Response +} + +data class ChangePasswordReq( + val oldPassword: String, + val newPassword: String +) \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/user/data/UserModels.kt b/app/src/main/java/com/example/goodroad/modules/user/data/UserModels.kt index c90e421..db57feb 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/data/UserModels.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/data/UserModels.kt @@ -13,7 +13,13 @@ data class UpdateUserReq( val firstName: String? = null, val lastName: String? = null, val photoUrl: String? = null, - val phone: String? = null + val phone: String? = null, + val currentPassword: String? = null +) + +data class ChangePhoneReq( + val phone: String, + val currentPassword: String ) data class AvatarUploadResp( diff --git a/app/src/main/java/com/example/goodroad/modules/user/data/UserRepository.kt b/app/src/main/java/com/example/goodroad/modules/user/data/UserRepository.kt index e61057d..a7233ce 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/data/UserRepository.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/data/UserRepository.kt @@ -3,33 +3,67 @@ package com.example.goodroad.modules.user.data import okhttp3.MultipartBody import retrofit2.HttpException -class UserRepository(private val api: UserApi) { +class UserRepository( + private val api: UserApi +) { suspend fun getCurrentUser(): SettingsView? { val response = api.getCurrentUser() - if (response.isSuccessful) return response.body() + + if (response.isSuccessful) { + return response.body() + } + throw HttpException(response) } - suspend fun updateCurrentUser(req: UpdateUserReq): SettingsView? { + suspend fun updateCurrentUser( + req: UpdateUserReq + ): SettingsView? { val response = api.updateCurrentUser(req) - if (response.isSuccessful) return response.body() + + if (response.isSuccessful) { + return response.body() + } + throw HttpException(response) } - suspend fun changePassword(oldPassword: String, newPassword: String) { - val response = api.changePassword(oldPassword, newPassword) - if (!response.isSuccessful) throw HttpException(response) + suspend fun changePassword( + oldPassword: String, + newPassword: String + ) { + val response = api.changePassword( + ChangePasswordReq( + oldPassword = oldPassword, + newPassword = newPassword + ) + ) + + if (!response.isSuccessful) { + throw HttpException(response) + } } - suspend fun uploadAvatar(file: MultipartBody.Part): AvatarUploadResp? { + suspend fun uploadAvatar( + file: MultipartBody.Part + ): AvatarUploadResp? { val response = api.uploadAvatar(file) - if (response.isSuccessful) return response.body() + + if (response.isSuccessful) { + return response.body() + } + throw HttpException(response) } - suspend fun deleteCurrentUser(req: DeleteAccountReq) { + suspend fun deleteCurrentUser( + req: DeleteAccountReq + ) { val response = api.deleteCurrentUser(req) - if (!response.isSuccessful) throw HttpException(response) + + if (!response.isSuccessful) { + throw HttpException(response) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt index 630f06b..27162ab 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt @@ -49,6 +49,9 @@ import com.example.goodroad.modules.tasks.screens.CompletedTasksHistoryScreen import com.example.goodroad.modules.tasks.screens.TaskExecutionScreen import com.example.goodroad.modules.rewards.screens.CouponsScreen import com.example.goodroad.modules.tasks.data.TargetViewDto +import com.example.goodroad.ui.user.SecurityScreen +import com.example.goodroad.ui.user.ChangePasswordScreen +import com.example.goodroad.ui.user.ChangePhoneScreen enum class BottomTab { MAP, @@ -61,6 +64,9 @@ enum class OverlayScreen { NONE, EDIT_PROFILE, DELETE_PROFILE, + SECURITY, + CHANGE_PASSWORD, + CHANGE_PHONE, REVIEW_FORM, REVIEW_DETAILS, OBSTACLES, @@ -243,6 +249,9 @@ fun UserNav( onSelectObstacles = { overlayScreen = OverlayScreen.OBSTACLES }, + onSecurity = { + overlayScreen = OverlayScreen.SECURITY + }, onBecomeVolunteer = { overlayScreen = OverlayScreen.VOLUNTEER_APPLICATION }, @@ -270,6 +279,32 @@ fun UserNav( onExit = onLogout ) + OverlayScreen.SECURITY -> SecurityScreen( + onBack = { + overlayScreen = OverlayScreen.NONE + }, + onChangePassword = { + overlayScreen = OverlayScreen.CHANGE_PASSWORD + }, + onChangePhone = { + overlayScreen = OverlayScreen.CHANGE_PHONE + } + ) + + OverlayScreen.CHANGE_PASSWORD -> ChangePasswordScreen( + userViewModel = userViewModel, + onBack = { + overlayScreen = OverlayScreen.SECURITY + } + ) + + OverlayScreen.CHANGE_PHONE -> ChangePhoneScreen( + userViewModel = userViewModel, + onBack = { + overlayScreen = OverlayScreen.SECURITY + } + ) + OverlayScreen.REVIEW_FORM -> ReviewFormScreen( reviewsViewModel = reviewsViewModel, initialReview = selectedReview, diff --git a/app/src/main/java/com/example/goodroad/modules/user/presentation/UserViewModel.kt b/app/src/main/java/com/example/goodroad/modules/user/presentation/UserViewModel.kt index b4755ad..593d60f 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/presentation/UserViewModel.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/presentation/UserViewModel.kt @@ -58,9 +58,7 @@ class UserViewModel( firstName: String, lastName: String, photoUrl: String? = null, - phone: String? = null, - oldPassword: String? = null, - newPassword: String? = null + phone: String? = null ) { viewModelScope.launch { isLoading.value = true @@ -70,49 +68,129 @@ class UserViewModel( try { val current = user.value - val phoneToUpdate = phone?.takeIf { it.isNotBlank() } - val hasPasswordChange = !oldPassword.isNullOrBlank() || !newPassword.isNullOrBlank() - - if (hasPasswordChange && - (oldPassword.isNullOrBlank() || newPassword.isNullOrBlank()) - ) { - throw IllegalArgumentException("Для смены пароля заполните оба поля") - } - val req = UpdateUserReq( - firstName = firstName.takeIf { it != current?.firstName }, - lastName = lastName.takeIf { it != current?.lastName }, - photoUrl = photoUrl.takeIf { it != current?.photoUrl }, - phone = phoneToUpdate + firstName = firstName.takeIf { + it != current?.firstName + }, + lastName = lastName.takeIf { + it != current?.lastName + }, + photoUrl = photoUrl.takeIf { + it != current?.photoUrl + }, + phone = phone?.takeIf { + it.isNotBlank() + } ) val hasChanges = req.firstName != null || req.lastName != null || req.photoUrl != null || - req.phone != null || - hasPasswordChange + req.phone != null if (!hasChanges) { - throw IllegalArgumentException("Нет изменений для сохранения") + throw IllegalArgumentException( + "Нет изменений для сохранения" + ) } - if (req.phone != null) { - user.value = repository.updateCurrentUser(req) - val updatedUser = repository.updateCurrentUser(req) - user.value = updatedUser + user.value = repository.updateCurrentUser(req) + + successMessage.value = "Профиль обновлён" + + } catch (e: Exception) { + errorMessage.value = mapUserError(e) + } finally { + isLoading.value = false + } + } + } + + fun changePhone( + newPhone: String, + currentPassword: String, + onSuccess: () -> Unit + ) { + viewModelScope.launch { + isLoading.value = true + errorMessage.value = null + successMessage.value = null + + try { + if (newPhone.isBlank()) { + throw IllegalArgumentException( + "Введите новый номер телефона" + ) } - if (hasPasswordChange) { - repository.changePassword(oldPassword!!, newPassword!!) - successMessage.value = "Пароль изменён. Пожалуйста, войдите заново." - ApiClient.logout() + if (currentPassword.isBlank()) { + throw IllegalArgumentException( + "Введите текущий пароль" + ) } - successMessage.value = "Профиль обновлён" + val req = UpdateUserReq( + phone = newPhone.trim(), + currentPassword = currentPassword + ) + + val updatedUser = repository.updateCurrentUser(req) + + if (updatedUser == null) { + throw IllegalStateException( + "Сервер не вернул данные пользователя" + ) + } + + user.value = updatedUser + + successMessage.value = "Номер телефона изменён" + + onSuccess() } catch (e: Exception) { - errorMessage.value = mapUserError(e) + errorMessage.value = mapPhoneChangeError(e) + } finally { + isLoading.value = false + } + } + } + + fun changePassword( + oldPassword: String, + newPassword: String, + onSuccess: () -> Unit + ) { + viewModelScope.launch { + isLoading.value = true + errorMessage.value = null + successMessage.value = null + + try { + if (oldPassword.isBlank()) { + throw IllegalArgumentException( + "Введите текущий пароль" + ) + } + + if (newPassword.isBlank()) { + throw IllegalArgumentException( + "Введите новый пароль" + ) + } + + repository.changePassword( + oldPassword = oldPassword, + newPassword = newPassword + ) + + successMessage.value = "Пароль успешно изменён" + + onSuccess() + + } catch (e: Exception) { + errorMessage.value = mapPasswordChangeError(e) } finally { isLoading.value = false } @@ -135,26 +213,45 @@ class UserViewModel( val resolver = context.contentResolver val mimeType = resolver.getType(uri) ?: "image/*" - tempFile = File.createTempFile("avatar", ".tmp", context.cacheDir) + tempFile = File.createTempFile( + "avatar", + ".tmp", + context.cacheDir + ) resolver.openInputStream(uri)?.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } - } ?: throw IllegalStateException("Не удалось прочитать файл") + } ?: throw IllegalStateException( + "Не удалось прочитать файл" + ) - val body = tempFile.asRequestBody(mimeType.toMediaTypeOrNull()) - val part = MultipartBody.Part.createFormData("file", tempFile.name, body) + val body = tempFile.asRequestBody( + mimeType.toMediaTypeOrNull() + ) + + val part = MultipartBody.Part.createFormData( + "file", + tempFile.name, + body + ) val response = repository.uploadAvatar(part) - ?: throw IllegalStateException("Сервер не вернул фото") + ?: throw IllegalStateException( + "Сервер не вернул фото" + ) val current = user.value + if (current != null) { - user.value = current.copy(photoUrl = response.photoUrl) + user.value = current.copy( + photoUrl = response.photoUrl + ) } successMessage.value = "Фото профиля обновлено" + onSuccess(response.photoUrl) } catch (e: Exception) { @@ -166,15 +263,22 @@ class UserViewModel( } } - fun deleteUser(password: String, onSuccess: () -> Unit) { + fun deleteUser( + password: String, + onSuccess: () -> Unit + ) { viewModelScope.launch { isLoading.value = true errorMessage.value = null successMessage.value = null try { - repository.deleteCurrentUser(DeleteAccountReq(password)) + repository.deleteCurrentUser( + DeleteAccountReq(password) + ) + ApiClient.logout() + user.value = null isDeleted = true @@ -188,12 +292,16 @@ class UserViewModel( } } - fun logout(onSuccess: () -> Unit) { + fun logout( + onSuccess: () -> Unit + ) { ApiClient.logout() + user.value = null isDeleted = false errorMessage.value = null successMessage.value = null + onSuccess() } @@ -208,7 +316,10 @@ class UserViewModel( private fun mapUserError(e: Exception): String { return when (e) { - is IllegalArgumentException -> e.message ?: "Некорректные данные" + + is IllegalArgumentException -> + e.message ?: "Некорректные данные" + is HttpException -> when (e.code()) { 400 -> "Некорректные данные" 401 -> "Не авторизован" @@ -218,13 +329,42 @@ class UserViewModel( 500 -> "Ошибка сервера" else -> "Ошибка" } - is IOException -> "Проверьте интернет" - else -> e.message ?: "Неизвестная ошибка" + + is IOException -> + "Проверьте интернет" + + else -> + e.message ?: "Неизвестная ошибка" + } + } + + private fun mapPhoneChangeError(e: Exception): String { + return when (e) { + + is IllegalArgumentException -> + e.message ?: "Некорректные данные" + + is HttpException -> when (e.code()) { + 400 -> "Некорректный номер телефона" + 401 -> "Неверный текущий пароль" + 403 -> "Нет доступа" + 404 -> "Пользователь не найден" + 409 -> "Этот номер телефона уже используется" + 500 -> "Ошибка сервера" + else -> "Не удалось изменить номер телефона" + } + + is IOException -> + "Проверьте интернет" + + else -> + e.message ?: "Неизвестная ошибка" } } private fun mapDeleteError(e: Exception): String { return when (e) { + is HttpException -> when (e.code()) { 400 -> "Неверный пароль" 401 -> "Неверный пароль" @@ -234,8 +374,35 @@ class UserViewModel( 500 -> "Ошибка сервера" else -> "Ошибка удаления" } - is IOException -> "Проверьте интернет" - else -> e.message ?: "Неизвестная ошибка" + + is IOException -> + "Проверьте интернет" + + else -> + e.message ?: "Неизвестная ошибка" + } + } +} + +private fun mapPasswordChangeError(e: Exception): String { + return when (e) { + + is IllegalArgumentException -> + e.message ?: "Некорректные данные" + + is HttpException -> when (e.code()) { + 400 -> "Некорректные данные" + 401 -> "Неверный текущий пароль" + 403 -> "Нет доступа" + 404 -> "Пользователь не найден" + 500 -> "Ошибка сервера" + else -> "Не удалось изменить пароль" } + + is IOException -> + "Проверьте интернет" + + else -> + e.message ?: "Неизвестная ошибка" } } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePasswordScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePasswordScreen.kt new file mode 100644 index 0000000..3396280 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePasswordScreen.kt @@ -0,0 +1,165 @@ +package com.example.goodroad.ui.user + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.goodroad.modules.user.presentation.UserViewModel +import com.example.goodroad.ui.AuthStatusText +import com.example.goodroad.ui.buttons.PrimaryButton +import com.example.goodroad.ui.fields.PasswordField +import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.ui.theme.TextPrimary +import com.example.goodroad.ui.theme.UrbanBrown + +@Composable +fun ChangePasswordScreen( + userViewModel: UserViewModel, + onBack: () -> Unit +) { + var oldPassword by rememberSaveable { mutableStateOf("") } + var newPassword by rememberSaveable { mutableStateOf("") } + var confirmPassword by rememberSaveable { mutableStateOf("") } + var localError by rememberSaveable { mutableStateOf(null) } + + val isLoading by userViewModel.isLoading + val serverError by userViewModel.errorMessage + + LaunchedEffect(Unit) { + userViewModel.clearMessages() + localError = null + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Сменить пароль", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary, + modifier = Modifier.weight(1f) + ) + + IconButton( + onClick = onBack, + enabled = !isLoading + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Назад", + tint = UrbanBrown.copy(alpha = 0.7f) + ) + } + } + + Spacer(Modifier.height(24.dp)) + + PasswordField( + value = oldPassword, + onValueChange = { + oldPassword = it + localError = null + userViewModel.clearMessages() + }, + label = "Старый пароль" + ) + + Spacer(Modifier.height(12.dp)) + + PasswordField( + value = newPassword, + onValueChange = { + newPassword = it + localError = null + userViewModel.clearMessages() + }, + label = "Новый пароль" + ) + + Spacer(Modifier.height(12.dp)) + + PasswordField( + value = confirmPassword, + onValueChange = { + confirmPassword = it + localError = null + userViewModel.clearMessages() + }, + label = "Подтвердите новый пароль" + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Введите текущий пароль, затем новый пароль и повторите его.", + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown + ) + + Spacer(Modifier.height(16.dp)) + + AuthStatusText( + text = localError ?: serverError, + onTimeout = { + localError = null + userViewModel.clearMessages() + } + ) + + Spacer(Modifier.height(16.dp)) + + PrimaryButton( + text = if (isLoading) { + "Меняем..." + } else { + "Сменить пароль" + }, + enabled = !isLoading + ) { + when { + oldPassword.isBlank() -> { + localError = "Введите текущий пароль" + } + + newPassword.isBlank() -> { + localError = "Введите новый пароль" + } + + confirmPassword.isBlank() -> { + localError = "Подтвердите новый пароль" + } + + newPassword != confirmPassword -> { + localError = "Пароли не совпадают" + } + + else -> { + localError = null + + userViewModel.changePassword( + oldPassword = oldPassword, + newPassword = newPassword, + onSuccess = onBack + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt new file mode 100644 index 0000000..f6f8f43 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt @@ -0,0 +1,182 @@ +package com.example.goodroad.ui.user + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.goodroad.modules.user.presentation.UserViewModel +import com.example.goodroad.ui.AuthStatusText +import com.example.goodroad.ui.buttons.PrimaryButton +import com.example.goodroad.ui.fields.PasswordField +import com.example.goodroad.ui.fields.PhoneField +import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.ui.theme.TextPrimary +import com.example.goodroad.validation.PHONE_FORMAT_WARNING +import com.example.goodroad.validation.formatPhoneForRequest +import com.example.goodroad.validation.isValidRussianPhoneDigits +import com.example.goodroad.validation.normalizeRequiredRussianPhone + +@Composable +fun ChangePhoneScreen( + userViewModel: UserViewModel, + onBack: () -> Unit +) { + var phone by rememberSaveable { mutableStateOf("") } + var currentPassword by rememberSaveable { mutableStateOf("") } + + var localError by rememberSaveable { + mutableStateOf(null) + } + + val serverError by userViewModel.errorMessage + val isLoading by userViewModel.isLoading + + val phoneValidation = remember(phone) { + when { + phone.isEmpty() -> PhoneValidation.Empty + + !isValidRussianPhoneDigits(phone.trim()) -> + PhoneValidation.InvalidFormat + + phone.length > 11 -> + PhoneValidation.InvalidFormat + + phone.first() !in listOf('7', '8') -> + PhoneValidation.InvalidFormat + + else -> { + val normalized = normalizeRequiredRussianPhone(phone) + + if (normalized != null) { + PhoneValidation.Valid(normalized) + } else { + PhoneValidation.InvalidFormat + } + } + } + } + + LaunchedEffect(Unit) { + userViewModel.clearMessages() + localError = null + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + + Text( + text = "Смена телефона", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary + ) + + Spacer( + modifier = Modifier.height(24.dp) + ) + + PhoneField( + value = phone, + onValueChange = { + phone = it + localError = null + userViewModel.clearMessages() + }, + label = "Новый телефон", + warning = when (phoneValidation) { + is PhoneValidation.InvalidFormat -> + PHONE_FORMAT_WARNING + + else -> + null + } + ) + + Spacer( + modifier = Modifier.height(12.dp) + ) + + PasswordField( + value = currentPassword, + onValueChange = { + currentPassword = it + localError = null + userViewModel.clearMessages() + }, + label = "Текущий пароль" + ) + + Spacer( + modifier = Modifier.height(20.dp) + ) + + AuthStatusText( + text = serverError ?: localError, + onTimeout = { + localError = null + userViewModel.clearMessages() + } + ) + + Spacer( + modifier = Modifier.height(20.dp) + ) + + PrimaryButton( + text = if (isLoading) { + "Меняем..." + } else { + "Сменить номер" + }, + enabled = !isLoading + ) { + when (phoneValidation) { + + is PhoneValidation.Empty -> { + localError = "Введите номер телефона" + } + + is PhoneValidation.InvalidFormat -> { + localError = "Введите корректный номер телефона" + } + + is PhoneValidation.Valid -> { + + if (currentPassword.isBlank()) { + localError = "Введите текущий пароль" + return@PrimaryButton + } + + localError = null + + userViewModel.changePhone( + newPhone = formatPhoneForRequest( + phoneValidation.phoneDigits + ), + currentPassword = currentPassword, + onSuccess = onBack + ) + } + } + } + } + } +} + +private sealed class PhoneValidation { + data object Empty : PhoneValidation() + + data object InvalidFormat : PhoneValidation() + + data class Valid( + val phoneDigits: String + ) : PhoneValidation() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/SecurityScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/SecurityScreen.kt new file mode 100644 index 0000000..76628ea --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/SecurityScreen.kt @@ -0,0 +1,114 @@ +package com.example.goodroad.ui.user + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.ui.theme.SurfaceWarm +import com.example.goodroad.ui.theme.TextPrimary +import com.example.goodroad.ui.theme.TextSecondary +import com.example.goodroad.ui.theme.UrbanBrown + +@Composable +fun SecurityScreen( + onBack: () -> Unit, + onChangePassword: () -> Unit, + onChangePhone: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Безопасность", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary, + modifier = Modifier.weight(1f) + ) + + IconButton( + onClick = onBack + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Назад", + tint = UrbanBrown.copy(alpha = 0.7f) + ) + } + } + } + + item { + SecurityCard( + title = "Сменить пароль", + description = "Изменить пароль от аккаунта", + onClick = onChangePassword + ) + } + + item { + SecurityCard( + title = "Сменить телефон", + description = "Изменить номер телефона, привязанный к аккаунту", + onClick = onChangePhone + ) + } + } + } +} + +@Composable +private fun SecurityCard( + title: String, + description: String, + onClick: () -> Unit +) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = SurfaceWarm + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 1.dp + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = TextPrimary + ) + + Spacer( + modifier = Modifier.height(4.dp) + ) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt index 51c99b6..0e6a2c7 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt @@ -371,8 +371,8 @@ fun UserEditScreen( firstName = firstNameNormalized, lastName = lastNameNormalized, phone = phoneDigits?.let { formatPhoneForRequest(it) }, - oldPassword = oldPass, - newPassword = newPass + //oldPassword = oldPass, + //newPassword = newPass ) oldPassword = "" diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/UserProfileScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/UserProfileScreen.kt index 54ad2c4..7e23047 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/screens/UserProfileScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/UserProfileScreen.kt @@ -2,7 +2,6 @@ package com.example.goodroad.ui.user import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.* import androidx.compose.runtime.* @@ -17,7 +16,6 @@ import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.example.goodroad.modules.user.presentation.UserViewModel import com.example.goodroad.ui.UserDecor -import com.example.goodroad.ui.buttons.PrimaryButton import com.example.goodroad.ui.theme.* @Composable @@ -27,6 +25,7 @@ fun UserProfileScreen( onDelete: () -> Unit, onLogout: () -> Unit, onSelectObstacles: () -> Unit, + onSecurity: () -> Unit, onBecomeVolunteer: () -> Unit = {}, onNavigateToRewards: () -> Unit = {}, onNavigateToTasks: () -> Unit = {} @@ -43,21 +42,36 @@ fun UserProfileScreen( when { isLoading && user == null -> { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { CircularProgressIndicator() } } userViewModel.isDeleted -> { - LaunchedEffect(Unit) { onLogout() } - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LaunchedEffect(Unit) { + onLogout() + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { CircularProgressIndicator() } } errorMessage != null && user == null -> { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Ошибка: $errorMessage", color = Color.Red) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Ошибка: $errorMessage", + color = Color.Red + ) } } @@ -68,13 +82,13 @@ fun UserProfileScreen( modifier = Modifier.fillMaxSize(), color = BackgroundLight ) { - LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(24.dp), verticalArrangement = Arrangement.spacedBy(14.dp) ) { + item { UserDecor() @@ -99,7 +113,9 @@ fun UserProfileScreen( verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.weight(1f)) { + Column( + modifier = Modifier.weight(1f) + ) { Text( text = "${u.firstName ?: ""} ${u.lastName ?: ""}".trim(), fontSize = 22.sp, @@ -123,7 +139,9 @@ fun UserProfileScreen( shape = CircleShape, color = WhiteSoft ) { - Box(contentAlignment = Alignment.Center) { + Box( + contentAlignment = Alignment.Center + ) { Text( text = "👤", fontSize = 32.sp @@ -136,7 +154,7 @@ fun UserProfileScreen( } item { - SectionTitle("Настройки достуности") + SectionTitle("Настройки доступности") } item { @@ -147,6 +165,7 @@ fun UserProfileScreen( ) } + item { SectionTitle("Мой вклад") } @@ -187,6 +206,15 @@ fun UserProfileScreen( ) } + + item { + ServiceCard( + title = "Безопасность", + description = "Смена пароля, телефона", + onClick = onSecurity + ) + } + item { ServiceCard( title = "Удалить аккаунт", @@ -199,7 +227,11 @@ fun UserProfileScreen( ServiceCard( title = "Выйти из аккаунта", description = "Завершить текущую сессию", - onClick = { userViewModel.logout { onLogout() } } + onClick = { + userViewModel.logout { + onLogout() + } + } ) } } @@ -207,7 +239,10 @@ fun UserProfileScreen( } else -> { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { CircularProgressIndicator() } } @@ -215,7 +250,9 @@ fun UserProfileScreen( } @Composable -private fun SectionTitle(text: String) { +private fun SectionTitle( + text: String +) { Text( text = text, style = MaterialTheme.typography.titleMedium, @@ -232,6 +269,7 @@ private fun ServiceCard( ) { Card( onClick = onClick, + modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = SurfaceWarm ), @@ -251,7 +289,9 @@ private fun ServiceCard( color = TextPrimary ) - Spacer(Modifier.height(4.dp)) + Spacer( + modifier = Modifier.height(4.dp) + ) Text( text = description, @@ -260,4 +300,4 @@ private fun ServiceCard( ) } } -} \ No newline at end of file +} From 4e10b322617abe739d433a3c2fc2dee42d95b588 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:52:07 +0300 Subject: [PATCH 11/13] add: bound ChangePhoneScreen with backend --- .../goodroad/modules/user/data/UserApi.kt | 5 + .../goodroad/modules/user/data/UserModels.kt | 13 +- .../modules/user/data/UserRepository.kt | 18 ++ .../user/presentation/UserViewModel.kt | 27 +- .../modules/user/screens/ChangePhoneScreen.kt | 3 - .../modules/user/screens/UserEditScreen.kt | 237 +++++++++++++----- 6 files changed, 221 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/com/example/goodroad/modules/user/data/UserApi.kt b/app/src/main/java/com/example/goodroad/modules/user/data/UserApi.kt index f221776..ae756cb 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/data/UserApi.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/data/UserApi.kt @@ -20,6 +20,11 @@ interface UserApi { @Body req: UpdateUserReq ): Response + @PUT("/users/phone") + suspend fun changePhone( + @Body req: ChangePhoneReq + ): Response + @POST("/users") suspend fun changePassword( @Body req: ChangePasswordReq diff --git a/app/src/main/java/com/example/goodroad/modules/user/data/UserModels.kt b/app/src/main/java/com/example/goodroad/modules/user/data/UserModels.kt index db57feb..e9e39c9 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/data/UserModels.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/data/UserModels.kt @@ -1,20 +1,9 @@ package com.example.goodroad.modules.user.data -data class UserDto( - val id: String, - val role: String, - val firstName: String?, - val lastName: String?, - val photoUrl: String?, - val active: Boolean -) - data class UpdateUserReq( val firstName: String? = null, val lastName: String? = null, - val photoUrl: String? = null, - val phone: String? = null, - val currentPassword: String? = null + val photoUrl: String? = null ) data class ChangePhoneReq( diff --git a/app/src/main/java/com/example/goodroad/modules/user/data/UserRepository.kt b/app/src/main/java/com/example/goodroad/modules/user/data/UserRepository.kt index a7233ce..38b3847 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/data/UserRepository.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/data/UserRepository.kt @@ -29,6 +29,24 @@ class UserRepository( throw HttpException(response) } + suspend fun changePhone( + phone: String, + currentPassword: String + ): SettingsView? { + val response = api.changePhone( + ChangePhoneReq( + phone = phone, + currentPassword = currentPassword + ) + ) + + if (response.isSuccessful) { + return response.body() + } + + throw HttpException(response) + } + suspend fun changePassword( oldPassword: String, newPassword: String diff --git a/app/src/main/java/com/example/goodroad/modules/user/presentation/UserViewModel.kt b/app/src/main/java/com/example/goodroad/modules/user/presentation/UserViewModel.kt index 593d60f..6083f37 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/presentation/UserViewModel.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/presentation/UserViewModel.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.goodroad.data.network.ApiClient +import com.example.goodroad.modules.user.data.ChangePhoneReq import com.example.goodroad.modules.user.data.DeleteAccountReq import com.example.goodroad.modules.user.data.SettingsView import com.example.goodroad.modules.user.data.UpdateUserReq @@ -57,8 +58,7 @@ class UserViewModel( fun updateUser( firstName: String, lastName: String, - photoUrl: String? = null, - phone: String? = null + photoUrl: String? = null ) { viewModelScope.launch { isLoading.value = true @@ -77,17 +77,13 @@ class UserViewModel( }, photoUrl = photoUrl.takeIf { it != current?.photoUrl - }, - phone = phone?.takeIf { - it.isNotBlank() } ) val hasChanges = req.firstName != null || req.lastName != null || - req.photoUrl != null || - req.phone != null + req.photoUrl != null if (!hasChanges) { throw IllegalArgumentException( @@ -95,7 +91,15 @@ class UserViewModel( ) } - user.value = repository.updateCurrentUser(req) + val updatedUser = repository.updateCurrentUser(req) + + if (updatedUser == null) { + throw IllegalStateException( + "Сервер не вернул данные пользователя" + ) + } + + user.value = updatedUser successMessage.value = "Профиль обновлён" @@ -130,12 +134,15 @@ class UserViewModel( ) } - val req = UpdateUserReq( + val req = ChangePhoneReq( phone = newPhone.trim(), currentPassword = currentPassword ) - val updatedUser = repository.updateCurrentUser(req) + val updatedUser = repository.changePhone( + phone = req.phone, + currentPassword = req.currentPassword + ) if (updatedUser == null) { throw IllegalStateException( diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt index f6f8f43..809353f 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt @@ -72,7 +72,6 @@ fun ChangePhoneScreen( .fillMaxSize() .padding(24.dp) ) { - Text( text = "Смена телефона", style = MaterialTheme.typography.headlineLarge, @@ -139,7 +138,6 @@ fun ChangePhoneScreen( enabled = !isLoading ) { when (phoneValidation) { - is PhoneValidation.Empty -> { localError = "Введите номер телефона" } @@ -149,7 +147,6 @@ fun ChangePhoneScreen( } is PhoneValidation.Valid -> { - if (currentPassword.isBlank()) { localError = "Введите текущий пароль" return@PrimaryButton diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt index 0e6a2c7..f4efeb0 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt @@ -59,7 +59,9 @@ fun UserEditScreen( var firstName by remember { mutableStateOf(user.firstName ?: "") } var lastName by remember { mutableStateOf(user.lastName ?: "") } var selectedPhotoUri by remember { mutableStateOf(null) } + var phone by remember { mutableStateOf("") } + var oldPassword by remember { mutableStateOf("") } var newPassword by remember { mutableStateOf("") } var confirmNewPassword by remember { mutableStateOf("") } @@ -69,38 +71,38 @@ fun UserEditScreen( var phoneWarning by remember { mutableStateOf(null) } var errorText by remember { mutableStateOf(null) } - val errorMessage by remember { derivedStateOf { userViewModel.errorMessage.value } } - val successMessage by remember { derivedStateOf { userViewModel.successMessage.value } } - val isLoading by remember { derivedStateOf { userViewModel.isLoading.value } } + val errorMessage by userViewModel.errorMessage + val successMessage by userViewModel.successMessage + val isLoading by userViewModel.isLoading + val finalError = errorMessage ?: errorText - val hasProfileChanges by remember(firstName, lastName, phone, selectedPhotoUri, user) { - derivedStateOf { - firstName != (user.firstName ?: "") || - lastName != (user.lastName ?: "") || - phone.isNotBlank() || - selectedPhotoUri != null - } - } + val hasProfileChanges = + firstName != (user.firstName ?: "") || + lastName != (user.lastName ?: "") - val hasPasswordChanges by remember(oldPassword, newPassword, confirmNewPassword) { - derivedStateOf { - oldPassword.isNotBlank() || newPassword.isNotBlank() || confirmNewPassword.isNotBlank() - } - } + val hasPhoneChange = phone.isNotBlank() - val canSave by remember(hasProfileChanges, hasPasswordChanges, isLoading) { - derivedStateOf { - (hasProfileChanges || hasPasswordChanges) && !isLoading - } - } + val hasPasswordChanges = + oldPassword.isNotBlank() || + newPassword.isNotBlank() || + confirmNewPassword.isNotBlank() + + val hasChanges = + hasProfileChanges || + hasPhoneChange || + hasPasswordChanges || + selectedPhotoUri != null + + val canSave = hasChanges && !isLoading val photoPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() ) { uri -> if (uri != null) { selectedPhotoUri = uri - userViewModel.uploadAvatar(context, uri) { _ -> + + userViewModel.uploadAvatar(context, uri) { selectedPhotoUri = null } } @@ -129,9 +131,10 @@ fun UserEditScreen( firstNameWarning = CYRILLIC_WARNING } - value != firstName -> { + else -> { firstName = value firstNameWarning = null + errorText = null } } }, @@ -157,9 +160,10 @@ fun UserEditScreen( lastNameWarning = CYRILLIC_WARNING } - value != lastName -> { + else -> { lastName = value lastNameWarning = null + errorText = null } } }, @@ -175,8 +179,7 @@ fun UserEditScreen( maxLength = NAME_MAX_LENGTH ) - Spacer(Modifier.height(12.dp)) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(20.dp)) Box( modifier = Modifier.fillMaxWidth(), @@ -212,7 +215,9 @@ fun UserEditScreen( color = WhiteSoft, tonalElevation = 2.dp ) { - Box(contentAlignment = Alignment.Center) { + Box( + contentAlignment = Alignment.Center + ) { Icon( imageVector = Icons.Default.Photo, contentDescription = null, @@ -228,7 +233,9 @@ fun UserEditScreen( Spacer(Modifier.height(16.dp)) OutlinedButton( - onClick = { photoPickerLauncher.launch("image/*") }, + onClick = { + photoPickerLauncher.launch("image/*") + }, modifier = Modifier .fillMaxWidth() .height(56.dp), @@ -245,7 +252,9 @@ fun UserEditScreen( tint = UrbanBrown, modifier = Modifier.size(22.dp) ) + Spacer(Modifier.width(12.dp)) + Text( text = "Выбрать фото профиля", style = MaterialTheme.typography.titleMedium, @@ -259,18 +268,29 @@ fun UserEditScreen( value = phone, onValueChange = { value -> phone = value + errorText = null + phoneWarning = when { - !isAllowedDigitsInput(value) -> PHONE_CHARS_WARNING - value.length > 11 -> PHONE_FORMAT_WARNING - value.isNotEmpty() && value.first() !in listOf('7', '8') -> PHONE_FORMAT_WARNING - else -> null + !isAllowedDigitsInput(value) -> + PHONE_CHARS_WARNING + + value.length > 11 -> + PHONE_FORMAT_WARNING + + value.isNotEmpty() && + value.first() !in listOf('7', '8') -> + PHONE_FORMAT_WARNING + + else -> + null } }, - label = "Телефон", + label = "Новый телефон", warning = phoneWarning ) Spacer(Modifier.height(4.dp)) + Text( text = "Оставьте поле пустым, если номер менять не нужно.", style = MaterialTheme.typography.bodySmall, @@ -281,7 +301,10 @@ fun UserEditScreen( PasswordField( value = oldPassword, - onValueChange = { oldPassword = it }, + onValueChange = { + oldPassword = it + errorText = null + }, label = "Старый пароль" ) @@ -289,7 +312,10 @@ fun UserEditScreen( PasswordField( value = newPassword, - onValueChange = { newPassword = it }, + onValueChange = { + newPassword = it + errorText = null + }, label = "Новый пароль" ) @@ -297,11 +323,15 @@ fun UserEditScreen( PasswordField( value = confirmNewPassword, - onValueChange = { confirmNewPassword = it }, + onValueChange = { + confirmNewPassword = it + errorText = null + }, label = "Подтвердите новый пароль" ) Spacer(Modifier.height(4.dp)) + Text( text = "Чтобы сменить пароль, заполните старый пароль и дважды введите новый.", style = MaterialTheme.typography.bodySmall, @@ -315,6 +345,7 @@ fun UserEditScreen( userViewModel.clearMessages() } ) + AuthStatusText( text = finalError, onTimeout = { @@ -326,25 +357,45 @@ fun UserEditScreen( Spacer(Modifier.height(20.dp)) PrimaryButton( - text = if (isLoading) "Сохраняем..." else "Сохранить", + text = if (isLoading) { + "Сохраняем..." + } else { + "Сохранить" + }, enabled = canSave ) { - val firstNameNormalized = normalizeRequiredCyrillic(firstName) + + /* + * Проверяем имя. + */ + val firstNameNormalized = + normalizeRequiredCyrillic(firstName) + if (firstNameNormalized == null) { firstNameWarning = CYRILLIC_WARNING errorText = "Имя должно содержать только кириллицу" return@PrimaryButton } - val lastNameNormalized = normalizeRequiredCyrillic(lastName) + /* + * Проверяем фамилию. + */ + val lastNameNormalized = + normalizeRequiredCyrillic(lastName) + if (lastNameNormalized == null) { lastNameWarning = CYRILLIC_WARNING errorText = "Фамилия должна содержать только кириллицу" return@PrimaryButton } + /* + * Проверяем телефон только если пользователь его ввёл. + */ val phoneDigits = - phone.takeIf { it.isNotBlank() }?.let { normalizeRequiredRussianPhone(it) } + phone + .takeIf { it.isNotBlank() } + ?.let { normalizeRequiredRussianPhone(it) } if (phone.isNotBlank() && phoneDigits == null) { phoneWarning = PHONE_FORMAT_WARNING @@ -352,32 +403,104 @@ fun UserEditScreen( return@PrimaryButton } - val oldPass = oldPassword.takeIf { it.isNotBlank() } - val newPass = newPassword.takeIf { it.isNotBlank() } - val confirmPass = confirmNewPassword.takeIf { it.isNotBlank() } + /* + * Проверяем пароль. + */ + val oldPass = + oldPassword.takeIf { it.isNotBlank() } + + val newPass = + newPassword.takeIf { it.isNotBlank() } + + val confirmPass = + confirmNewPassword.takeIf { it.isNotBlank() } + + val passwordChangeRequested = + oldPass != null || + newPass != null || + confirmPass != null + + if (passwordChangeRequested) { + + if ( + oldPass.isNullOrBlank() || + newPass.isNullOrBlank() || + confirmPass.isNullOrBlank() + ) { + errorText = + "Для смены пароля заполните все три поля" - if (!newPass.isNullOrBlank() || !confirmPass.isNullOrBlank() || !oldPass.isNullOrBlank()) { - if (oldPass.isNullOrBlank() || newPass.isNullOrBlank() || confirmPass.isNullOrBlank()) { - errorText = "Для смены пароля заполните все три поля" return@PrimaryButton } + if (newPass != confirmPass) { - errorText = "Новые пароли не совпадают" + errorText = + "Новые пароли не совпадают" + return@PrimaryButton } } - userViewModel.updateUser( - firstName = firstNameNormalized, - lastName = lastNameNormalized, - phone = phoneDigits?.let { formatPhoneForRequest(it) }, - //oldPassword = oldPass, - //newPassword = newPass - ) + /* + * 1. Сохраняем имя и фамилию. + * + * Телефон сюда НЕ передаём. + */ + if (hasProfileChanges) { + userViewModel.updateUser( + firstName = firstNameNormalized, + lastName = lastNameNormalized + ) + } + + /* + * Если нужно изменить телефон, + * он должен отправляться на отдельный endpoint: + * + * PUT /users/phone + * + * Но для этого нужен текущий пароль. + */ + if (hasPhoneChange) { + + if (oldPass.isNullOrBlank()) { + errorText = + "Для смены телефона введите текущий пароль" + + return@PrimaryButton + } + + userViewModel.changePhone( + newPhone = formatPhoneForRequest( + phoneDigits!! + ), + currentPassword = oldPass, + onSuccess = { + phone = "" + } + ) + } + + /* + * Отдельный endpoint: + * + * POST /users + * + * для смены пароля. + */ + if (passwordChangeRequested) { + + userViewModel.changePassword( + oldPassword = oldPass!!, + newPassword = newPass!!, + onSuccess = { + oldPassword = "" + newPassword = "" + confirmNewPassword = "" + } + ) + } - oldPassword = "" - newPassword = "" - confirmNewPassword = "" errorText = null } } From f99bbaa6f820524f410777dd0348e52ca1211934 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:57:45 +0300 Subject: [PATCH 12/13] fix: UserEditScreen for updated backend --- .../modules/user/screens/ChangePhoneScreen.kt | 31 ++- .../modules/user/screens/UserEditScreen.kt | 260 +----------------- 2 files changed, 39 insertions(+), 252 deletions(-) diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt index 809353f..e866b5f 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt @@ -1,9 +1,12 @@ package com.example.goodroad.ui.user import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.example.goodroad.modules.user.presentation.UserViewModel @@ -13,6 +16,7 @@ import com.example.goodroad.ui.fields.PasswordField import com.example.goodroad.ui.fields.PhoneField import com.example.goodroad.ui.theme.BackgroundLight import com.example.goodroad.ui.theme.TextPrimary +import com.example.goodroad.ui.theme.UrbanBrown import com.example.goodroad.validation.PHONE_FORMAT_WARNING import com.example.goodroad.validation.formatPhoneForRequest import com.example.goodroad.validation.isValidRussianPhoneDigits @@ -72,11 +76,28 @@ fun ChangePhoneScreen( .fillMaxSize() .padding(24.dp) ) { - Text( - text = "Смена телефона", - style = MaterialTheme.typography.headlineLarge, - color = TextPrimary - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Смена телефона", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary, + modifier = Modifier.weight(1f) + ) + + IconButton( + onClick = onBack, + enabled = !isLoading + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Назад", + tint = UrbanBrown.copy(alpha = 0.7f) + ) + } + } Spacer( modifier = Modifier.height(24.dp) diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt index f4efeb0..de9f7f5 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt @@ -23,21 +23,13 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.example.goodroad.modules.user.presentation.UserViewModel import com.example.goodroad.ui.AuthStatusText -import com.example.goodroad.ui.AuthSuccessText import com.example.goodroad.ui.buttons.PrimaryButton -import com.example.goodroad.ui.fields.PasswordField -import com.example.goodroad.ui.fields.PhoneField import com.example.goodroad.ui.fields.PlainField import com.example.goodroad.ui.theme.* import com.example.goodroad.validation.CYRILLIC_WARNING import com.example.goodroad.validation.NAME_MAX_LENGTH -import com.example.goodroad.validation.PHONE_CHARS_WARNING -import com.example.goodroad.validation.PHONE_FORMAT_WARNING -import com.example.goodroad.validation.formatPhoneForRequest import com.example.goodroad.validation.isAllowedCyrillicInput -import com.example.goodroad.validation.isAllowedDigitsInput import com.example.goodroad.validation.normalizeRequiredCyrillic -import com.example.goodroad.validation.normalizeRequiredRussianPhone @Composable fun UserEditScreen( @@ -48,6 +40,13 @@ fun UserEditScreen( val context = LocalContext.current val user = userViewModel.user.value + LaunchedEffect(userViewModel.successMessage.value) { + if (userViewModel.successMessage.value != null) { + userViewModel.clearMessages() + onBack() + } + } + if (user == null) { Box( modifier = Modifier.fillMaxSize(), @@ -60,48 +59,25 @@ fun UserEditScreen( var lastName by remember { mutableStateOf(user.lastName ?: "") } var selectedPhotoUri by remember { mutableStateOf(null) } - var phone by remember { mutableStateOf("") } - - var oldPassword by remember { mutableStateOf("") } - var newPassword by remember { mutableStateOf("") } - var confirmNewPassword by remember { mutableStateOf("") } - var firstNameWarning by remember { mutableStateOf(null) } var lastNameWarning by remember { mutableStateOf(null) } - var phoneWarning by remember { mutableStateOf(null) } var errorText by remember { mutableStateOf(null) } val errorMessage by userViewModel.errorMessage - val successMessage by userViewModel.successMessage val isLoading by userViewModel.isLoading val finalError = errorMessage ?: errorText - val hasProfileChanges = - firstName != (user.firstName ?: "") || - lastName != (user.lastName ?: "") - - val hasPhoneChange = phone.isNotBlank() + val hasProfileChanges = firstName != (user.firstName ?: "") || + lastName != (user.lastName ?: "") - val hasPasswordChanges = - oldPassword.isNotBlank() || - newPassword.isNotBlank() || - confirmNewPassword.isNotBlank() - - val hasChanges = - hasProfileChanges || - hasPhoneChange || - hasPasswordChanges || - selectedPhotoUri != null - - val canSave = hasChanges && !isLoading + val canSave = (hasProfileChanges || selectedPhotoUri != null) && !isLoading val photoPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() ) { uri -> if (uri != null) { selectedPhotoUri = uri - userViewModel.uploadAvatar(context, uri) { selectedPhotoUri = null } @@ -130,7 +106,6 @@ fun UserEditScreen( !isAllowedCyrillicInput(value) -> { firstNameWarning = CYRILLIC_WARNING } - else -> { firstName = value firstNameWarning = null @@ -159,7 +134,6 @@ fun UserEditScreen( !isAllowedCyrillicInput(value) -> { lastNameWarning = CYRILLIC_WARNING } - else -> { lastName = value lastNameWarning = null @@ -196,7 +170,6 @@ fun UserEditScreen( contentScale = ContentScale.Crop ) } - user.photoUrl?.isNotBlank() == true -> { AsyncImage( model = user.photoUrl, @@ -207,7 +180,6 @@ fun UserEditScreen( contentScale = ContentScale.Crop ) } - else -> { Surface( modifier = Modifier.size(120.dp), @@ -252,9 +224,7 @@ fun UserEditScreen( tint = UrbanBrown, modifier = Modifier.size(22.dp) ) - Spacer(Modifier.width(12.dp)) - Text( text = "Выбрать фото профиля", style = MaterialTheme.typography.titleMedium, @@ -262,90 +232,6 @@ fun UserEditScreen( ) } - Spacer(Modifier.height(12.dp)) - - PhoneField( - value = phone, - onValueChange = { value -> - phone = value - errorText = null - - phoneWarning = when { - !isAllowedDigitsInput(value) -> - PHONE_CHARS_WARNING - - value.length > 11 -> - PHONE_FORMAT_WARNING - - value.isNotEmpty() && - value.first() !in listOf('7', '8') -> - PHONE_FORMAT_WARNING - - else -> - null - } - }, - label = "Новый телефон", - warning = phoneWarning - ) - - Spacer(Modifier.height(4.dp)) - - Text( - text = "Оставьте поле пустым, если номер менять не нужно.", - style = MaterialTheme.typography.bodySmall, - color = UrbanBrown - ) - - Spacer(Modifier.height(12.dp)) - - PasswordField( - value = oldPassword, - onValueChange = { - oldPassword = it - errorText = null - }, - label = "Старый пароль" - ) - - Spacer(Modifier.height(12.dp)) - - PasswordField( - value = newPassword, - onValueChange = { - newPassword = it - errorText = null - }, - label = "Новый пароль" - ) - - Spacer(Modifier.height(12.dp)) - - PasswordField( - value = confirmNewPassword, - onValueChange = { - confirmNewPassword = it - errorText = null - }, - label = "Подтвердите новый пароль" - ) - - Spacer(Modifier.height(4.dp)) - - Text( - text = "Чтобы сменить пароль, заполните старый пароль и дважды введите новый.", - style = MaterialTheme.typography.bodySmall, - color = UrbanBrown - ) - - AuthSuccessText( - text = successMessage, - onTimeout = { - errorText = null - userViewModel.clearMessages() - } - ) - AuthStatusText( text = finalError, onTimeout = { @@ -357,95 +243,23 @@ fun UserEditScreen( Spacer(Modifier.height(20.dp)) PrimaryButton( - text = if (isLoading) { - "Сохраняем..." - } else { - "Сохранить" - }, + text = if (isLoading) "Сохраняем..." else "Сохранить", enabled = canSave ) { - - /* - * Проверяем имя. - */ - val firstNameNormalized = - normalizeRequiredCyrillic(firstName) - + val firstNameNormalized = normalizeRequiredCyrillic(firstName) if (firstNameNormalized == null) { firstNameWarning = CYRILLIC_WARNING errorText = "Имя должно содержать только кириллицу" return@PrimaryButton } - /* - * Проверяем фамилию. - */ - val lastNameNormalized = - normalizeRequiredCyrillic(lastName) - + val lastNameNormalized = normalizeRequiredCyrillic(lastName) if (lastNameNormalized == null) { lastNameWarning = CYRILLIC_WARNING errorText = "Фамилия должна содержать только кириллицу" return@PrimaryButton } - /* - * Проверяем телефон только если пользователь его ввёл. - */ - val phoneDigits = - phone - .takeIf { it.isNotBlank() } - ?.let { normalizeRequiredRussianPhone(it) } - - if (phone.isNotBlank() && phoneDigits == null) { - phoneWarning = PHONE_FORMAT_WARNING - errorText = "Некорректный телефон" - return@PrimaryButton - } - - /* - * Проверяем пароль. - */ - val oldPass = - oldPassword.takeIf { it.isNotBlank() } - - val newPass = - newPassword.takeIf { it.isNotBlank() } - - val confirmPass = - confirmNewPassword.takeIf { it.isNotBlank() } - - val passwordChangeRequested = - oldPass != null || - newPass != null || - confirmPass != null - - if (passwordChangeRequested) { - - if ( - oldPass.isNullOrBlank() || - newPass.isNullOrBlank() || - confirmPass.isNullOrBlank() - ) { - errorText = - "Для смены пароля заполните все три поля" - - return@PrimaryButton - } - - if (newPass != confirmPass) { - errorText = - "Новые пароли не совпадают" - - return@PrimaryButton - } - } - - /* - * 1. Сохраняем имя и фамилию. - * - * Телефон сюда НЕ передаём. - */ if (hasProfileChanges) { userViewModel.updateUser( firstName = firstNameNormalized, @@ -453,54 +267,6 @@ fun UserEditScreen( ) } - /* - * Если нужно изменить телефон, - * он должен отправляться на отдельный endpoint: - * - * PUT /users/phone - * - * Но для этого нужен текущий пароль. - */ - if (hasPhoneChange) { - - if (oldPass.isNullOrBlank()) { - errorText = - "Для смены телефона введите текущий пароль" - - return@PrimaryButton - } - - userViewModel.changePhone( - newPhone = formatPhoneForRequest( - phoneDigits!! - ), - currentPassword = oldPass, - onSuccess = { - phone = "" - } - ) - } - - /* - * Отдельный endpoint: - * - * POST /users - * - * для смены пароля. - */ - if (passwordChangeRequested) { - - userViewModel.changePassword( - oldPassword = oldPass!!, - newPassword = newPass!!, - onSuccess = { - oldPassword = "" - newPassword = "" - confirmNewPassword = "" - } - ) - } - errorText = null } } From a3b507529cfe8d38057d3100bd3106678c1403a8 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:41:12 +0300 Subject: [PATCH 13/13] refactor: unify PhoneField in all files where it uses --- .../modules/auth/screens/LoginScreen.kt | 33 +---- .../auth/screens/RecoverPasswordScreen.kt | 44 +++--- .../modules/auth/screens/RegisterScreen.kt | 57 +++----- .../screens/ModeratorsManagementScreen.kt | 44 +++--- .../modules/user/screens/ChangePhoneScreen.kt | 60 ++------ .../screens/HelpRequestCreateScreen.kt | 58 ++++---- .../example/goodroad/ui/fields/PhoneField.kt | 133 ++++++++++++++++-- 7 files changed, 226 insertions(+), 203 deletions(-) diff --git a/app/src/main/java/com/example/goodroad/modules/auth/screens/LoginScreen.kt b/app/src/main/java/com/example/goodroad/modules/auth/screens/LoginScreen.kt index 0574111..52812fe 100644 --- a/app/src/main/java/com/example/goodroad/modules/auth/screens/LoginScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/auth/screens/LoginScreen.kt @@ -15,10 +15,6 @@ import com.example.goodroad.ui.AuthFooter import com.example.goodroad.ui.AuthScreenFrame import com.example.goodroad.ui.AuthStatusText import com.example.goodroad.ui.fields.* -import com.example.goodroad.validation.PHONE_FORMAT_WARNING -import com.example.goodroad.validation.formatPhoneForRequest -import com.example.goodroad.validation.isValidRussianPhoneDigits -import com.example.goodroad.validation.normalizeRequiredRussianPhone @Composable fun LoginScreen( @@ -38,16 +34,6 @@ fun LoginScreen( loginResult?.let { onLoginSuccess(it) } } - val phoneValidation = remember(phone) { - when { - phone.isEmpty() -> PhoneValidation.Empty - !isValidRussianPhoneDigits(phone.trim()) -> PhoneValidation.InvalidFormat - phone.length > 11 -> PhoneValidation.InvalidFormat - phone.first() !in listOf('7', '8') -> PhoneValidation.InvalidFormat - else -> PhoneValidation.Valid(normalizeRequiredRussianPhone(phone)!!) - } - } - AuthScreenFrame( title = "Вход", action = { @@ -55,11 +41,16 @@ fun LoginScreen( text = if (loading) "Входим..." else "Войти", enabled = !loading ) { + val phoneValidation = validatePhone(phone) when (phoneValidation) { is PhoneValidation.Empty -> { viewModel.setError("Введите номер телефона") return@PrimaryButton } + is PhoneValidation.InvalidChars -> { + viewModel.setError("Телефон должен содержать только цифры") + return@PrimaryButton + } is PhoneValidation.InvalidFormat -> { viewModel.setError("Введите корректный номер телефона") return@PrimaryButton @@ -70,7 +61,7 @@ fun LoginScreen( return@PrimaryButton } viewModel.login( - formatPhoneForRequest(phoneValidation.phoneDigits), + phoneValidation.toFormattedPhone()!!, password ) } @@ -91,11 +82,7 @@ fun LoginScreen( phone = it viewModel.clearError() }, - label = "Телефон", - warning = when (phoneValidation) { - is PhoneValidation.InvalidFormat -> PHONE_FORMAT_WARNING - else -> null - } + label = "Телефон" ) Spacer(Modifier.height(12.dp)) @@ -125,10 +112,4 @@ fun LoginScreen( onTimeout = viewModel::clearError ) } -} - -sealed class PhoneValidation { - object Empty : PhoneValidation() - object InvalidFormat : PhoneValidation() - data class Valid(val phoneDigits: String) : PhoneValidation() } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/auth/screens/RecoverPasswordScreen.kt b/app/src/main/java/com/example/goodroad/modules/auth/screens/RecoverPasswordScreen.kt index c040dc3..0024be3 100644 --- a/app/src/main/java/com/example/goodroad/modules/auth/screens/RecoverPasswordScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/auth/screens/RecoverPasswordScreen.kt @@ -24,13 +24,8 @@ import com.example.goodroad.ui.theme.UrbanBrown import com.example.goodroad.modules.auth.presentation.AuthViewModel import com.example.goodroad.validation.CYRILLIC_WARNING import com.example.goodroad.validation.NAME_MAX_LENGTH -import com.example.goodroad.validation.PHONE_CHARS_WARNING -import com.example.goodroad.validation.PHONE_FORMAT_WARNING -import com.example.goodroad.validation.formatPhoneForRequest import com.example.goodroad.validation.isAllowedCyrillicInput -import com.example.goodroad.validation.isAllowedDigitsInput import com.example.goodroad.validation.normalizeRequiredCyrillic -import com.example.goodroad.validation.normalizeRequiredRussianPhone @Composable fun RecoverPasswordScreen( @@ -44,7 +39,6 @@ fun RecoverPasswordScreen( var confirmPassword by rememberSaveable { mutableStateOf("") } var firstNameWarning by rememberSaveable { mutableStateOf(null) } var lastNameWarning by rememberSaveable { mutableStateOf(null) } - var phoneWarning by rememberSaveable { mutableStateOf(null) } var errorText by rememberSaveable { mutableStateOf(null) } val viewModel: AuthViewModel = viewModel() @@ -70,14 +64,22 @@ fun RecoverPasswordScreen( val lastNameNormalized = normalizeRequiredCyrillic(lastName) if (lastNameNormalized == null) { lastNameWarning = CYRILLIC_WARNING - errorText = - "Фамилия обязательна и должна содержать только кириллицу, пробел и -" + errorText = "Фамилия обязательна и должна содержать только кириллицу, пробел и -" return@PrimaryButton } - val phoneDigits = normalizeRequiredRussianPhone(phone) - if (phoneDigits == null || newPassword.isBlank() || confirmPassword.isBlank()) { - phoneWarning = PHONE_FORMAT_WARNING + val phoneValidation = validatePhone(phone) + if (phoneValidation !is PhoneValidation.Valid) { + errorText = when (phoneValidation) { + is PhoneValidation.Empty -> "Введите номер телефона" + is PhoneValidation.InvalidChars -> "Телефон должен содержать только цифры" + is PhoneValidation.InvalidFormat -> "Введите корректный номер телефона" + else -> "Заполните все поля" + } + return@PrimaryButton + } + + if (newPassword.isBlank() || confirmPassword.isBlank()) { errorText = "Заполните все поля" return@PrimaryButton } @@ -89,7 +91,7 @@ fun RecoverPasswordScreen( errorText = null viewModel.recoverPassword( - phone = formatPhoneForRequest(phoneDigits), + phone = phoneValidation.toFormattedPhone()!!, firstName = firstNameNormalized, lastName = lastNameNormalized, newPassword = newPassword @@ -156,20 +158,10 @@ fun RecoverPasswordScreen( PhoneField( value = phone, - onValueChange = { value -> - when { - !isAllowedDigitsInput(value) -> phoneWarning = PHONE_CHARS_WARNING - value.length > 11 || value.isNotEmpty() && value.first() !in listOf('7', '8') -> - phoneWarning = PHONE_FORMAT_WARNING - - else -> { - phone = value - phoneWarning = null - } - } + onValueChange = { + phone = it }, - label = "Телефон", - warning = phoneWarning + label = "Телефон" ) Spacer(Modifier.height(12.dp)) @@ -202,4 +194,4 @@ fun RecoverPasswordScreen( } ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/auth/screens/RegisterScreen.kt b/app/src/main/java/com/example/goodroad/modules/auth/screens/RegisterScreen.kt index 29bdf08..551538d 100644 --- a/app/src/main/java/com/example/goodroad/modules/auth/screens/RegisterScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/auth/screens/RegisterScreen.kt @@ -21,13 +21,8 @@ import com.example.goodroad.ui.theme.UrbanBrown import com.example.goodroad.modules.auth.presentation.AuthViewModel import com.example.goodroad.validation.CYRILLIC_WARNING import com.example.goodroad.validation.NAME_MAX_LENGTH -import com.example.goodroad.validation.PHONE_CHARS_WARNING -import com.example.goodroad.validation.PHONE_FORMAT_WARNING -import com.example.goodroad.validation.formatPhoneForRequest import com.example.goodroad.validation.isAllowedCyrillicInput -import com.example.goodroad.validation.isAllowedDigitsInput import com.example.goodroad.validation.normalizeRequiredCyrillic -import com.example.goodroad.validation.normalizeRequiredRussianPhone @Composable fun RegisterScreen( @@ -42,7 +37,6 @@ fun RegisterScreen( var confirmPassword by rememberSaveable { mutableStateOf("") } var firstNameWarning by rememberSaveable { mutableStateOf(null) } var lastNameWarning by rememberSaveable { mutableStateOf(null) } - var phoneWarning by rememberSaveable { mutableStateOf(null) } var errorText by rememberSaveable { mutableStateOf(null) } val viewModel: AuthViewModel = viewModel() @@ -73,15 +67,23 @@ fun RegisterScreen( val lastNameNormalized = normalizeRequiredCyrillic(lastName) if (lastNameNormalized == null) { lastNameWarning = CYRILLIC_WARNING - errorText = - "Фамилия обязательна и должна содержать только кириллицу, пробел и -" + errorText = "Фамилия обязательна и должна содержать только кириллицу, пробел и -" return@PrimaryButton } - val phoneDigits = normalizeRequiredRussianPhone(phone) - if (phoneDigits == null || password.isBlank()) { - phoneWarning = PHONE_FORMAT_WARNING - errorText = "Телефон и пароль обязательны" + val phoneValidation = validatePhone(phone) + if (phoneValidation !is PhoneValidation.Valid) { + errorText = when (phoneValidation) { + is PhoneValidation.Empty -> "Введите номер телефона" + is PhoneValidation.InvalidChars -> "Телефон должен содержать только цифры" + is PhoneValidation.InvalidFormat -> "Введите корректный номер телефона" + else -> "Заполните все поля" + } + return@PrimaryButton + } + + if (password.isBlank()) { + errorText = "Введите пароль" return@PrimaryButton } @@ -96,10 +98,11 @@ fun RegisterScreen( return@PrimaryButton } + errorText = null viewModel.register( firstNameNormalized, lastNameNormalized, - formatPhoneForRequest(phoneDigits), + phoneValidation.toFormattedPhone()!!, password ) } @@ -119,7 +122,6 @@ fun RegisterScreen( !isAllowedCyrillicInput(value) -> { firstNameWarning = CYRILLIC_WARNING } - value != firstName -> { firstName = value firstNameWarning = null @@ -147,7 +149,6 @@ fun RegisterScreen( !isAllowedCyrillicInput(value) -> { lastNameWarning = CYRILLIC_WARNING } - value != lastName -> { lastName = value lastNameWarning = null @@ -170,30 +171,10 @@ fun RegisterScreen( PhoneField( value = phone, - onValueChange = { value -> - when { - !isAllowedDigitsInput(value) -> { - phoneWarning = PHONE_CHARS_WARNING - } - - value.length > 11 -> { - phoneWarning = PHONE_FORMAT_WARNING - } - - value.isNotEmpty() && value.first() !in listOf('7', '8') -> { - phoneWarning = PHONE_FORMAT_WARNING - } - - else -> { - if (value != phone) { - phone = value - phoneWarning = null - } - } - } + onValueChange = { + phone = it }, - label = "Телефон", - warning = phoneWarning + label = "Телефон" ) Spacer(Modifier.height(12.dp)) diff --git a/app/src/main/java/com/example/goodroad/modules/moderator/screens/ModeratorsManagementScreen.kt b/app/src/main/java/com/example/goodroad/modules/moderator/screens/ModeratorsManagementScreen.kt index 47a3eac..fc0513e 100644 --- a/app/src/main/java/com/example/goodroad/modules/moderator/screens/ModeratorsManagementScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/moderator/screens/ModeratorsManagementScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -47,7 +48,9 @@ import com.example.goodroad.ui.buttons.PrimaryButton import com.example.goodroad.ui.fields.PasswordField import com.example.goodroad.ui.fields.PhoneField import com.example.goodroad.ui.fields.PlainField -import androidx.compose.foundation.layout.size +import com.example.goodroad.ui.fields.PhoneValidation +import com.example.goodroad.ui.fields.validatePhone +import com.example.goodroad.ui.fields.toFormattedPhone import com.example.goodroad.ui.theme.AlertRed import com.example.goodroad.ui.theme.BackgroundLight import com.example.goodroad.ui.theme.BorderWarm @@ -57,10 +60,6 @@ import com.example.goodroad.ui.theme.SurfaceWarm import com.example.goodroad.ui.theme.TextPrimary import com.example.goodroad.ui.theme.UrbanBrown import com.example.goodroad.ui.theme.WarningOrange -import com.example.goodroad.validation.PHONE_FORMAT_WARNING -import com.example.goodroad.validation.formatPhoneForRequest -import com.example.goodroad.validation.isAllowedDigitsInput -import com.example.goodroad.validation.normalizeRequiredRussianPhone @Composable private fun NameField( @@ -385,7 +384,6 @@ private fun AddModeratorDialog( var lastName by remember { mutableStateOf("") } var phone by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } - var phoneWarning by remember { mutableStateOf(null) } var errorText by remember { mutableStateOf(null) } AlertDialog( @@ -393,17 +391,27 @@ private fun AddModeratorDialog( confirmButton = { Button( onClick = { - val phoneDigits = normalizeRequiredRussianPhone(phone) - if (firstName.isBlank() || lastName.isBlank() || password.isBlank() || phoneDigits == null) { - errorText = "Заполните все поля корректно" - phoneWarning = if (phoneDigits == null && phone.isNotBlank()) PHONE_FORMAT_WARNING else null + val phoneValidation = validatePhone(phone) + if (firstName.isBlank() || lastName.isBlank() || password.isBlank()) { + errorText = "Заполните все поля" + return@Button + } + + if (phoneValidation !is PhoneValidation.Valid) { + errorText = when (phoneValidation) { + is PhoneValidation.Empty -> "Введите номер телефона" + is PhoneValidation.InvalidChars -> "Телефон должен содержать только цифры" + is PhoneValidation.InvalidFormat -> "Введите корректный номер телефона" + else -> "Заполните все поля" + } return@Button } + errorText = null onAdd( firstName.trim(), lastName.trim(), - formatPhoneForRequest(phoneDigits), + phoneValidation.toFormattedPhone()!!, password ) } @@ -429,18 +437,10 @@ private fun AddModeratorDialog( ) PhoneField( value = phone, - onValueChange = { value -> - phone = value - phoneWarning = when { - value.isEmpty() -> null - !isAllowedDigitsInput(value) -> PHONE_FORMAT_WARNING - value.length > 11 -> PHONE_FORMAT_WARNING - value.firstOrNull() !in listOf('7', '8') -> PHONE_FORMAT_WARNING - else -> null - } + onValueChange = { + phone = it }, - label = "Телефон", - warning = phoneWarning + label = "Телефон" ) PasswordField( value = password, diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt index e866b5f..3d629ac 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt @@ -14,13 +14,12 @@ import com.example.goodroad.ui.AuthStatusText import com.example.goodroad.ui.buttons.PrimaryButton import com.example.goodroad.ui.fields.PasswordField import com.example.goodroad.ui.fields.PhoneField +import com.example.goodroad.ui.fields.PhoneValidation +import com.example.goodroad.ui.fields.validatePhone +import com.example.goodroad.ui.fields.toFormattedPhone import com.example.goodroad.ui.theme.BackgroundLight import com.example.goodroad.ui.theme.TextPrimary import com.example.goodroad.ui.theme.UrbanBrown -import com.example.goodroad.validation.PHONE_FORMAT_WARNING -import com.example.goodroad.validation.formatPhoneForRequest -import com.example.goodroad.validation.isValidRussianPhoneDigits -import com.example.goodroad.validation.normalizeRequiredRussianPhone @Composable fun ChangePhoneScreen( @@ -37,31 +36,6 @@ fun ChangePhoneScreen( val serverError by userViewModel.errorMessage val isLoading by userViewModel.isLoading - val phoneValidation = remember(phone) { - when { - phone.isEmpty() -> PhoneValidation.Empty - - !isValidRussianPhoneDigits(phone.trim()) -> - PhoneValidation.InvalidFormat - - phone.length > 11 -> - PhoneValidation.InvalidFormat - - phone.first() !in listOf('7', '8') -> - PhoneValidation.InvalidFormat - - else -> { - val normalized = normalizeRequiredRussianPhone(phone) - - if (normalized != null) { - PhoneValidation.Valid(normalized) - } else { - PhoneValidation.InvalidFormat - } - } - } - } - LaunchedEffect(Unit) { userViewModel.clearMessages() localError = null @@ -110,14 +84,7 @@ fun ChangePhoneScreen( localError = null userViewModel.clearMessages() }, - label = "Новый телефон", - warning = when (phoneValidation) { - is PhoneValidation.InvalidFormat -> - PHONE_FORMAT_WARNING - - else -> - null - } + label = "Новый телефон" ) Spacer( @@ -158,11 +125,16 @@ fun ChangePhoneScreen( }, enabled = !isLoading ) { + val phoneValidation = validatePhone(phone) when (phoneValidation) { is PhoneValidation.Empty -> { localError = "Введите номер телефона" } + is PhoneValidation.InvalidChars -> { + localError = "Телефон должен содержать только цифры" + } + is PhoneValidation.InvalidFormat -> { localError = "Введите корректный номер телефона" } @@ -176,9 +148,7 @@ fun ChangePhoneScreen( localError = null userViewModel.changePhone( - newPhone = formatPhoneForRequest( - phoneValidation.phoneDigits - ), + newPhone = phoneValidation.toFormattedPhone()!!, currentPassword = currentPassword, onSuccess = onBack ) @@ -187,14 +157,4 @@ fun ChangePhoneScreen( } } } -} - -private sealed class PhoneValidation { - data object Empty : PhoneValidation() - - data object InvalidFormat : PhoneValidation() - - data class Valid( - val phoneDigits: String - ) : PhoneValidation() } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/volunteer/screens/HelpRequestCreateScreen.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/HelpRequestCreateScreen.kt index 9ee9632..c398295 100644 --- a/app/src/main/java/com/example/goodroad/modules/volunteer/screens/HelpRequestCreateScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/HelpRequestCreateScreen.kt @@ -24,13 +24,15 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.TextRange import androidx.compose.ui.unit.dp import com.example.goodroad.modules.volunteer.presentation.VolunteerViewModel import com.example.goodroad.ui.UserDecor import com.example.goodroad.ui.buttons.PrimaryButton +import com.example.goodroad.ui.fields.PhoneField +import com.example.goodroad.ui.fields.PhoneValidation +import com.example.goodroad.ui.fields.validatePhone +import com.example.goodroad.ui.fields.toFormattedPhone import com.example.goodroad.ui.theme.BackgroundLight -import com.example.goodroad.validation.isValidRussianPhoneDigits import java.time.LocalDateTime @Composable @@ -105,16 +107,21 @@ fun HelpRequestCreateScreen( } } - contactError = when { - contact.isBlank() -> { + val phoneValidation = validatePhone(contact) + contactError = when (phoneValidation) { + is PhoneValidation.Empty -> { valid = false "Обязательное поле" } - !isValidRussianPhoneDigits(contact.trim()) -> { + is PhoneValidation.InvalidChars -> { + valid = false + "Телефон должен содержать только цифры" + } + is PhoneValidation.InvalidFormat -> { valid = false "Введите корректный номер телефона" } - else -> null + is PhoneValidation.Valid -> null } socialNicknameError = null @@ -214,16 +221,16 @@ fun HelpRequestCreateScreen( Spacer(Modifier.height(12.dp)) - PhoneFieldForRequest( + PhoneField( value = contact, onValueChange = { contact = it contactError = null }, label = "Номер телефона *", - isError = contactError != null, - supportingText = contactError, - modifier = Modifier.fillMaxWidth() + showPrefix = false, + showIcon = false, + allowEmptyWarning = true ) Spacer(Modifier.height(12.dp)) @@ -293,12 +300,19 @@ fun HelpRequestCreateScreen( meetingTime } + // Получаем форматированный номер + val phoneValidation = validatePhone(contact) + val formattedPhone = when (phoneValidation) { + is PhoneValidation.Valid -> phoneValidation.toFormattedPhone()!! + else -> contact // fallback + } + helpViewModel.createRequest( routeStart = routeStart, routeEnd = routeEnd, meetingDate = formattedDate, meetingTime = formattedTime, - contact = contact, + contact = formattedPhone, socialNickname = socialNickname, comment = comment ) { @@ -311,28 +325,6 @@ fun HelpRequestCreateScreen( } } -@Composable -fun PhoneFieldForRequest( - value: String, - onValueChange: (String) -> Unit, - label: String, - isError: Boolean = false, - supportingText: String? = null, - modifier: Modifier = Modifier -) { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(label) }, - isError = isError, - supportingText = { supportingText?.let { Text(it) } }, - modifier = modifier, - shape = MaterialTheme.shapes.large, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone) - ) -} - private object DateVisualTransformation : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { val digits = text.text.filter { it.isDigit() }.take(8) diff --git a/app/src/main/java/com/example/goodroad/ui/fields/PhoneField.kt b/app/src/main/java/com/example/goodroad/ui/fields/PhoneField.kt index c8ddb9d..1323a58 100644 --- a/app/src/main/java/com/example/goodroad/ui/fields/PhoneField.kt +++ b/app/src/main/java/com/example/goodroad/ui/fields/PhoneField.kt @@ -5,28 +5,145 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Phone import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.text.input.KeyboardType import com.example.goodroad.ui.theme.* -import com.example.goodroad.validation.PHONE_MAX_LENGTH +import com.example.goodroad.validation.* @Composable fun PhoneField( value: String, onValueChange: (String) -> Unit, label: String, - warning: String? = null, - maxLength: Int = PHONE_MAX_LENGTH + maxLength: Int = PHONE_MAX_LENGTH, + showPrefix: Boolean = true, + showIcon: Boolean = true, + validateOnInput: Boolean = true, + allowEmptyWarning: Boolean = false ) { + val validation = remember(value) { + validatePhone(value, allowEmpty = allowEmptyWarning) + } + + val warning = when (validation) { + is PhoneValidation.InvalidChars -> PHONE_CHARS_WARNING + is PhoneValidation.InvalidFormat -> PHONE_FORMAT_WARNING + is PhoneValidation.Empty -> if (allowEmptyWarning) "Обязательное поле" else null + is PhoneValidation.Valid -> null + } + PlainField( value = value, - onValueChange = onValueChange, + onValueChange = { newValue -> + if (validateOnInput && newValue.any { !it.isDigit() }) { + return@PlainField + } + + val digitsOnly = newValue.filter { it.isDigit() } + val limited = digitsOnly.take(maxLength) + + if (limited != value) { + onValueChange(limited) + } + }, label = label, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), - icon = { - Icon(Icons.Default.Phone, contentDescription = null, tint = UrbanBrown) - }, + icon = if (showIcon) { + { + Icon( + imageVector = Icons.Default.Phone, + contentDescription = null, + tint = UrbanBrown + ) + } + } else null, warning = warning, maxLength = maxLength, - prefix = { androidx.compose.material3.Text("+") } + prefix = if (showPrefix) { + { androidx.compose.material3.Text("+") } + } else null ) +} + +// Единая функция валидации для всех случаев +fun validatePhone( + phone: String, + allowEmpty: Boolean = false +): PhoneValidation { + val trimmed = phone.trim() + + if (trimmed.isEmpty()) { + return PhoneValidation.Empty + } + + if (!trimmed.all { it.isDigit() }) { + return PhoneValidation.InvalidChars + } + + if (trimmed.length > PHONE_MAX_LENGTH) { + return PhoneValidation.InvalidFormat + } + + if (trimmed.first() !in listOf('7', '8')) { + return PhoneValidation.InvalidFormat + } + + if (!isValidRussianPhoneDigits(trimmed)) { + return PhoneValidation.InvalidFormat + } + + val normalized = normalizeRequiredRussianPhone(trimmed) + return if (normalized != null) { + PhoneValidation.Valid(normalized) + } else { + PhoneValidation.InvalidFormat + } +} + +// Проверка с кастомными сообщениями для отправки +fun getPhoneErrorMessage( + phone: String, + required: Boolean = true +): String? { + val trimmed = phone.trim() + + if (required && trimmed.isEmpty()) { + return "Обязательное поле" + } + + if (trimmed.isEmpty()) { + return null + } + + val validation = validatePhone(phone, allowEmpty = true) + + return when (validation) { + is PhoneValidation.InvalidChars -> "Телефон должен содержать только цифры" + is PhoneValidation.InvalidFormat -> "Введите корректный номер телефона (11 цифр, начинается с 7 или 8)" + is PhoneValidation.Empty -> null + is PhoneValidation.Valid -> null + } +} + +// Получить форматированный номер для отправки +fun getFormattedPhoneOrNull(phone: String): String? { + val validation = validatePhone(phone) + return when (validation) { + is PhoneValidation.Valid -> formatPhoneForRequest(validation.phoneDigits) + else -> null + } +} + +sealed class PhoneValidation { + data object Empty : PhoneValidation() + data object InvalidChars : PhoneValidation() + data object InvalidFormat : PhoneValidation() + data class Valid(val phoneDigits: String) : PhoneValidation() +} + +fun PhoneValidation.toFormattedPhone(): String? { + return when (this) { + is PhoneValidation.Valid -> formatPhoneForRequest(phoneDigits) + else -> null + } } \ No newline at end of file