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 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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