fix: fix phone field on profile screen, bottom bar beautify; feat: show buttons only after change, on profile edit

This commit is contained in:
MaximOksiuta
2025-11-22 05:43:40 +03:00
parent 09ff18cb02
commit d1e38cdfe8
7 changed files with 536 additions and 469 deletions
@@ -25,6 +25,7 @@ data class ValidationResult(
@Single
class ValidateAuthFieldsUseCase {
fun validateProfile(
chosenPattern: PhoneNumberPattern?,
firstName: String,
lastName: String,
email: String,
@@ -34,7 +35,11 @@ class ValidateAuthFieldsUseCase {
if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя")
if (lastName.isBlank()) put(AuthField.LastName, "Введите фамилию")
if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email")
if (!isPhoneValid(phone)) put(AuthField.Phone, "Некорректный номер телефона")
val maxCount = chosenPattern!!.pattern.count { it == '0' }
if (phone.isNotBlank() && !isPhoneValid(phone) && phone.length != maxCount) put(
AuthField.Phone,
"Некорректный номер телефона"
)
}
return ValidationResult(errors)
}
@@ -4,6 +4,8 @@ import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -59,7 +61,6 @@ fun TBottomNavigation(modifier: Modifier = Modifier, selectedPage: Int, onSelect
}
target?.let { (it - center).toDp() }
}
AnimatedVisibility(indicatorOffset != null) {
indicatorOffset?.let {
Box(
modifier = Modifier
@@ -71,7 +72,6 @@ fun TBottomNavigation(modifier: Modifier = Modifier, selectedPage: Int, onSelect
)
)
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Icon(
modifier = Modifier
@@ -0,0 +1,158 @@
package com.prodhack.moscow2025.presentation.components.standart
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
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
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.presentation.screens.fillProfile.UIPhoneNumberPattern
import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes
import com.prodhack.moscow2025.presentation.utils.PhoneVisualTransformation
@Composable
fun TPhoneField(
modifier: Modifier = Modifier,
currentPattern: UIPhoneNumberPattern?,
currentPhone: String,
onPhoneChange: (String) -> Unit,
error: String?,
onOpenCountryList: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = modifier.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(
Paddings.medium
)
) {
FieldWrapper(
modifier = Modifier
.width(IntrinsicSize.Min)
.fillMaxHeight()
) {
BasicTextField(
modifier = Modifier
.fillMaxSize()
.offset(y = 5.dp)
.padding(bottom = 16.dp)
.background(colorScheme.primary, Shapes.smallRoundedBox)
.clip(Shapes.smallRoundedBox),
value = currentPattern?.prefix ?: "",
onValueChange = {},
readOnly = true,
textStyle = TextStyle(
color = colorScheme.onPrimary
),
decorationBox = { innerTextField ->
Row(
modifier = Modifier
.clickable {
onOpenCountryList()
}
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.weight(1f)) {
innerTextField()
}
Icon(
modifier = Modifier.size(15.dp),
painter = painterResource(R.drawable.ic_arr_dropdown),
tint = colorScheme.onPrimary,
contentDescription = null
)
}
}
)
}
TTTextField(
value = currentPhone,
onValueChange = onPhoneChange,
label = "Ваш телефон",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone
),
visualTransformation = currentPattern?.let {
PhoneVisualTransformation(
it.pattern,
'0'
)
} ?: VisualTransformation.None,
error = error
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TPhoneCountryList(
modifier: Modifier = Modifier,
isSheetOpen: MutableState<Boolean>,
sheetState: SheetState,
patternList: List<UIPhoneNumberPattern>,
setPattern: (UIPhoneNumberPattern) -> Unit
) {
if (isSheetOpen.value) {
ModalBottomSheet(
modifier = modifier,
sheetState = sheetState,
onDismissRequest = {
isSheetOpen.value = false
},
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(patternList) { pattern ->
Text(
text = pattern.name,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
.clickable {
setPattern(pattern)
isSheetOpen.value = false
}
)
}
}
}
}
}
}
@@ -44,7 +44,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
@@ -56,6 +55,8 @@ import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.FieldWrapper
import com.prodhack.moscow2025.presentation.components.standart.TPhoneCountryList
import com.prodhack.moscow2025.presentation.components.standart.TPhoneField
import com.prodhack.moscow2025.presentation.components.standart.TTTextField
import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes
@@ -165,70 +166,16 @@ fun ErrorCollectorScope.FillProfileScreen(
error = formState.errors[AuthField.LastName],
)
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(
Paddings.medium
)
) {
FieldWrapper(modifier = Modifier
.width(IntrinsicSize.Min)
.fillMaxHeight()) {
BasicTextField(
modifier = Modifier
.fillMaxSize()
.offset(y = 5.dp)
.padding(bottom = 16.dp)
.background(colorScheme.primary, Shapes.smallRoundedBox)
.clip(Shapes.smallRoundedBox),
value = viewModel.chosenPattern.value?.prefix ?: "",
onValueChange = {},
readOnly = true,
textStyle = TextStyle(
color = colorScheme.onPrimary
),
decorationBox = { innerTextField ->
Row(
modifier = Modifier
.clickable {
TPhoneField(
currentPattern = viewModel.currentPattern.value,
currentPhone = formState.phone,
onPhoneChange = viewModel::onPhoneChange,
error = formState.errors[AuthField.Phone],
onOpenCountryList =
{
isSheetOpen.value = true
}
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.weight(1f)) {
innerTextField()
}
Icon(
modifier = Modifier.size(15.dp),
painter = painterResource(R.drawable.ic_arr_dropdown),
tint = colorScheme.onPrimary,
contentDescription = null
)
}
}
)
}
TTTextField(
value = formState.phone,
onValueChange = viewModel::onPhoneChange,
label = "Ваш телефон",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone
),
visualTransformation = viewModel.chosenPattern.value?.pattern?.let {
PhoneVisualTransformation(
it,
'0'
)
} ?: VisualTransformation.None,
error = formState.errors[AuthField.Phone]
)
Log.d("Test", formState.errors[AuthField.Phone].toString())
}
Spacer(modifier = Modifier.height(20.dp))
BigButton(
@@ -241,32 +188,12 @@ fun ErrorCollectorScope.FillProfileScreen(
}
}
if (isSheetOpen.value) {
ModalBottomSheet(
TPhoneCountryList(
isSheetOpen = isSheetOpen,
sheetState = sheetState,
onDismissRequest = {
isSheetOpen.value = false
},
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(viewModel.phoneNumberPatterns) { pattern ->
Text(
text = pattern.name,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
.clickable {
viewModel.chosenPattern.value = pattern
isSheetOpen.value = false
patternList = viewModel.phoneNumberPatterns,
setPattern = {
viewModel.currentPattern.value = it
}
)
}
}
}
}
}
}
@@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel
import org.koin.core.annotation.Single
data class FillProfileFormState(
val firstName: String = "",
@@ -95,7 +94,7 @@ class FillProfileViewModel(
}
fun onPhoneChange(value: String) {
val maxDigits = chosenPattern.value?.pattern?.count { it == '0' } ?: Int.MAX_VALUE
val maxDigits = currentPattern.value?.pattern?.count { it == '0' } ?: Int.MAX_VALUE
val digits = value.filter { it.isDigit() }.take(maxDigits)
_formStateFillProfile.update {
it.copy(
@@ -153,14 +152,14 @@ class FillProfileViewModel(
}
val chosenPattern = mutableStateOf<UIPhoneNumberPattern?>(null)
val currentPattern = mutableStateOf<UIPhoneNumberPattern?>(null)
val phoneNumberPatterns = mutableStateListOf<UIPhoneNumberPattern>()
fun update() {
// Load default pattern
chosenPattern.value = getDefaultPhoneNumberPatternUseCase.execute()?.mapToUI()
currentPattern.value = getDefaultPhoneNumberPatternUseCase.execute()?.mapToUI()
// Load all phone number patterns
phoneNumberPatterns.clear()
@@ -173,7 +172,7 @@ class FillProfileViewModel(
firstName = _formStateFillProfile.value.firstName,
lastName = _formStateFillProfile.value.lastName,
phone = _formStateFillProfile.value.phone,
chosenPattern = chosenPattern.value?.mapToDomain()
chosenPattern = currentPattern.value?.mapToDomain()
)
if (!validation.isValid) {
@@ -187,7 +186,7 @@ class FillProfileViewModel(
UpdateUserData(
firstName = _formStateFillProfile.value.firstName,
lastName = _formStateFillProfile.value.lastName,
phone = chosenPattern.value?.mapToDomain()?.let { phoneNumberPattern ->
phone = currentPattern.value?.mapToDomain()?.let { phoneNumberPattern ->
convertNumberToPattern(
phoneNumberPattern,
_formStateFillProfile.value.phone
@@ -51,6 +51,8 @@ import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.FieldWrapper
import com.prodhack.moscow2025.presentation.components.standart.TPhoneCountryList
import com.prodhack.moscow2025.presentation.components.standart.TPhoneField
import com.prodhack.moscow2025.presentation.components.standart.TTTextField
import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes
@@ -118,31 +120,31 @@ fun ErrorCollectorScope.ProfileScreen(
.fillMaxSize()
.imePadding()
.systemBarsPadding()
.padding(horizontal = 30.dp),
.padding(horizontal = Paddings.large),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Spacer(Modifier.height(32.dp))
Spacer(Modifier.height(Paddings.large))
Text(
text = "Профиль",
style = typography.titleLarge,
fontSize = 40.sp
)
Spacer(Modifier.height(20.dp))
Spacer(Modifier.height(Paddings.large))
TTTextField(
value = formState.firstName,
onValueChange = viewModel::onFirstNameChange,
label = "Имя",
error = formState.errors[AuthField.FirstName],
)
Spacer(Modifier.height(12.dp))
Spacer(Modifier.height(Paddings.medium))
TTTextField(
value = formState.lastName,
onValueChange = viewModel::onLastNameChange,
label = "Фамилия",
error = formState.errors[AuthField.LastName],
)
Spacer(Modifier.height(12.dp))
Spacer(Modifier.height(Paddings.medium))
TTTextField(
value = formState.email,
onValueChange = viewModel::onEmailChange,
@@ -150,75 +152,48 @@ fun ErrorCollectorScope.ProfileScreen(
error = formState.errors[AuthField.Email],
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
)
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Paddings.medium)
) {
FieldWrapper(
modifier = Modifier
.width(IntrinsicSize.Min)
.fillMaxHeight()
) {
BasicTextField(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 16.dp)
.clip(Shapes.smallRoundedBox)
.background(
color = colorScheme.primary,
shape = Shapes.smallRoundedBox
),
value = viewModel.chosenPattern.value?.prefix ?: "",
onValueChange = {},
readOnly = true,
textStyle = TextStyle(
color = colorScheme.onPrimary
),
decorationBox = { innerTextField ->
Row(
modifier = Modifier
.clickable { isSheetOpen.value = true }
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.weight(1f)) {
innerTextField()
}
Icon(
modifier = Modifier.size(15.dp),
painter = painterResource(R.drawable.ic_arr_dropdown),
tint = colorScheme.onPrimary,
contentDescription = null
)
}
}
)
}
Spacer(Modifier.height(Paddings.medium))
TTTextField(
value = formState.phone,
onValueChange = viewModel::onPhoneChange,
label = "Телефон",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone
),
visualTransformation = viewModel.chosenPattern.value?.pattern?.let {
PhoneVisualTransformation(it, '0')
} ?: VisualTransformation.None,
error = formState.errors[AuthField.Phone]
)
TPhoneField(
currentPattern = viewModel.chosenPattern.value,
currentPhone = formState.phone,
onPhoneChange = viewModel::onPhoneChange,
error = formState.errors[AuthField.Phone],
onOpenCountryList = {
isSheetOpen.value = true
}
)
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(Paddings.large))
if (viewModel.madeChanges.collectAsState().value) {
BigButton(
onClick = viewModel::submit,
modifier = Modifier.fillMaxWidth(),
buttonText = "Сохранить",
isLoading = profileState is UIState.Loading
)
Spacer(Modifier.height(15.dp))
Spacer(Modifier.height(Paddings.medium))
Button(
modifier = modifier
.fillMaxWidth()
.height(60.dp),
shape = Shapes.smallRoundedBox,
onClick = viewModel::reset,
colors = ButtonColors(
containerColor = colorScheme.secondaryContainer,
contentColor = colorScheme.onSecondaryContainer,
disabledContainerColor = colorScheme.secondaryContainer,
disabledContentColor = colorScheme.onSecondaryContainer
)
) {
Text(
text = "Отменить",
style = typography.titleMedium,
fontSize = 24.sp
)
}
Spacer(Modifier.height(Paddings.medium))
}
Button(
modifier = modifier
.fillMaxWidth()
@@ -250,34 +225,15 @@ fun ErrorCollectorScope.ProfileScreen(
)
}
}
Spacer(modifier = Modifier.height(48.dp))
Spacer(modifier = Modifier.height(Paddings.large))
}
if (isSheetOpen.value) {
ModalBottomSheet(
TPhoneCountryList(
isSheetOpen = isSheetOpen,
sheetState = sheetState,
onDismissRequest = { isSheetOpen.value = false },
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(viewModel.phoneNumberPatterns) { pattern ->
Text(
text = pattern.name,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
.clickable {
viewModel.chosenPattern.value = pattern
viewModel.onPhoneChange(formState.phone)
isSheetOpen.value = false
patternList = viewModel.phoneNumberPatterns,
setPattern = {
viewModel.chosenPattern.value = it
}
)
}
}
}
}
}
}
@@ -6,6 +6,8 @@ import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.viewModelScope
@@ -28,8 +30,11 @@ import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import com.prodhack.moscow2025.presentation.utils.convertNumberToPattern
import com.prodhack.moscow2025.presentation.utils.toByteArray
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel
@@ -78,8 +83,8 @@ class ProfileScreenViewModel(
private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase,
private val logOutUseCase: LogOutUseCase,
private val getDefaultPhoneNumberPatternUseCase: GetDefaultPhoneNumberPatternUseCase,
private val galleryRepository: GalleryRepository
): BaseViewModel() {
galleryRepository: GalleryRepository
) : BaseViewModel() {
private val _formStateProfile = MutableStateFlow(ProfileState())
val formStateFillProfile: StateFlow<ProfileState> = _formStateProfile
@@ -89,6 +94,28 @@ class ProfileScreenViewModel(
val chosenPattern = mutableStateOf<UIPhoneNumberPattern?>(null)
val phoneNumberPatterns = mutableStateListOf<UIPhoneNumberPattern>()
private val realState = MutableStateFlow(_formStateProfile.value)
val madeChanges = _formStateProfile.combine(realState) { current, real ->
current.phone != real.phone ||
current.firstName != real.firstName ||
current.lastName != real.lastName ||
current.email != real.email
}.stateIn(viewModelScope, SharingStarted.Lazily, false)
fun reset() {
if (madeChanges.value) {
_formStateProfile.update {
it.copy(
email = realState.value.email,
phone = realState.value.phone,
firstName = realState.value.firstName,
lastName = realState.value.lastName,
)
}
}
}
fun onEmailChange(value: String) {
_formStateProfile.update {
it.copy(
@@ -175,7 +202,9 @@ class ProfileScreenViewModel(
fun submit() {
viewModelScope.launch {
val pattern = chosenPattern.value
val validation = validateAuthFieldsUseCase.validateProfile(
chosenPattern = pattern?.mapToDomain(),
firstName = _formStateProfile.value.firstName,
lastName = _formStateProfile.value.lastName,
email = _formStateProfile.value.email,
@@ -184,15 +213,6 @@ class ProfileScreenViewModel(
val errors = validation.errors.toMutableMap()
val pattern = chosenPattern.value
if (pattern == null) {
errors[AuthField.Phone] = "Выберите код страны"
} else {
val expectedDigits = pattern.pattern.count { it == '0' }
if (_formStateProfile.value.phone.length != expectedDigits) {
errors[AuthField.Phone] = "Номер должен содержать $expectedDigits цифр"
}
}
if (errors.isNotEmpty()) {
_formStateProfile.update { it.copy(errors = errors) }
@@ -214,6 +234,7 @@ class ProfileScreenViewModel(
)
)
result.map { it.id }.collectRequest(_profileState)
update()
}
}
@@ -223,7 +244,7 @@ class ProfileScreenViewModel(
}
}
init {
fun update() {
viewModelScope.launch {
loadPhonePatterns()
@@ -233,12 +254,8 @@ class ProfileScreenViewModel(
val selectedPattern = phoneNumberPatterns.firstOrNull { pattern ->
val codeDigits = pattern.countryCode.filter { it.isDigit() }
digits.startsWith(codeDigits) && digits.length >= codeDigits.length
} ?: phoneNumberPatterns.firstOrNull {
it.countryCodeISO.equals(
getDefaultPhoneNumberPatternUseCase.execute()?.countryCodeISO,
ignoreCase = true
)
} ?: phoneNumberPatterns.firstOrNull()
} ?: getDefaultPhoneNumberPatternUseCase.execute()?.mapToUI()
?: phoneNumberPatterns.firstOrNull()
selectedPattern?.let { chosenPattern.value = it }
@@ -257,10 +274,15 @@ class ProfileScreenViewModel(
phone = digitsWithoutCode.take(maxDigits)
)
}
realState.emit(_formStateProfile.value)
}
}
}
init {
update()
}
private fun loadPhonePatterns() {
phoneNumberPatterns.clear()
phoneNumberPatterns.addAll(