diff --git a/app/build.gradle b/app/build.gradle index 9e03945..8bb9250 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,8 +18,7 @@ android { testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' buildConfigField "boolean", "MOCK_AUTH", "false" - buildConfigField "String", "GOODROAD_SERVER_URL", "\"https://93.77.188.216.nip.io/\"" - } + buildConfigField "String", "GOODROAD_SERVER_URL", "\"http://10.0.2.2:8080/\"" } buildTypes { release { 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/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..52812fe 100644 --- a/app/src/main/java/com/example/goodroad/modules/auth/screens/LoginScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/auth/screens/LoginScreen.kt @@ -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 @@ -17,11 +15,6 @@ import com.example.goodroad.ui.AuthFooter import com.example.goodroad.ui.AuthScreenFrame import com.example.goodroad.ui.AuthStatusText import com.example.goodroad.ui.fields.* -import com.example.goodroad.validation.PHONE_FORMAT_WARNING -import com.example.goodroad.validation.formatPhoneForRequest -import com.example.goodroad.validation.isAllowedDigitsInput -import com.example.goodroad.validation.isValidRussianPhoneDigits -import com.example.goodroad.validation.normalizeRequiredRussianPhone @Composable fun LoginScreen( @@ -32,20 +25,13 @@ 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 { resp -> - onLoginSuccess(resp) - } + loginResult?.let { onLoginSuccess(it) } } AuthScreenFrame( @@ -55,20 +41,31 @@ 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 + val phoneValidation = validatePhone(phone) + when (phoneValidation) { + is PhoneValidation.Empty -> { + viewModel.setError("Введите номер телефона") + return@PrimaryButton + } + is PhoneValidation.InvalidChars -> { + viewModel.setError("Телефон должен содержать только цифры") + return@PrimaryButton + } + is PhoneValidation.InvalidFormat -> { + viewModel.setError("Введите корректный номер телефона") + return@PrimaryButton + } + is PhoneValidation.Valid -> { + if (password.isBlank()) { + viewModel.setError("Введите пароль") + return@PrimaryButton + } + viewModel.login( + phoneValidation.toFormattedPhone()!!, + password + ) + } } - - errorText = null - viewModel.login(formatPhoneForRequest(phoneDigits), password) } }, footer = { @@ -79,31 +76,23 @@ 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 + label = "Телефон" ) Spacer(Modifier.height(12.dp)) PasswordField( value = password, - onValueChange = { password = it }, + onValueChange = { + password = it + viewModel.clearError() + }, label = "Пароль" ) @@ -119,11 +108,8 @@ fun LoginScreen( } AuthStatusText( - text = error ?: errorText, - onTimeout = { - errorText = null - viewModel.clearError() - } + text = error, + onTimeout = viewModel::clearError ) } } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/auth/screens/RecoverPasswordScreen.kt b/app/src/main/java/com/example/goodroad/modules/auth/screens/RecoverPasswordScreen.kt index c040dc3..0024be3 100644 --- a/app/src/main/java/com/example/goodroad/modules/auth/screens/RecoverPasswordScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/auth/screens/RecoverPasswordScreen.kt @@ -24,13 +24,8 @@ import com.example.goodroad.ui.theme.UrbanBrown import com.example.goodroad.modules.auth.presentation.AuthViewModel import com.example.goodroad.validation.CYRILLIC_WARNING import com.example.goodroad.validation.NAME_MAX_LENGTH -import com.example.goodroad.validation.PHONE_CHARS_WARNING -import com.example.goodroad.validation.PHONE_FORMAT_WARNING -import com.example.goodroad.validation.formatPhoneForRequest import com.example.goodroad.validation.isAllowedCyrillicInput -import com.example.goodroad.validation.isAllowedDigitsInput import com.example.goodroad.validation.normalizeRequiredCyrillic -import com.example.goodroad.validation.normalizeRequiredRussianPhone @Composable fun RecoverPasswordScreen( @@ -44,7 +39,6 @@ fun RecoverPasswordScreen( var confirmPassword by rememberSaveable { mutableStateOf("") } var firstNameWarning by rememberSaveable { mutableStateOf(null) } var lastNameWarning by rememberSaveable { mutableStateOf(null) } - var phoneWarning by rememberSaveable { mutableStateOf(null) } var errorText by rememberSaveable { mutableStateOf(null) } val viewModel: AuthViewModel = viewModel() @@ -70,14 +64,22 @@ fun RecoverPasswordScreen( val lastNameNormalized = normalizeRequiredCyrillic(lastName) if (lastNameNormalized == null) { lastNameWarning = CYRILLIC_WARNING - errorText = - "Фамилия обязательна и должна содержать только кириллицу, пробел и -" + errorText = "Фамилия обязательна и должна содержать только кириллицу, пробел и -" return@PrimaryButton } - val phoneDigits = normalizeRequiredRussianPhone(phone) - if (phoneDigits == null || newPassword.isBlank() || confirmPassword.isBlank()) { - phoneWarning = PHONE_FORMAT_WARNING + val phoneValidation = validatePhone(phone) + if (phoneValidation !is PhoneValidation.Valid) { + errorText = when (phoneValidation) { + is PhoneValidation.Empty -> "Введите номер телефона" + is PhoneValidation.InvalidChars -> "Телефон должен содержать только цифры" + is PhoneValidation.InvalidFormat -> "Введите корректный номер телефона" + else -> "Заполните все поля" + } + return@PrimaryButton + } + + if (newPassword.isBlank() || confirmPassword.isBlank()) { errorText = "Заполните все поля" return@PrimaryButton } @@ -89,7 +91,7 @@ fun RecoverPasswordScreen( errorText = null viewModel.recoverPassword( - phone = formatPhoneForRequest(phoneDigits), + phone = phoneValidation.toFormattedPhone()!!, firstName = firstNameNormalized, lastName = lastNameNormalized, newPassword = newPassword @@ -156,20 +158,10 @@ fun RecoverPasswordScreen( PhoneField( value = phone, - onValueChange = { value -> - when { - !isAllowedDigitsInput(value) -> phoneWarning = PHONE_CHARS_WARNING - value.length > 11 || value.isNotEmpty() && value.first() !in listOf('7', '8') -> - phoneWarning = PHONE_FORMAT_WARNING - - else -> { - phone = value - phoneWarning = null - } - } + onValueChange = { + phone = it }, - label = "Телефон", - warning = phoneWarning + label = "Телефон" ) Spacer(Modifier.height(12.dp)) @@ -202,4 +194,4 @@ fun RecoverPasswordScreen( } ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/auth/screens/RegisterScreen.kt b/app/src/main/java/com/example/goodroad/modules/auth/screens/RegisterScreen.kt index 29bdf08..551538d 100644 --- a/app/src/main/java/com/example/goodroad/modules/auth/screens/RegisterScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/auth/screens/RegisterScreen.kt @@ -21,13 +21,8 @@ import com.example.goodroad.ui.theme.UrbanBrown import com.example.goodroad.modules.auth.presentation.AuthViewModel import com.example.goodroad.validation.CYRILLIC_WARNING import com.example.goodroad.validation.NAME_MAX_LENGTH -import com.example.goodroad.validation.PHONE_CHARS_WARNING -import com.example.goodroad.validation.PHONE_FORMAT_WARNING -import com.example.goodroad.validation.formatPhoneForRequest import com.example.goodroad.validation.isAllowedCyrillicInput -import com.example.goodroad.validation.isAllowedDigitsInput import com.example.goodroad.validation.normalizeRequiredCyrillic -import com.example.goodroad.validation.normalizeRequiredRussianPhone @Composable fun RegisterScreen( @@ -42,7 +37,6 @@ fun RegisterScreen( var confirmPassword by rememberSaveable { mutableStateOf("") } var firstNameWarning by rememberSaveable { mutableStateOf(null) } var lastNameWarning by rememberSaveable { mutableStateOf(null) } - var phoneWarning by rememberSaveable { mutableStateOf(null) } var errorText by rememberSaveable { mutableStateOf(null) } val viewModel: AuthViewModel = viewModel() @@ -73,15 +67,23 @@ fun RegisterScreen( val lastNameNormalized = normalizeRequiredCyrillic(lastName) if (lastNameNormalized == null) { lastNameWarning = CYRILLIC_WARNING - errorText = - "Фамилия обязательна и должна содержать только кириллицу, пробел и -" + errorText = "Фамилия обязательна и должна содержать только кириллицу, пробел и -" return@PrimaryButton } - val phoneDigits = normalizeRequiredRussianPhone(phone) - if (phoneDigits == null || password.isBlank()) { - phoneWarning = PHONE_FORMAT_WARNING - errorText = "Телефон и пароль обязательны" + val phoneValidation = validatePhone(phone) + if (phoneValidation !is PhoneValidation.Valid) { + errorText = when (phoneValidation) { + is PhoneValidation.Empty -> "Введите номер телефона" + is PhoneValidation.InvalidChars -> "Телефон должен содержать только цифры" + is PhoneValidation.InvalidFormat -> "Введите корректный номер телефона" + else -> "Заполните все поля" + } + return@PrimaryButton + } + + if (password.isBlank()) { + errorText = "Введите пароль" return@PrimaryButton } @@ -96,10 +98,11 @@ fun RegisterScreen( return@PrimaryButton } + errorText = null viewModel.register( firstNameNormalized, lastNameNormalized, - formatPhoneForRequest(phoneDigits), + phoneValidation.toFormattedPhone()!!, password ) } @@ -119,7 +122,6 @@ fun RegisterScreen( !isAllowedCyrillicInput(value) -> { firstNameWarning = CYRILLIC_WARNING } - value != firstName -> { firstName = value firstNameWarning = null @@ -147,7 +149,6 @@ fun RegisterScreen( !isAllowedCyrillicInput(value) -> { lastNameWarning = CYRILLIC_WARNING } - value != lastName -> { lastName = value lastNameWarning = null @@ -170,30 +171,10 @@ fun RegisterScreen( PhoneField( value = phone, - onValueChange = { value -> - when { - !isAllowedDigitsInput(value) -> { - phoneWarning = PHONE_CHARS_WARNING - } - - value.length > 11 -> { - phoneWarning = PHONE_FORMAT_WARNING - } - - value.isNotEmpty() && value.first() !in listOf('7', '8') -> { - phoneWarning = PHONE_FORMAT_WARNING - } - - else -> { - if (value != phone) { - phone = value - phoneWarning = null - } - } - } + onValueChange = { + phone = it }, - label = "Телефон", - warning = phoneWarning + label = "Телефон" ) Spacer(Modifier.height(12.dp)) diff --git a/app/src/main/java/com/example/goodroad/modules/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/moderator/screens/ModeratorsManagementScreen.kt b/app/src/main/java/com/example/goodroad/modules/moderator/screens/ModeratorsManagementScreen.kt index 47a3eac..fc0513e 100644 --- a/app/src/main/java/com/example/goodroad/modules/moderator/screens/ModeratorsManagementScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/moderator/screens/ModeratorsManagementScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -47,7 +48,9 @@ import com.example.goodroad.ui.buttons.PrimaryButton import com.example.goodroad.ui.fields.PasswordField import com.example.goodroad.ui.fields.PhoneField import com.example.goodroad.ui.fields.PlainField -import androidx.compose.foundation.layout.size +import com.example.goodroad.ui.fields.PhoneValidation +import com.example.goodroad.ui.fields.validatePhone +import com.example.goodroad.ui.fields.toFormattedPhone import com.example.goodroad.ui.theme.AlertRed import com.example.goodroad.ui.theme.BackgroundLight import com.example.goodroad.ui.theme.BorderWarm @@ -57,10 +60,6 @@ import com.example.goodroad.ui.theme.SurfaceWarm import com.example.goodroad.ui.theme.TextPrimary import com.example.goodroad.ui.theme.UrbanBrown import com.example.goodroad.ui.theme.WarningOrange -import com.example.goodroad.validation.PHONE_FORMAT_WARNING -import com.example.goodroad.validation.formatPhoneForRequest -import com.example.goodroad.validation.isAllowedDigitsInput -import com.example.goodroad.validation.normalizeRequiredRussianPhone @Composable private fun NameField( @@ -385,7 +384,6 @@ private fun AddModeratorDialog( var lastName by remember { mutableStateOf("") } var phone by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } - var phoneWarning by remember { mutableStateOf(null) } var errorText by remember { mutableStateOf(null) } AlertDialog( @@ -393,17 +391,27 @@ private fun AddModeratorDialog( confirmButton = { Button( onClick = { - val phoneDigits = normalizeRequiredRussianPhone(phone) - if (firstName.isBlank() || lastName.isBlank() || password.isBlank() || phoneDigits == null) { - errorText = "Заполните все поля корректно" - phoneWarning = if (phoneDigits == null && phone.isNotBlank()) PHONE_FORMAT_WARNING else null + val phoneValidation = validatePhone(phone) + if (firstName.isBlank() || lastName.isBlank() || password.isBlank()) { + errorText = "Заполните все поля" + return@Button + } + + if (phoneValidation !is PhoneValidation.Valid) { + errorText = when (phoneValidation) { + is PhoneValidation.Empty -> "Введите номер телефона" + is PhoneValidation.InvalidChars -> "Телефон должен содержать только цифры" + is PhoneValidation.InvalidFormat -> "Введите корректный номер телефона" + else -> "Заполните все поля" + } return@Button } + errorText = null onAdd( firstName.trim(), lastName.trim(), - formatPhoneForRequest(phoneDigits), + phoneValidation.toFormattedPhone()!!, password ) } @@ -429,18 +437,10 @@ private fun AddModeratorDialog( ) PhoneField( value = phone, - onValueChange = { value -> - phone = value - phoneWarning = when { - value.isEmpty() -> null - !isAllowedDigitsInput(value) -> PHONE_FORMAT_WARNING - value.length > 11 -> PHONE_FORMAT_WARNING - value.firstOrNull() !in listOf('7', '8') -> PHONE_FORMAT_WARNING - else -> null - } + onValueChange = { + phone = it }, - label = "Телефон", - warning = phoneWarning + label = "Телефон" ) PasswordField( value = password, diff --git a/app/src/main/java/com/example/goodroad/modules/review/screens/ReviewFormScreen.kt b/app/src/main/java/com/example/goodroad/modules/review/screens/ReviewFormScreen.kt index 8faf23a..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,13 +15,19 @@ 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 +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 +44,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 +67,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 @@ -74,6 +83,7 @@ fun ReviewFormScreen( initialPlaceName: String = "", initialLatitude: String = "", initialLongitude: String = "", + isLocationLocked: Boolean = false, onBack: () -> Unit, onSaved: () -> Unit ) { @@ -82,19 +92,26 @@ 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) } + 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) } @@ -105,15 +122,33 @@ 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 + ) } } } @@ -122,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) { @@ -153,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)) @@ -182,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)) @@ -224,7 +314,9 @@ fun ReviewFormScreen( style = MaterialTheme.typography.titleMedium, color = UrbanBrown ) + Spacer(Modifier.height(8.dp)) + SeveritySelector( value = rating, range = 1..5, @@ -238,32 +330,160 @@ fun ReviewFormScreen( style = MaterialTheme.typography.titleMedium, color = UrbanBrown ) + 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)) @@ -284,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, @@ -302,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), @@ -312,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 ) } @@ -350,15 +581,19 @@ fun ReviewFormScreen( }, enabled = !submitInProgress && !isPhotoUploading ) { + scope.launch { + isPreparingSubmit = true var sentToViewModel = false try { + val validationError = validateReviewForm( latitude = latitude, longitude = longitude, rating = rating, + obstacleSelected = obstacleSelected, obstacleSeverities = obstacleSeverities ) @@ -367,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, @@ -381,17 +623,24 @@ 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() } ) @@ -399,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 } @@ -418,22 +676,38 @@ private fun validateReviewForm( latitude: String, longitude: String, rating: Int?, + 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 (obstacleSeverities.values.none { it > 0 }) { - return "Укажите тяжесть хотя бы для одного препятствия" + + if (obstacleSelected.values.none { it }) { + return "Выберите хотя бы одно препятствие" } + return null } @@ -444,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 = "Регион не указан", @@ -454,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/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 new file mode 100644 index 0000000..a07b5d5 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/rewards/screens/CouponsScreen.kt @@ -0,0 +1,408 @@ +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 +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.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.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.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.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 java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun CouponsScreen( + viewModel: RewardsViewModel, + onBack: () -> Unit +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + val coupons = state.activeCoupons + state.inactiveCoupons + + LaunchedEffect(Unit) { + viewModel.loadCurrentUserRewards() + } + + 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(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.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = coupon.title, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = TextPrimary + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = coupon.partnerName, + fontSize = 16.sp, + color = UrbanBrown.copy(alpha = 1.5f) + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = coupon.rewardType, + fontSize = 14.sp, + color = UrbanBrown.copy(alpha = 0.7f) + ) + } + + Column( + horizontalAlignment = Alignment.End + ) { + Text( + text = "⭐", + fontSize = 24.sp + ) + + Text( + 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 d01ad0f..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 @@ -1,29 +1,36 @@ 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 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 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.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 @@ -32,6 +39,7 @@ fun RewardsShopScreen( onRewardClick: (RewardOffer) -> Unit, onNavigateToHistory: () -> Unit, onNavigateToLeaderboard: () -> Unit, + onNavigateToCoupons: () -> Unit, onBack: () -> Unit ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -41,6 +49,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 +65,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 = onNavigateToCoupons) { + Icon( + imageVector = Icons.Default.CardGiftcard, + 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 } + ) + } + } +} + +@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)) - else -> { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(12.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)) } } } @@ -337,4 +443,5 @@ private fun RewardCard( } } } -} \ 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 ab87da2..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 @@ -7,9 +7,12 @@ 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.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 @@ -23,18 +26,18 @@ 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, + onTargetClick: (TargetViewDto) -> Unit, onBack: () -> Unit ) { - var targetsState by remember { mutableStateOf(task.targets.toList()) } + var targetsState by remember(task.targets) { + mutableStateOf(task.targets.toList()) + } + val completedTargets = targetsState.count { it.done } - val allCompleted = completedTargets >= task.targetCount Surface( modifier = Modifier.fillMaxSize(), @@ -45,6 +48,7 @@ fun TaskExecutionScreen( .fillMaxSize() .padding(24.dp) ) { + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -85,42 +89,24 @@ fun TaskExecutionScreen( fontWeight = FontWeight.Bold, color = UrbanBrown ) + Text( text = "⭐", 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)) LinearProgressIndicator( - progress = completedTargets.toFloat() / task.targetCount, + progress = { + if (task.targetCount > 0) { + completedTargets.toFloat() / task.targetCount + } else { + 0f + } + }, modifier = Modifier .fillMaxWidth() .height(8.dp), @@ -148,35 +134,23 @@ 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) } } ) } } - - 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 +165,11 @@ private fun TargetItem( Card( modifier = Modifier .fillMaxWidth() - .clickable(enabled = !isCompleted) { if (!isCompleted) onComplete() }, + .clickable(enabled = !isCompleted) { + if (!isCompleted) { + onComplete() + } + }, colors = CardDefaults.cardColors( containerColor = SurfaceWarm ) @@ -203,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, @@ -231,29 +211,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 - ) - } - } } } @@ -263,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/tasks/screens/TaskScreen.kt b/app/src/main/java/com/example/goodroad/modules/tasks/screens/TaskScreen.kt index 8e4bf0f..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,19 +250,29 @@ fun TasksScreen( @Composable private fun TaskCard( task: TaskViewDto, + isReview: Boolean, onClick: () -> Unit ) { val isCompleted = task.completedCount >= task.targetCount - val isInProgress = task.completedCount > 0 && !isCompleted 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 @@ -182,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) { @@ -211,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, @@ -232,12 +341,28 @@ 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 - ) + + if (isReview) { + + PrimaryButton( + text = "Выполнить", + backgroundColor = SafeGreen, + modifier = Modifier.width(100.dp), + onClick = onClick + ) + + } else { + + PrimaryButton( + text = "Выполнено", + backgroundColor = SafeGreen, + modifier = Modifier.width(110.dp), + onClick = { + + + } + ) + } } } } diff --git a/app/src/main/java/com/example/goodroad/modules/user/data/UserApi.kt b/app/src/main/java/com/example/goodroad/modules/user/data/UserApi.kt index 189e6e1..ae756cb 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/data/UserApi.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/data/UserApi.kt @@ -3,13 +3,12 @@ package com.example.goodroad.modules.user.data import okhttp3.MultipartBody import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET -import retrofit2.http.HTTP import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Part -import retrofit2.http.Query interface UserApi { @@ -17,18 +16,33 @@ interface UserApi { suspend fun getCurrentUser(): Response @PUT("/users") - suspend fun updateCurrentUser(@Body req: UpdateUserReq): Response + suspend fun updateCurrentUser( + @Body req: UpdateUserReq + ): Response + + @PUT("/users/phone") + suspend fun changePhone( + @Body req: ChangePhoneReq + ): Response @POST("/users") suspend fun changePassword( - @Query("oldPassword") oldPassword: String, - @Query("newPassword") newPassword: String + @Body req: ChangePasswordReq ): Response @Multipart @POST("/users/avatar") - suspend fun uploadAvatar(@Part file: MultipartBody.Part): Response + suspend fun uploadAvatar( + @Part file: MultipartBody.Part + ): Response + + @DELETE("/users") + suspend fun deleteCurrentUser( + @Body req: DeleteAccountReq + ): Response +} - @HTTP(method = "DELETE", path = "/users", hasBody = true) - suspend fun deleteCurrentUser(@Body req: DeleteAccountReq): Response -} \ No newline at end of file +data class ChangePasswordReq( + val oldPassword: String, + val newPassword: String +) \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/user/data/UserModels.kt b/app/src/main/java/com/example/goodroad/modules/user/data/UserModels.kt index c90e421..e9e39c9 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/data/UserModels.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/data/UserModels.kt @@ -1,19 +1,14 @@ package com.example.goodroad.modules.user.data -data class UserDto( - val id: String, - val role: String, - val firstName: String?, - val lastName: String?, - val photoUrl: String?, - val active: Boolean -) - data class UpdateUserReq( val firstName: String? = null, val lastName: String? = null, - val photoUrl: String? = null, - val phone: String? = null + val photoUrl: String? = null +) + +data class ChangePhoneReq( + val phone: String, + val currentPassword: String ) data class AvatarUploadResp( diff --git a/app/src/main/java/com/example/goodroad/modules/user/data/UserRepository.kt b/app/src/main/java/com/example/goodroad/modules/user/data/UserRepository.kt index e61057d..38b3847 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/data/UserRepository.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/data/UserRepository.kt @@ -3,33 +3,85 @@ package com.example.goodroad.modules.user.data import okhttp3.MultipartBody import retrofit2.HttpException -class UserRepository(private val api: UserApi) { +class UserRepository( + private val api: UserApi +) { suspend fun getCurrentUser(): SettingsView? { val response = api.getCurrentUser() - if (response.isSuccessful) return response.body() + + if (response.isSuccessful) { + return response.body() + } + throw HttpException(response) } - suspend fun updateCurrentUser(req: UpdateUserReq): SettingsView? { + suspend fun updateCurrentUser( + req: UpdateUserReq + ): SettingsView? { val response = api.updateCurrentUser(req) - if (response.isSuccessful) return response.body() + + if (response.isSuccessful) { + return response.body() + } + throw HttpException(response) } - suspend fun changePassword(oldPassword: String, newPassword: String) { - val response = api.changePassword(oldPassword, newPassword) - if (!response.isSuccessful) throw HttpException(response) + suspend fun changePhone( + phone: String, + currentPassword: String + ): SettingsView? { + val response = api.changePhone( + ChangePhoneReq( + phone = phone, + currentPassword = currentPassword + ) + ) + + if (response.isSuccessful) { + return response.body() + } + + throw HttpException(response) } - suspend fun uploadAvatar(file: MultipartBody.Part): AvatarUploadResp? { + suspend fun changePassword( + oldPassword: String, + newPassword: String + ) { + val response = api.changePassword( + ChangePasswordReq( + oldPassword = oldPassword, + newPassword = newPassword + ) + ) + + if (!response.isSuccessful) { + throw HttpException(response) + } + } + + suspend fun uploadAvatar( + file: MultipartBody.Part + ): AvatarUploadResp? { val response = api.uploadAvatar(file) - if (response.isSuccessful) return response.body() + + if (response.isSuccessful) { + return response.body() + } + throw HttpException(response) } - suspend fun deleteCurrentUser(req: DeleteAccountReq) { + suspend fun deleteCurrentUser( + req: DeleteAccountReq + ) { val response = api.deleteCurrentUser(req) - if (!response.isSuccessful) throw HttpException(response) + + if (!response.isSuccessful) { + throw HttpException(response) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt index 00bc9b8..27162ab 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt @@ -47,6 +47,11 @@ 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 +import com.example.goodroad.modules.tasks.data.TargetViewDto +import com.example.goodroad.ui.user.SecurityScreen +import com.example.goodroad.ui.user.ChangePasswordScreen +import com.example.goodroad.ui.user.ChangePhoneScreen enum class BottomTab { MAP, @@ -59,6 +64,9 @@ enum class OverlayScreen { NONE, EDIT_PROFILE, DELETE_PROFILE, + SECURITY, + CHANGE_PASSWORD, + CHANGE_PHONE, REVIEW_FORM, REVIEW_DETAILS, OBSTACLES, @@ -73,7 +81,9 @@ enum class OverlayScreen { LEADERBOARD, TASKS_SHOP, TASK_DETAIL, - TASKS_HISTORY + TASKS_HISTORY, + COUPONS, + REVIEW_FORM_FROM_TASK, } @Composable @@ -137,7 +147,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 { @@ -239,6 +249,9 @@ fun UserNav( onSelectObstacles = { overlayScreen = OverlayScreen.OBSTACLES }, + onSecurity = { + overlayScreen = OverlayScreen.SECURITY + }, onBecomeVolunteer = { overlayScreen = OverlayScreen.VOLUNTEER_APPLICATION }, @@ -266,6 +279,32 @@ fun UserNav( onExit = onLogout ) + OverlayScreen.SECURITY -> SecurityScreen( + onBack = { + overlayScreen = OverlayScreen.NONE + }, + onChangePassword = { + overlayScreen = OverlayScreen.CHANGE_PASSWORD + }, + onChangePhone = { + overlayScreen = OverlayScreen.CHANGE_PHONE + } + ) + + OverlayScreen.CHANGE_PASSWORD -> ChangePasswordScreen( + userViewModel = userViewModel, + onBack = { + overlayScreen = OverlayScreen.SECURITY + } + ) + + OverlayScreen.CHANGE_PHONE -> ChangePhoneScreen( + userViewModel = userViewModel, + onBack = { + overlayScreen = OverlayScreen.SECURITY + } + ) + OverlayScreen.REVIEW_FORM -> ReviewFormScreen( reviewsViewModel = reviewsViewModel, initialReview = selectedReview, @@ -294,6 +333,11 @@ fun UserNav( } } + OverlayScreen.COUPONS -> CouponsScreen( + viewModel = rewardsViewModel, + onBack = { overlayScreen = OverlayScreen.REWARDS_SHOP } + ) + OverlayScreen.OBSTACLES -> ObstacleSelectScreen( mapsViewModel = mapsViewModel, onBackToProfile = { overlayScreen = OverlayScreen.NONE }, @@ -334,6 +378,7 @@ fun UserNav( }, onNavigateToHistory = { overlayScreen = OverlayScreen.REWARDS_HISTORY }, onNavigateToLeaderboard = { overlayScreen = OverlayScreen.LEADERBOARD }, + onNavigateToCoupons = { overlayScreen = OverlayScreen.COUPONS }, onBack = { overlayScreen = OverlayScreen.NONE } ) @@ -357,6 +402,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 } @@ -384,19 +454,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/modules/user/presentation/UserViewModel.kt b/app/src/main/java/com/example/goodroad/modules/user/presentation/UserViewModel.kt index b4755ad..6083f37 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/presentation/UserViewModel.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/presentation/UserViewModel.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.goodroad.data.network.ApiClient +import com.example.goodroad.modules.user.data.ChangePhoneReq import com.example.goodroad.modules.user.data.DeleteAccountReq import com.example.goodroad.modules.user.data.SettingsView import com.example.goodroad.modules.user.data.UpdateUserReq @@ -57,10 +58,7 @@ class UserViewModel( fun updateUser( firstName: String, lastName: String, - photoUrl: String? = null, - phone: String? = null, - oldPassword: String? = null, - newPassword: String? = null + photoUrl: String? = null ) { viewModelScope.launch { isLoading.value = true @@ -70,45 +68,39 @@ class UserViewModel( try { val current = user.value - val phoneToUpdate = phone?.takeIf { it.isNotBlank() } - val hasPasswordChange = !oldPassword.isNullOrBlank() || !newPassword.isNullOrBlank() - - if (hasPasswordChange && - (oldPassword.isNullOrBlank() || newPassword.isNullOrBlank()) - ) { - throw IllegalArgumentException("Для смены пароля заполните оба поля") - } - val req = UpdateUserReq( - firstName = firstName.takeIf { it != current?.firstName }, - lastName = lastName.takeIf { it != current?.lastName }, - photoUrl = photoUrl.takeIf { it != current?.photoUrl }, - phone = phoneToUpdate + firstName = firstName.takeIf { + it != current?.firstName + }, + lastName = lastName.takeIf { + it != current?.lastName + }, + photoUrl = photoUrl.takeIf { + it != current?.photoUrl + } ) val hasChanges = req.firstName != null || req.lastName != null || - req.photoUrl != null || - req.phone != null || - hasPasswordChange + req.photoUrl != null if (!hasChanges) { - throw IllegalArgumentException("Нет изменений для сохранения") + throw IllegalArgumentException( + "Нет изменений для сохранения" + ) } - if (req.phone != null) { - user.value = repository.updateCurrentUser(req) - val updatedUser = repository.updateCurrentUser(req) - user.value = updatedUser - } + val updatedUser = repository.updateCurrentUser(req) - if (hasPasswordChange) { - repository.changePassword(oldPassword!!, newPassword!!) - successMessage.value = "Пароль изменён. Пожалуйста, войдите заново." - ApiClient.logout() + if (updatedUser == null) { + throw IllegalStateException( + "Сервер не вернул данные пользователя" + ) } + user.value = updatedUser + successMessage.value = "Профиль обновлён" } catch (e: Exception) { @@ -119,6 +111,99 @@ class UserViewModel( } } + fun changePhone( + newPhone: String, + currentPassword: String, + onSuccess: () -> Unit + ) { + viewModelScope.launch { + isLoading.value = true + errorMessage.value = null + successMessage.value = null + + try { + if (newPhone.isBlank()) { + throw IllegalArgumentException( + "Введите новый номер телефона" + ) + } + + if (currentPassword.isBlank()) { + throw IllegalArgumentException( + "Введите текущий пароль" + ) + } + + val req = ChangePhoneReq( + phone = newPhone.trim(), + currentPassword = currentPassword + ) + + val updatedUser = repository.changePhone( + phone = req.phone, + currentPassword = req.currentPassword + ) + + if (updatedUser == null) { + throw IllegalStateException( + "Сервер не вернул данные пользователя" + ) + } + + user.value = updatedUser + + successMessage.value = "Номер телефона изменён" + + onSuccess() + + } catch (e: Exception) { + errorMessage.value = mapPhoneChangeError(e) + } finally { + isLoading.value = false + } + } + } + + fun changePassword( + oldPassword: String, + newPassword: String, + onSuccess: () -> Unit + ) { + viewModelScope.launch { + isLoading.value = true + errorMessage.value = null + successMessage.value = null + + try { + if (oldPassword.isBlank()) { + throw IllegalArgumentException( + "Введите текущий пароль" + ) + } + + if (newPassword.isBlank()) { + throw IllegalArgumentException( + "Введите новый пароль" + ) + } + + repository.changePassword( + oldPassword = oldPassword, + newPassword = newPassword + ) + + successMessage.value = "Пароль успешно изменён" + + onSuccess() + + } catch (e: Exception) { + errorMessage.value = mapPasswordChangeError(e) + } finally { + isLoading.value = false + } + } + } + fun uploadAvatar( context: Context, uri: Uri, @@ -135,26 +220,45 @@ class UserViewModel( val resolver = context.contentResolver val mimeType = resolver.getType(uri) ?: "image/*" - tempFile = File.createTempFile("avatar", ".tmp", context.cacheDir) + tempFile = File.createTempFile( + "avatar", + ".tmp", + context.cacheDir + ) resolver.openInputStream(uri)?.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } - } ?: throw IllegalStateException("Не удалось прочитать файл") + } ?: throw IllegalStateException( + "Не удалось прочитать файл" + ) - val body = tempFile.asRequestBody(mimeType.toMediaTypeOrNull()) - val part = MultipartBody.Part.createFormData("file", tempFile.name, body) + val body = tempFile.asRequestBody( + mimeType.toMediaTypeOrNull() + ) + + val part = MultipartBody.Part.createFormData( + "file", + tempFile.name, + body + ) val response = repository.uploadAvatar(part) - ?: throw IllegalStateException("Сервер не вернул фото") + ?: throw IllegalStateException( + "Сервер не вернул фото" + ) val current = user.value + if (current != null) { - user.value = current.copy(photoUrl = response.photoUrl) + user.value = current.copy( + photoUrl = response.photoUrl + ) } successMessage.value = "Фото профиля обновлено" + onSuccess(response.photoUrl) } catch (e: Exception) { @@ -166,15 +270,22 @@ class UserViewModel( } } - fun deleteUser(password: String, onSuccess: () -> Unit) { + fun deleteUser( + password: String, + onSuccess: () -> Unit + ) { viewModelScope.launch { isLoading.value = true errorMessage.value = null successMessage.value = null try { - repository.deleteCurrentUser(DeleteAccountReq(password)) + repository.deleteCurrentUser( + DeleteAccountReq(password) + ) + ApiClient.logout() + user.value = null isDeleted = true @@ -188,12 +299,16 @@ class UserViewModel( } } - fun logout(onSuccess: () -> Unit) { + fun logout( + onSuccess: () -> Unit + ) { ApiClient.logout() + user.value = null isDeleted = false errorMessage.value = null successMessage.value = null + onSuccess() } @@ -208,7 +323,10 @@ class UserViewModel( private fun mapUserError(e: Exception): String { return when (e) { - is IllegalArgumentException -> e.message ?: "Некорректные данные" + + is IllegalArgumentException -> + e.message ?: "Некорректные данные" + is HttpException -> when (e.code()) { 400 -> "Некорректные данные" 401 -> "Не авторизован" @@ -218,13 +336,42 @@ class UserViewModel( 500 -> "Ошибка сервера" else -> "Ошибка" } - is IOException -> "Проверьте интернет" - else -> e.message ?: "Неизвестная ошибка" + + is IOException -> + "Проверьте интернет" + + else -> + e.message ?: "Неизвестная ошибка" + } + } + + private fun mapPhoneChangeError(e: Exception): String { + return when (e) { + + is IllegalArgumentException -> + e.message ?: "Некорректные данные" + + is HttpException -> when (e.code()) { + 400 -> "Некорректный номер телефона" + 401 -> "Неверный текущий пароль" + 403 -> "Нет доступа" + 404 -> "Пользователь не найден" + 409 -> "Этот номер телефона уже используется" + 500 -> "Ошибка сервера" + else -> "Не удалось изменить номер телефона" + } + + is IOException -> + "Проверьте интернет" + + else -> + e.message ?: "Неизвестная ошибка" } } private fun mapDeleteError(e: Exception): String { return when (e) { + is HttpException -> when (e.code()) { 400 -> "Неверный пароль" 401 -> "Неверный пароль" @@ -234,8 +381,35 @@ class UserViewModel( 500 -> "Ошибка сервера" else -> "Ошибка удаления" } - is IOException -> "Проверьте интернет" - else -> e.message ?: "Неизвестная ошибка" + + is IOException -> + "Проверьте интернет" + + else -> + e.message ?: "Неизвестная ошибка" + } + } +} + +private fun mapPasswordChangeError(e: Exception): String { + return when (e) { + + is IllegalArgumentException -> + e.message ?: "Некорректные данные" + + is HttpException -> when (e.code()) { + 400 -> "Некорректные данные" + 401 -> "Неверный текущий пароль" + 403 -> "Нет доступа" + 404 -> "Пользователь не найден" + 500 -> "Ошибка сервера" + else -> "Не удалось изменить пароль" } + + is IOException -> + "Проверьте интернет" + + else -> + e.message ?: "Неизвестная ошибка" } } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePasswordScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePasswordScreen.kt new file mode 100644 index 0000000..3396280 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePasswordScreen.kt @@ -0,0 +1,165 @@ +package com.example.goodroad.ui.user + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.goodroad.modules.user.presentation.UserViewModel +import com.example.goodroad.ui.AuthStatusText +import com.example.goodroad.ui.buttons.PrimaryButton +import com.example.goodroad.ui.fields.PasswordField +import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.ui.theme.TextPrimary +import com.example.goodroad.ui.theme.UrbanBrown + +@Composable +fun ChangePasswordScreen( + userViewModel: UserViewModel, + onBack: () -> Unit +) { + var oldPassword by rememberSaveable { mutableStateOf("") } + var newPassword by rememberSaveable { mutableStateOf("") } + var confirmPassword by rememberSaveable { mutableStateOf("") } + var localError by rememberSaveable { mutableStateOf(null) } + + val isLoading by userViewModel.isLoading + val serverError by userViewModel.errorMessage + + LaunchedEffect(Unit) { + userViewModel.clearMessages() + localError = null + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Сменить пароль", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary, + modifier = Modifier.weight(1f) + ) + + IconButton( + onClick = onBack, + enabled = !isLoading + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Назад", + tint = UrbanBrown.copy(alpha = 0.7f) + ) + } + } + + Spacer(Modifier.height(24.dp)) + + PasswordField( + value = oldPassword, + onValueChange = { + oldPassword = it + localError = null + userViewModel.clearMessages() + }, + label = "Старый пароль" + ) + + Spacer(Modifier.height(12.dp)) + + PasswordField( + value = newPassword, + onValueChange = { + newPassword = it + localError = null + userViewModel.clearMessages() + }, + label = "Новый пароль" + ) + + Spacer(Modifier.height(12.dp)) + + PasswordField( + value = confirmPassword, + onValueChange = { + confirmPassword = it + localError = null + userViewModel.clearMessages() + }, + label = "Подтвердите новый пароль" + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Введите текущий пароль, затем новый пароль и повторите его.", + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown + ) + + Spacer(Modifier.height(16.dp)) + + AuthStatusText( + text = localError ?: serverError, + onTimeout = { + localError = null + userViewModel.clearMessages() + } + ) + + Spacer(Modifier.height(16.dp)) + + PrimaryButton( + text = if (isLoading) { + "Меняем..." + } else { + "Сменить пароль" + }, + enabled = !isLoading + ) { + when { + oldPassword.isBlank() -> { + localError = "Введите текущий пароль" + } + + newPassword.isBlank() -> { + localError = "Введите новый пароль" + } + + confirmPassword.isBlank() -> { + localError = "Подтвердите новый пароль" + } + + newPassword != confirmPassword -> { + localError = "Пароли не совпадают" + } + + else -> { + localError = null + + userViewModel.changePassword( + oldPassword = oldPassword, + newPassword = newPassword, + onSuccess = onBack + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt new file mode 100644 index 0000000..3d629ac --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/ChangePhoneScreen.kt @@ -0,0 +1,160 @@ +package com.example.goodroad.ui.user + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.goodroad.modules.user.presentation.UserViewModel +import com.example.goodroad.ui.AuthStatusText +import com.example.goodroad.ui.buttons.PrimaryButton +import com.example.goodroad.ui.fields.PasswordField +import com.example.goodroad.ui.fields.PhoneField +import com.example.goodroad.ui.fields.PhoneValidation +import com.example.goodroad.ui.fields.validatePhone +import com.example.goodroad.ui.fields.toFormattedPhone +import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.ui.theme.TextPrimary +import com.example.goodroad.ui.theme.UrbanBrown + +@Composable +fun ChangePhoneScreen( + userViewModel: UserViewModel, + onBack: () -> Unit +) { + var phone by rememberSaveable { mutableStateOf("") } + var currentPassword by rememberSaveable { mutableStateOf("") } + + var localError by rememberSaveable { + mutableStateOf(null) + } + + val serverError by userViewModel.errorMessage + val isLoading by userViewModel.isLoading + + LaunchedEffect(Unit) { + userViewModel.clearMessages() + localError = null + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Смена телефона", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary, + modifier = Modifier.weight(1f) + ) + + IconButton( + onClick = onBack, + enabled = !isLoading + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Назад", + tint = UrbanBrown.copy(alpha = 0.7f) + ) + } + } + + Spacer( + modifier = Modifier.height(24.dp) + ) + + PhoneField( + value = phone, + onValueChange = { + phone = it + localError = null + userViewModel.clearMessages() + }, + label = "Новый телефон" + ) + + Spacer( + modifier = Modifier.height(12.dp) + ) + + PasswordField( + value = currentPassword, + onValueChange = { + currentPassword = it + localError = null + userViewModel.clearMessages() + }, + label = "Текущий пароль" + ) + + Spacer( + modifier = Modifier.height(20.dp) + ) + + AuthStatusText( + text = serverError ?: localError, + onTimeout = { + localError = null + userViewModel.clearMessages() + } + ) + + Spacer( + modifier = Modifier.height(20.dp) + ) + + PrimaryButton( + text = if (isLoading) { + "Меняем..." + } else { + "Сменить номер" + }, + enabled = !isLoading + ) { + val phoneValidation = validatePhone(phone) + when (phoneValidation) { + is PhoneValidation.Empty -> { + localError = "Введите номер телефона" + } + + is PhoneValidation.InvalidChars -> { + localError = "Телефон должен содержать только цифры" + } + + is PhoneValidation.InvalidFormat -> { + localError = "Введите корректный номер телефона" + } + + is PhoneValidation.Valid -> { + if (currentPassword.isBlank()) { + localError = "Введите текущий пароль" + return@PrimaryButton + } + + localError = null + + userViewModel.changePhone( + newPhone = phoneValidation.toFormattedPhone()!!, + currentPassword = currentPassword, + onSuccess = onBack + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/SecurityScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/SecurityScreen.kt new file mode 100644 index 0000000..76628ea --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/SecurityScreen.kt @@ -0,0 +1,114 @@ +package com.example.goodroad.ui.user + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.ui.theme.SurfaceWarm +import com.example.goodroad.ui.theme.TextPrimary +import com.example.goodroad.ui.theme.TextSecondary +import com.example.goodroad.ui.theme.UrbanBrown + +@Composable +fun SecurityScreen( + onBack: () -> Unit, + onChangePassword: () -> Unit, + onChangePhone: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Безопасность", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary, + modifier = Modifier.weight(1f) + ) + + IconButton( + onClick = onBack + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Назад", + tint = UrbanBrown.copy(alpha = 0.7f) + ) + } + } + } + + item { + SecurityCard( + title = "Сменить пароль", + description = "Изменить пароль от аккаунта", + onClick = onChangePassword + ) + } + + item { + SecurityCard( + title = "Сменить телефон", + description = "Изменить номер телефона, привязанный к аккаунту", + onClick = onChangePhone + ) + } + } + } +} + +@Composable +private fun SecurityCard( + title: String, + description: String, + onClick: () -> Unit +) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = SurfaceWarm + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 1.dp + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = TextPrimary + ) + + Spacer( + modifier = Modifier.height(4.dp) + ) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt index 51c99b6..de9f7f5 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/UserEditScreen.kt @@ -23,21 +23,13 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.example.goodroad.modules.user.presentation.UserViewModel import com.example.goodroad.ui.AuthStatusText -import com.example.goodroad.ui.AuthSuccessText import com.example.goodroad.ui.buttons.PrimaryButton -import com.example.goodroad.ui.fields.PasswordField -import com.example.goodroad.ui.fields.PhoneField import com.example.goodroad.ui.fields.PlainField import com.example.goodroad.ui.theme.* import com.example.goodroad.validation.CYRILLIC_WARNING import com.example.goodroad.validation.NAME_MAX_LENGTH -import com.example.goodroad.validation.PHONE_CHARS_WARNING -import com.example.goodroad.validation.PHONE_FORMAT_WARNING -import com.example.goodroad.validation.formatPhoneForRequest import com.example.goodroad.validation.isAllowedCyrillicInput -import com.example.goodroad.validation.isAllowedDigitsInput import com.example.goodroad.validation.normalizeRequiredCyrillic -import com.example.goodroad.validation.normalizeRequiredRussianPhone @Composable fun UserEditScreen( @@ -48,6 +40,13 @@ fun UserEditScreen( val context = LocalContext.current val user = userViewModel.user.value + LaunchedEffect(userViewModel.successMessage.value) { + if (userViewModel.successMessage.value != null) { + userViewModel.clearMessages() + onBack() + } + } + if (user == null) { Box( modifier = Modifier.fillMaxSize(), @@ -59,48 +58,27 @@ fun UserEditScreen( var firstName by remember { mutableStateOf(user.firstName ?: "") } var lastName by remember { mutableStateOf(user.lastName ?: "") } var selectedPhotoUri by remember { mutableStateOf(null) } - var phone by remember { mutableStateOf("") } - var oldPassword by remember { mutableStateOf("") } - var newPassword by remember { mutableStateOf("") } - var confirmNewPassword by remember { mutableStateOf("") } var firstNameWarning by remember { mutableStateOf(null) } var lastNameWarning by remember { mutableStateOf(null) } - var phoneWarning by remember { mutableStateOf(null) } var errorText by remember { mutableStateOf(null) } - val errorMessage by remember { derivedStateOf { userViewModel.errorMessage.value } } - val successMessage by remember { derivedStateOf { userViewModel.successMessage.value } } - val isLoading by remember { derivedStateOf { userViewModel.isLoading.value } } + val errorMessage by userViewModel.errorMessage + val isLoading by userViewModel.isLoading + val finalError = errorMessage ?: errorText - val hasProfileChanges by remember(firstName, lastName, phone, selectedPhotoUri, user) { - derivedStateOf { - firstName != (user.firstName ?: "") || - lastName != (user.lastName ?: "") || - phone.isNotBlank() || - selectedPhotoUri != null - } - } + val hasProfileChanges = firstName != (user.firstName ?: "") || + lastName != (user.lastName ?: "") - val hasPasswordChanges by remember(oldPassword, newPassword, confirmNewPassword) { - derivedStateOf { - oldPassword.isNotBlank() || newPassword.isNotBlank() || confirmNewPassword.isNotBlank() - } - } - - val canSave by remember(hasProfileChanges, hasPasswordChanges, isLoading) { - derivedStateOf { - (hasProfileChanges || hasPasswordChanges) && !isLoading - } - } + val canSave = (hasProfileChanges || selectedPhotoUri != null) && !isLoading val photoPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() ) { uri -> if (uri != null) { selectedPhotoUri = uri - userViewModel.uploadAvatar(context, uri) { _ -> + userViewModel.uploadAvatar(context, uri) { selectedPhotoUri = null } } @@ -128,10 +106,10 @@ fun UserEditScreen( !isAllowedCyrillicInput(value) -> { firstNameWarning = CYRILLIC_WARNING } - - value != firstName -> { + else -> { firstName = value firstNameWarning = null + errorText = null } } }, @@ -156,10 +134,10 @@ fun UserEditScreen( !isAllowedCyrillicInput(value) -> { lastNameWarning = CYRILLIC_WARNING } - - value != lastName -> { + else -> { lastName = value lastNameWarning = null + errorText = null } } }, @@ -175,8 +153,7 @@ fun UserEditScreen( maxLength = NAME_MAX_LENGTH ) - Spacer(Modifier.height(12.dp)) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(20.dp)) Box( modifier = Modifier.fillMaxWidth(), @@ -193,7 +170,6 @@ fun UserEditScreen( contentScale = ContentScale.Crop ) } - user.photoUrl?.isNotBlank() == true -> { AsyncImage( model = user.photoUrl, @@ -204,7 +180,6 @@ fun UserEditScreen( contentScale = ContentScale.Crop ) } - else -> { Surface( modifier = Modifier.size(120.dp), @@ -212,7 +187,9 @@ fun UserEditScreen( color = WhiteSoft, tonalElevation = 2.dp ) { - Box(contentAlignment = Alignment.Center) { + Box( + contentAlignment = Alignment.Center + ) { Icon( imageVector = Icons.Default.Photo, contentDescription = null, @@ -228,7 +205,9 @@ fun UserEditScreen( Spacer(Modifier.height(16.dp)) OutlinedButton( - onClick = { photoPickerLauncher.launch("image/*") }, + onClick = { + photoPickerLauncher.launch("image/*") + }, modifier = Modifier .fillMaxWidth() .height(56.dp), @@ -253,68 +232,6 @@ fun UserEditScreen( ) } - Spacer(Modifier.height(12.dp)) - - PhoneField( - value = phone, - onValueChange = { value -> - phone = value - phoneWarning = when { - !isAllowedDigitsInput(value) -> PHONE_CHARS_WARNING - value.length > 11 -> PHONE_FORMAT_WARNING - value.isNotEmpty() && value.first() !in listOf('7', '8') -> PHONE_FORMAT_WARNING - else -> null - } - }, - label = "Телефон", - warning = phoneWarning - ) - - Spacer(Modifier.height(4.dp)) - Text( - text = "Оставьте поле пустым, если номер менять не нужно.", - style = MaterialTheme.typography.bodySmall, - color = UrbanBrown - ) - - Spacer(Modifier.height(12.dp)) - - PasswordField( - value = oldPassword, - onValueChange = { oldPassword = it }, - label = "Старый пароль" - ) - - Spacer(Modifier.height(12.dp)) - - PasswordField( - value = newPassword, - onValueChange = { newPassword = it }, - label = "Новый пароль" - ) - - Spacer(Modifier.height(12.dp)) - - PasswordField( - value = confirmNewPassword, - onValueChange = { confirmNewPassword = it }, - label = "Подтвердите новый пароль" - ) - - Spacer(Modifier.height(4.dp)) - Text( - text = "Чтобы сменить пароль, заполните старый пароль и дважды введите новый.", - style = MaterialTheme.typography.bodySmall, - color = UrbanBrown - ) - - AuthSuccessText( - text = successMessage, - onTimeout = { - errorText = null - userViewModel.clearMessages() - } - ) AuthStatusText( text = finalError, onTimeout = { @@ -343,41 +260,13 @@ fun UserEditScreen( return@PrimaryButton } - val phoneDigits = - phone.takeIf { it.isNotBlank() }?.let { normalizeRequiredRussianPhone(it) } - - if (phone.isNotBlank() && phoneDigits == null) { - phoneWarning = PHONE_FORMAT_WARNING - errorText = "Некорректный телефон" - return@PrimaryButton - } - - val oldPass = oldPassword.takeIf { it.isNotBlank() } - val newPass = newPassword.takeIf { it.isNotBlank() } - val confirmPass = confirmNewPassword.takeIf { it.isNotBlank() } - - if (!newPass.isNullOrBlank() || !confirmPass.isNullOrBlank() || !oldPass.isNullOrBlank()) { - if (oldPass.isNullOrBlank() || newPass.isNullOrBlank() || confirmPass.isNullOrBlank()) { - errorText = "Для смены пароля заполните все три поля" - return@PrimaryButton - } - if (newPass != confirmPass) { - errorText = "Новые пароли не совпадают" - return@PrimaryButton - } + if (hasProfileChanges) { + userViewModel.updateUser( + firstName = firstNameNormalized, + lastName = lastNameNormalized + ) } - userViewModel.updateUser( - firstName = firstNameNormalized, - lastName = lastNameNormalized, - phone = phoneDigits?.let { formatPhoneForRequest(it) }, - oldPassword = oldPass, - newPassword = newPass - ) - - oldPassword = "" - newPassword = "" - confirmNewPassword = "" errorText = null } } diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/UserProfileScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/UserProfileScreen.kt index 54ad2c4..7e23047 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/screens/UserProfileScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/UserProfileScreen.kt @@ -2,7 +2,6 @@ package com.example.goodroad.ui.user import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.* import androidx.compose.runtime.* @@ -17,7 +16,6 @@ import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.example.goodroad.modules.user.presentation.UserViewModel import com.example.goodroad.ui.UserDecor -import com.example.goodroad.ui.buttons.PrimaryButton import com.example.goodroad.ui.theme.* @Composable @@ -27,6 +25,7 @@ fun UserProfileScreen( onDelete: () -> Unit, onLogout: () -> Unit, onSelectObstacles: () -> Unit, + onSecurity: () -> Unit, onBecomeVolunteer: () -> Unit = {}, onNavigateToRewards: () -> Unit = {}, onNavigateToTasks: () -> Unit = {} @@ -43,21 +42,36 @@ fun UserProfileScreen( when { isLoading && user == null -> { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { CircularProgressIndicator() } } userViewModel.isDeleted -> { - LaunchedEffect(Unit) { onLogout() } - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LaunchedEffect(Unit) { + onLogout() + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { CircularProgressIndicator() } } errorMessage != null && user == null -> { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Ошибка: $errorMessage", color = Color.Red) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Ошибка: $errorMessage", + color = Color.Red + ) } } @@ -68,13 +82,13 @@ fun UserProfileScreen( modifier = Modifier.fillMaxSize(), color = BackgroundLight ) { - LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(24.dp), verticalArrangement = Arrangement.spacedBy(14.dp) ) { + item { UserDecor() @@ -99,7 +113,9 @@ fun UserProfileScreen( verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.weight(1f)) { + Column( + modifier = Modifier.weight(1f) + ) { Text( text = "${u.firstName ?: ""} ${u.lastName ?: ""}".trim(), fontSize = 22.sp, @@ -123,7 +139,9 @@ fun UserProfileScreen( shape = CircleShape, color = WhiteSoft ) { - Box(contentAlignment = Alignment.Center) { + Box( + contentAlignment = Alignment.Center + ) { Text( text = "👤", fontSize = 32.sp @@ -136,7 +154,7 @@ fun UserProfileScreen( } item { - SectionTitle("Настройки достуности") + SectionTitle("Настройки доступности") } item { @@ -147,6 +165,7 @@ fun UserProfileScreen( ) } + item { SectionTitle("Мой вклад") } @@ -187,6 +206,15 @@ fun UserProfileScreen( ) } + + item { + ServiceCard( + title = "Безопасность", + description = "Смена пароля, телефона", + onClick = onSecurity + ) + } + item { ServiceCard( title = "Удалить аккаунт", @@ -199,7 +227,11 @@ fun UserProfileScreen( ServiceCard( title = "Выйти из аккаунта", description = "Завершить текущую сессию", - onClick = { userViewModel.logout { onLogout() } } + onClick = { + userViewModel.logout { + onLogout() + } + } ) } } @@ -207,7 +239,10 @@ fun UserProfileScreen( } else -> { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { CircularProgressIndicator() } } @@ -215,7 +250,9 @@ fun UserProfileScreen( } @Composable -private fun SectionTitle(text: String) { +private fun SectionTitle( + text: String +) { Text( text = text, style = MaterialTheme.typography.titleMedium, @@ -232,6 +269,7 @@ private fun ServiceCard( ) { Card( onClick = onClick, + modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = SurfaceWarm ), @@ -251,7 +289,9 @@ private fun ServiceCard( color = TextPrimary ) - Spacer(Modifier.height(4.dp)) + Spacer( + modifier = Modifier.height(4.dp) + ) Text( text = description, @@ -260,4 +300,4 @@ private fun ServiceCard( ) } } -} \ No newline at end of file +} 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 3880fd5..c398295 100644 --- a/app/src/main/java/com/example/goodroad/modules/volunteer/screens/HelpRequestCreateScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/HelpRequestCreateScreen.kt @@ -24,14 +24,16 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.TextRange import androidx.compose.ui.unit.dp import com.example.goodroad.modules.volunteer.presentation.VolunteerViewModel import com.example.goodroad.ui.UserDecor import com.example.goodroad.ui.buttons.PrimaryButton +import com.example.goodroad.ui.fields.PhoneField +import com.example.goodroad.ui.fields.PhoneValidation +import com.example.goodroad.ui.fields.validatePhone +import com.example.goodroad.ui.fields.toFormattedPhone import com.example.goodroad.ui.theme.BackgroundLight -import androidx.compose.ui.text.input.TextFieldValue -//import androidx.compose.ui.text.input.TextRange +import java.time.LocalDateTime @Composable fun HelpRequestCreateScreen( @@ -55,6 +57,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() @@ -72,20 +75,56 @@ 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 + } + } + + val phoneValidation = validatePhone(contact) + contactError = when (phoneValidation) { + is PhoneValidation.Empty -> { + valid = false + "Обязательное поле" + } + is PhoneValidation.InvalidChars -> { + valid = false + "Телефон должен содержать только цифры" + } + is PhoneValidation.InvalidFormat -> { + valid = false + "Введите корректный номер телефона" + } + is PhoneValidation.Valid -> null + } + + socialNicknameError = null commentError = if (comment.isBlank()) { valid = false @@ -182,28 +221,29 @@ fun HelpRequestCreateScreen( Spacer(Modifier.height(12.dp)) - OutlinedTextField( + PhoneField( value = contact, onValueChange = { - contact = it.filter { ch -> - ch.isLetterOrDigit() || ch in "+@._-() " - } + contact = it contactError = null }, - label = { Text("Номер телефона *") }, - isError = contactError != null, - supportingText = { contactError?.let { Text(it) } }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - singleLine = true + label = "Номер телефона *", + showPrefix = false, + showIcon = false, + allowEmptyWarning = true ) 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 @@ -247,6 +287,7 @@ fun HelpRequestCreateScreen( text = if (isLoading) "Отправка..." else "Отправить заявку", onClick = { if (!validate()) return@PrimaryButton + val formattedDate = if (meetingDate.length == 8) { "${meetingDate.substring(0, 2)}-${meetingDate.substring(2, 4)}-${meetingDate.substring(4, 8)}" } else { @@ -259,12 +300,19 @@ fun HelpRequestCreateScreen( meetingTime } + // Получаем форматированный номер + val phoneValidation = validatePhone(contact) + val formattedPhone = when (phoneValidation) { + is PhoneValidation.Valid -> phoneValidation.toFormattedPhone()!! + else -> contact // fallback + } + helpViewModel.createRequest( routeStart = routeStart, routeEnd = routeEnd, meetingDate = formattedDate, meetingTime = formattedTime, - contact = contact, + contact = formattedPhone, socialNickname = socialNickname, comment = comment ) { @@ -345,6 +393,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 "Произошла неизвестная ошибка" 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/fields/PhoneField.kt b/app/src/main/java/com/example/goodroad/ui/fields/PhoneField.kt index c8ddb9d..1323a58 100644 --- a/app/src/main/java/com/example/goodroad/ui/fields/PhoneField.kt +++ b/app/src/main/java/com/example/goodroad/ui/fields/PhoneField.kt @@ -5,28 +5,145 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Phone import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.text.input.KeyboardType import com.example.goodroad.ui.theme.* -import com.example.goodroad.validation.PHONE_MAX_LENGTH +import com.example.goodroad.validation.* @Composable fun PhoneField( value: String, onValueChange: (String) -> Unit, label: String, - warning: String? = null, - maxLength: Int = PHONE_MAX_LENGTH + maxLength: Int = PHONE_MAX_LENGTH, + showPrefix: Boolean = true, + showIcon: Boolean = true, + validateOnInput: Boolean = true, + allowEmptyWarning: Boolean = false ) { + val validation = remember(value) { + validatePhone(value, allowEmpty = allowEmptyWarning) + } + + val warning = when (validation) { + is PhoneValidation.InvalidChars -> PHONE_CHARS_WARNING + is PhoneValidation.InvalidFormat -> PHONE_FORMAT_WARNING + is PhoneValidation.Empty -> if (allowEmptyWarning) "Обязательное поле" else null + is PhoneValidation.Valid -> null + } + PlainField( value = value, - onValueChange = onValueChange, + onValueChange = { newValue -> + if (validateOnInput && newValue.any { !it.isDigit() }) { + return@PlainField + } + + val digitsOnly = newValue.filter { it.isDigit() } + val limited = digitsOnly.take(maxLength) + + if (limited != value) { + onValueChange(limited) + } + }, label = label, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), - icon = { - Icon(Icons.Default.Phone, contentDescription = null, tint = UrbanBrown) - }, + icon = if (showIcon) { + { + Icon( + imageVector = Icons.Default.Phone, + contentDescription = null, + tint = UrbanBrown + ) + } + } else null, warning = warning, maxLength = maxLength, - prefix = { androidx.compose.material3.Text("+") } + prefix = if (showPrefix) { + { androidx.compose.material3.Text("+") } + } else null ) +} + +// Единая функция валидации для всех случаев +fun validatePhone( + phone: String, + allowEmpty: Boolean = false +): PhoneValidation { + val trimmed = phone.trim() + + if (trimmed.isEmpty()) { + return PhoneValidation.Empty + } + + if (!trimmed.all { it.isDigit() }) { + return PhoneValidation.InvalidChars + } + + if (trimmed.length > PHONE_MAX_LENGTH) { + return PhoneValidation.InvalidFormat + } + + if (trimmed.first() !in listOf('7', '8')) { + return PhoneValidation.InvalidFormat + } + + if (!isValidRussianPhoneDigits(trimmed)) { + return PhoneValidation.InvalidFormat + } + + val normalized = normalizeRequiredRussianPhone(trimmed) + return if (normalized != null) { + PhoneValidation.Valid(normalized) + } else { + PhoneValidation.InvalidFormat + } +} + +// Проверка с кастомными сообщениями для отправки +fun getPhoneErrorMessage( + phone: String, + required: Boolean = true +): String? { + val trimmed = phone.trim() + + if (required && trimmed.isEmpty()) { + return "Обязательное поле" + } + + if (trimmed.isEmpty()) { + return null + } + + val validation = validatePhone(phone, allowEmpty = true) + + return when (validation) { + is PhoneValidation.InvalidChars -> "Телефон должен содержать только цифры" + is PhoneValidation.InvalidFormat -> "Введите корректный номер телефона (11 цифр, начинается с 7 или 8)" + is PhoneValidation.Empty -> null + is PhoneValidation.Valid -> null + } +} + +// Получить форматированный номер для отправки +fun getFormattedPhoneOrNull(phone: String): String? { + val validation = validatePhone(phone) + return when (validation) { + is PhoneValidation.Valid -> formatPhoneForRequest(validation.phoneDigits) + else -> null + } +} + +sealed class PhoneValidation { + data object Empty : PhoneValidation() + data object InvalidChars : PhoneValidation() + data object InvalidFormat : PhoneValidation() + data class Valid(val phoneDigits: String) : PhoneValidation() +} + +fun PhoneValidation.toFormattedPhone(): String? { + return when (this) { + is PhoneValidation.Valid -> formatPhoneForRequest(phoneDigits) + else -> null + } } \ No newline at end of file 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, 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) - ) } }