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 @Single
class ValidateAuthFieldsUseCase { class ValidateAuthFieldsUseCase {
fun validateProfile( fun validateProfile(
chosenPattern: PhoneNumberPattern?,
firstName: String, firstName: String,
lastName: String, lastName: String,
email: String, email: String,
@@ -34,7 +35,11 @@ class ValidateAuthFieldsUseCase {
if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя") if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя")
if (lastName.isBlank()) put(AuthField.LastName, "Введите фамилию") if (lastName.isBlank()) put(AuthField.LastName, "Введите фамилию")
if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email") 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) return ValidationResult(errors)
} }
@@ -4,6 +4,8 @@ import android.util.Log
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState 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.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -59,18 +61,16 @@ fun TBottomNavigation(modifier: Modifier = Modifier, selectedPage: Int, onSelect
} }
target?.let { (it - center).toDp() } target?.let { (it - center).toDp() }
} }
AnimatedVisibility(indicatorOffset != null) { indicatorOffset?.let {
indicatorOffset?.let { Box(
Box( modifier = Modifier
modifier = Modifier .size(85.dp, 45.dp)
.size(85.dp, 45.dp) .offset(x = animateDpAsState(it).value)
.offset(x = animateDpAsState(it).value) .background(
.background( MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.primary, shape = Shapes.smallRoundedBox
shape = Shapes.smallRoundedBox )
) )
)
}
} }
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Icon( Icon(
@@ -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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle 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.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.presentation.components.standart.BigButton import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.FieldWrapper 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.components.standart.TTTextField
import com.prodhack.moscow2025.presentation.theme.Paddings import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes import com.prodhack.moscow2025.presentation.theme.Shapes
@@ -165,70 +166,16 @@ fun ErrorCollectorScope.FillProfileScreen(
error = formState.errors[AuthField.LastName], error = formState.errors[AuthField.LastName],
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
TPhoneField(
Row( currentPattern = viewModel.currentPattern.value,
modifier = Modifier.height(IntrinsicSize.Min), currentPhone = formState.phone,
verticalAlignment = Alignment.CenterVertically, onPhoneChange = viewModel::onPhoneChange,
horizontalArrangement = Arrangement.spacedBy( error = formState.errors[AuthField.Phone],
Paddings.medium onOpenCountryList =
) {
) { isSheetOpen.value = true
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 {
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)) Spacer(modifier = Modifier.height(20.dp))
BigButton( BigButton(
@@ -241,32 +188,12 @@ fun ErrorCollectorScope.FillProfileScreen(
} }
} }
if (isSheetOpen.value) { TPhoneCountryList(
ModalBottomSheet( isSheetOpen = isSheetOpen,
sheetState = sheetState, sheetState = sheetState,
onDismissRequest = { patternList = viewModel.phoneNumberPatterns,
isSheetOpen.value = false setPattern = {
}, viewModel.currentPattern.value = it
) {
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
}
)
}
}
}
} }
} )
} }
@@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel import org.koin.android.annotation.KoinViewModel
import org.koin.core.annotation.Single
data class FillProfileFormState( data class FillProfileFormState(
val firstName: String = "", val firstName: String = "",
@@ -95,7 +94,7 @@ class FillProfileViewModel(
} }
fun onPhoneChange(value: String) { 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) val digits = value.filter { it.isDigit() }.take(maxDigits)
_formStateFillProfile.update { _formStateFillProfile.update {
it.copy( it.copy(
@@ -153,14 +152,14 @@ class FillProfileViewModel(
} }
val chosenPattern = mutableStateOf<UIPhoneNumberPattern?>(null) val currentPattern = mutableStateOf<UIPhoneNumberPattern?>(null)
val phoneNumberPatterns = mutableStateListOf<UIPhoneNumberPattern>() val phoneNumberPatterns = mutableStateListOf<UIPhoneNumberPattern>()
fun update() { fun update() {
// Load default pattern // Load default pattern
chosenPattern.value = getDefaultPhoneNumberPatternUseCase.execute()?.mapToUI() currentPattern.value = getDefaultPhoneNumberPatternUseCase.execute()?.mapToUI()
// Load all phone number patterns // Load all phone number patterns
phoneNumberPatterns.clear() phoneNumberPatterns.clear()
@@ -173,7 +172,7 @@ class FillProfileViewModel(
firstName = _formStateFillProfile.value.firstName, firstName = _formStateFillProfile.value.firstName,
lastName = _formStateFillProfile.value.lastName, lastName = _formStateFillProfile.value.lastName,
phone = _formStateFillProfile.value.phone, phone = _formStateFillProfile.value.phone,
chosenPattern = chosenPattern.value?.mapToDomain() chosenPattern = currentPattern.value?.mapToDomain()
) )
if (!validation.isValid) { if (!validation.isValid) {
@@ -187,7 +186,7 @@ class FillProfileViewModel(
UpdateUserData( UpdateUserData(
firstName = _formStateFillProfile.value.firstName, firstName = _formStateFillProfile.value.firstName,
lastName = _formStateFillProfile.value.lastName, lastName = _formStateFillProfile.value.lastName,
phone = chosenPattern.value?.mapToDomain()?.let { phoneNumberPattern -> phone = currentPattern.value?.mapToDomain()?.let { phoneNumberPattern ->
convertNumberToPattern( convertNumberToPattern(
phoneNumberPattern, phoneNumberPattern,
_formStateFillProfile.value.phone _formStateFillProfile.value.phone
@@ -51,6 +51,8 @@ import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.domain.usecase.auth.AuthField import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.presentation.components.standart.BigButton import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.FieldWrapper 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.components.standart.TTTextField
import com.prodhack.moscow2025.presentation.theme.Paddings import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes import com.prodhack.moscow2025.presentation.theme.Shapes
@@ -66,7 +68,7 @@ import org.koin.androidx.compose.koinViewModel
fun ErrorCollectorScope.ProfileScreen( fun ErrorCollectorScope.ProfileScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
navigateToLoginScreen: () -> Unit, navigateToLoginScreen: () -> Unit,
viewModel: ProfileScreenViewModel = koinViewModel() viewModel: ProfileScreenViewModel = koinViewModel()
) { ) {
val typography = androidx.compose.material3.MaterialTheme.typography val typography = androidx.compose.material3.MaterialTheme.typography
@@ -113,171 +115,125 @@ fun ErrorCollectorScope.ProfileScreen(
} }
} }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.imePadding() .imePadding()
.systemBarsPadding() .systemBarsPadding()
.padding(horizontal = 30.dp), .padding(horizontal = Paddings.large),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top verticalArrangement = Arrangement.Top
) { ) {
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(Paddings.large))
Text( Text(
text = "Профиль", text = "Профиль",
style = typography.titleLarge, style = typography.titleLarge,
fontSize = 40.sp fontSize = 40.sp
) )
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(Paddings.large))
TTTextField( TTTextField(
value = formState.firstName, value = formState.firstName,
onValueChange = viewModel::onFirstNameChange, onValueChange = viewModel::onFirstNameChange,
label = "Имя", label = "Имя",
error = formState.errors[AuthField.FirstName], error = formState.errors[AuthField.FirstName],
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(Paddings.medium))
TTTextField( TTTextField(
value = formState.lastName, value = formState.lastName,
onValueChange = viewModel::onLastNameChange, onValueChange = viewModel::onLastNameChange,
label = "Фамилия", label = "Фамилия",
error = formState.errors[AuthField.LastName], error = formState.errors[AuthField.LastName],
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(Paddings.medium))
TTTextField( TTTextField(
value = formState.email, value = formState.email,
onValueChange = viewModel::onEmailChange, onValueChange = viewModel::onEmailChange,
label = "Email", label = "Email",
error = formState.errors[AuthField.Email], error = formState.errors[AuthField.Email],
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email) keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(Paddings.medium))
Row(
modifier = Modifier.height(IntrinsicSize.Min), TPhoneField(
verticalAlignment = Alignment.CenterVertically, currentPattern = viewModel.chosenPattern.value,
horizontalArrangement = Arrangement.spacedBy(Paddings.medium) currentPhone = formState.phone,
) { onPhoneChange = viewModel::onPhoneChange,
FieldWrapper( error = formState.errors[AuthField.Phone],
modifier = Modifier onOpenCountryList = {
.width(IntrinsicSize.Min) isSheetOpen.value = true
.fillMaxHeight() }
)
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(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
)
) { ) {
BasicTextField( Text(
modifier = Modifier text = "Отменить",
.fillMaxSize() style = typography.titleMedium,
.padding(bottom = 16.dp) fontSize = 24.sp
.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]
)
} }
Button(
Spacer(modifier = Modifier.height(24.dp)) modifier = modifier
BigButton( .fillMaxWidth()
onClick = viewModel::submit, .height(60.dp),
modifier = Modifier.fillMaxWidth(), shape = Shapes.smallRoundedBox,
buttonText = "Сохранить", onClick = {
isLoading = profileState is UIState.Loading viewModel.logout()
) navigateToLoginScreen()
Spacer(Modifier.height(15.dp)) },
Button( colors = ButtonColors(
modifier = modifier containerColor = colorScheme.errorContainer,
.fillMaxWidth() contentColor = colorScheme.onErrorContainer,
.height(60.dp), disabledContainerColor = colorScheme.errorContainer,
shape = Shapes.smallRoundedBox, disabledContentColor = colorScheme.onErrorContainer
onClick = { )
viewModel.logout()
navigateToLoginScreen()
},
colors = ButtonColors(
containerColor = colorScheme.errorContainer,
contentColor = colorScheme.onErrorContainer,
disabledContainerColor = colorScheme.errorContainer,
disabledContentColor = colorScheme.onErrorContainer
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Выйти из аккаунта",
style = typography.titleMedium,
fontSize = 24.sp
)
Spacer(Modifier.width(Paddings.small))
Icon(
painter = painterResource(R.drawable.logout_icon),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(modifier = Modifier.height(48.dp))
}
if (isSheetOpen.value) {
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = { isSheetOpen.value = false },
) { ) {
Column(
horizontalAlignment = Alignment.CenterHorizontally, Row(verticalAlignment = Alignment.CenterVertically) {
verticalArrangement = Arrangement.Center Text(
) { text = "Выйти из аккаунта",
LazyColumn(modifier = Modifier.fillMaxSize()) { style = typography.titleMedium,
items(viewModel.phoneNumberPatterns) { pattern -> fontSize = 24.sp
Text( )
text = pattern.name, Spacer(Modifier.width(Paddings.small))
modifier = Modifier Icon(
.fillMaxWidth() painter = painterResource(R.drawable.logout_icon),
.padding(10.dp) contentDescription = null,
.clickable { modifier = Modifier.size(24.dp)
viewModel.chosenPattern.value = pattern )
viewModel.onPhoneChange(formState.phone)
isSheetOpen.value = false
}
)
}
}
} }
} }
Spacer(modifier = Modifier.height(Paddings.large))
} }
TPhoneCountryList(
isSheetOpen = isSheetOpen,
sheetState = sheetState,
patternList = viewModel.phoneNumberPatterns,
setPattern = {
viewModel.chosenPattern.value = it
}
)
} }
@@ -6,6 +6,8 @@ import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.BitmapDrawable
import android.net.Uri import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -28,247 +30,267 @@ import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import com.prodhack.moscow2025.presentation.utils.convertNumberToPattern import com.prodhack.moscow2025.presentation.utils.convertNumberToPattern
import com.prodhack.moscow2025.presentation.utils.toByteArray import com.prodhack.moscow2025.presentation.utils.toByteArray
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel import org.koin.android.annotation.KoinViewModel
data class ProfileState( data class ProfileState(
val email: String = "", val email: String = "",
val firstName: String = "", val firstName: String = "",
val lastName: String = "", val lastName: String = "",
val phone: String = "", val phone: String = "",
val avatar: ByteArray? = null, val avatar: ByteArray? = null,
val errors: Map<AuthField, String> = emptyMap() val errors: Map<AuthField, String> = emptyMap()
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
other as ProfileState other as ProfileState
if (email != other.email) return false if (email != other.email) return false
if (firstName != other.firstName) return false if (firstName != other.firstName) return false
if (lastName != other.lastName) return false if (lastName != other.lastName) return false
if (phone != other.phone) return false if (phone != other.phone) return false
if (!avatar.contentEquals(other.avatar)) return false if (!avatar.contentEquals(other.avatar)) return false
if (errors != other.errors) return false if (errors != other.errors) return false
return true return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {
var result = email.hashCode() var result = email.hashCode()
result = 31 * result + firstName.hashCode() result = 31 * result + firstName.hashCode()
result = 31 * result + lastName.hashCode() result = 31 * result + lastName.hashCode()
result = 31 * result + phone.hashCode() result = 31 * result + phone.hashCode()
result = 31 * result + (avatar?.contentHashCode() ?: 0) result = 31 * result + (avatar?.contentHashCode() ?: 0)
result = 31 * result + errors.hashCode() result = 31 * result + errors.hashCode()
return result return result
} }
} }
@KoinViewModel @KoinViewModel
class ProfileScreenViewModel( class ProfileScreenViewModel(
private val getUserUseCase: GetUserUseCase, private val getUserUseCase: GetUserUseCase,
private val updateUserUseCase: UpdateUserUseCase, private val updateUserUseCase: UpdateUserUseCase,
private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase, private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase,
private val logOutUseCase: LogOutUseCase, private val logOutUseCase: LogOutUseCase,
private val getDefaultPhoneNumberPatternUseCase: GetDefaultPhoneNumberPatternUseCase, private val getDefaultPhoneNumberPatternUseCase: GetDefaultPhoneNumberPatternUseCase,
private val galleryRepository: GalleryRepository galleryRepository: GalleryRepository
): BaseViewModel() { ) : BaseViewModel() {
private val _formStateProfile = MutableStateFlow(ProfileState()) private val _formStateProfile = MutableStateFlow(ProfileState())
val formStateFillProfile: StateFlow<ProfileState> = _formStateProfile val formStateFillProfile: StateFlow<ProfileState> = _formStateProfile
private val _profileState = MutableUIStateFlow<String>() private val _profileState = MutableUIStateFlow<String>()
val profileState: StateFlow<UIState<String>> = _profileState val profileState: StateFlow<UIState<String>> = _profileState
val chosenPattern = mutableStateOf<UIPhoneNumberPattern?>(null) val chosenPattern = mutableStateOf<UIPhoneNumberPattern?>(null)
val phoneNumberPatterns = mutableStateListOf<UIPhoneNumberPattern>() val phoneNumberPatterns = mutableStateListOf<UIPhoneNumberPattern>()
fun onEmailChange(value: String) { private val realState = MutableStateFlow(_formStateProfile.value)
_formStateProfile.update {
it.copy(
email = value,
errors = it.errors - AuthField.Email
)
}
}
fun onFirstNameChange(value: String) { val madeChanges = _formStateProfile.combine(realState) { current, real ->
_formStateProfile.update { current.phone != real.phone ||
it.copy( current.firstName != real.firstName ||
firstName = value, current.lastName != real.lastName ||
errors = it.errors - AuthField.FirstName current.email != real.email
) }.stateIn(viewModelScope, SharingStarted.Lazily, false)
}
}
fun onLastNameChange(value: String) { fun reset() {
_formStateProfile.update { if (madeChanges.value) {
it.copy( _formStateProfile.update {
lastName = value, it.copy(
errors = it.errors - AuthField.LastName email = realState.value.email,
) phone = realState.value.phone,
} firstName = realState.value.firstName,
} lastName = realState.value.lastName,
)
}
}
}
fun onPhoneChange(value: String) { fun onEmailChange(value: String) {
val maxDigits = chosenPattern.value?.pattern?.count { it == '0' } ?: Int.MAX_VALUE _formStateProfile.update {
val digits = value.filter { it.isDigit() }.take(maxDigits) it.copy(
_formStateProfile.update { email = value,
it.copy( errors = it.errors - AuthField.Email
phone = digits, )
errors = it.errors - AuthField.Phone }
) }
}
}
val galleryItems = galleryRepository.getImagesIds().map { fun onFirstNameChange(value: String) {
it.map { id -> _formStateProfile.update {
ContentUris.withAppendedId( it.copy(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, firstName = value,
id errors = it.errors - AuthField.FirstName
) )
} }
} }
fun post(context: Context) { fun onLastNameChange(value: String) {
viewModelScope.launch { _formStateProfile.update {
post( it.copy(
(ImageLoader(context).execute( lastName = value,
ImageRequest.Builder(context) errors = it.errors - AuthField.LastName
.data(currentPhoto).build() )
).drawable as BitmapDrawable).bitmap }
) }
}
}
fun post(bitmap: Bitmap) { fun onPhoneChange(value: String) {
viewModelScope.launch { val maxDigits = chosenPattern.value?.pattern?.count { it == '0' } ?: Int.MAX_VALUE
_formStateProfile.update { val digits = value.filter { it.isDigit() }.take(maxDigits)
it.copy( _formStateProfile.update {
avatar = bitmap.toByteArray() it.copy(
) phone = digits,
} errors = it.errors - AuthField.Phone
} )
} }
}
fun clearAvatar() { val galleryItems = galleryRepository.getImagesIds().map {
viewModelScope.launch { it.map { id ->
_formStateProfile.update { ContentUris.withAppendedId(
it.copy( MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
avatar = null id
) )
} }
} }
}
var currentPhoto: Uri? = null fun post(context: Context) {
viewModelScope.launch {
post(
(ImageLoader(context).execute(
ImageRequest.Builder(context)
.data(currentPhoto).build()
).drawable as BitmapDrawable).bitmap
)
}
}
fun selectImage(photo: Uri) { fun post(bitmap: Bitmap) {
currentPhoto = photo viewModelScope.launch {
} _formStateProfile.update {
it.copy(
avatar = bitmap.toByteArray()
)
}
}
}
fun submit() { fun clearAvatar() {
viewModelScope.launch { viewModelScope.launch {
val validation = validateAuthFieldsUseCase.validateProfile( _formStateProfile.update {
firstName = _formStateProfile.value.firstName, it.copy(
lastName = _formStateProfile.value.lastName, avatar = null
email = _formStateProfile.value.email, )
phone = _formStateProfile.value.phone }
) }
}
val errors = validation.errors.toMutableMap() var currentPhoto: Uri? = null
val pattern = chosenPattern.value fun selectImage(photo: Uri) {
if (pattern == null) { currentPhoto = photo
errors[AuthField.Phone] = "Выберите код страны" }
} else {
val expectedDigits = pattern.pattern.count { it == '0' }
if (_formStateProfile.value.phone.length != expectedDigits) {
errors[AuthField.Phone] = "Номер должен содержать $expectedDigits цифр"
}
}
if (errors.isNotEmpty()) { fun submit() {
_formStateProfile.update { it.copy(errors = errors) } viewModelScope.launch {
return@launch val pattern = chosenPattern.value
} val validation = validateAuthFieldsUseCase.validateProfile(
chosenPattern = pattern?.mapToDomain(),
firstName = _formStateProfile.value.firstName,
lastName = _formStateProfile.value.lastName,
email = _formStateProfile.value.email,
phone = _formStateProfile.value.phone
)
_profileState.emit(UIState.Loading()) val errors = validation.errors.toMutableMap()
val formattedPhone = pattern?.mapToDomain()?.let { phonePattern ->
convertNumberToPattern(phonePattern, _formStateProfile.value.phone)
} ?: _formStateProfile.value.phone
val result = updateUserUseCase( if (errors.isNotEmpty()) {
UpdateUserData( _formStateProfile.update { it.copy(errors = errors) }
firstName = _formStateProfile.value.firstName, return@launch
lastName = _formStateProfile.value.lastName, }
email = _formStateProfile.value.email,
phone = formattedPhone
)
)
result.map { it.id }.collectRequest(_profileState)
}
}
fun logout() { _profileState.emit(UIState.Loading())
viewModelScope.launch {
logOutUseCase()
}
}
init { val formattedPhone = pattern?.mapToDomain()?.let { phonePattern ->
viewModelScope.launch { convertNumberToPattern(phonePattern, _formStateProfile.value.phone)
loadPhonePatterns() } ?: _formStateProfile.value.phone
val user = getUserUseCase().getOrNull() val result = updateUserUseCase(
if (user != null) { UpdateUserData(
val digits = user.phone.orEmpty().filter { it.isDigit() } firstName = _formStateProfile.value.firstName,
val selectedPattern = phoneNumberPatterns.firstOrNull { pattern -> lastName = _formStateProfile.value.lastName,
val codeDigits = pattern.countryCode.filter { it.isDigit() } email = _formStateProfile.value.email,
digits.startsWith(codeDigits) && digits.length >= codeDigits.length phone = formattedPhone
} ?: phoneNumberPatterns.firstOrNull { )
it.countryCodeISO.equals( )
getDefaultPhoneNumberPatternUseCase.execute()?.countryCodeISO, result.map { it.id }.collectRequest(_profileState)
ignoreCase = true update()
) }
} ?: phoneNumberPatterns.firstOrNull() }
selectedPattern?.let { chosenPattern.value = it } fun logout() {
viewModelScope.launch {
logOutUseCase()
}
}
val digitsWithoutCode = selectedPattern?.let { fun update() {
val codeDigits = it.countryCode.filter { d -> d.isDigit() } viewModelScope.launch {
if (digits.startsWith(codeDigits)) digits.drop(codeDigits.length) else digits loadPhonePatterns()
} ?: digits
val maxDigits = selectedPattern?.pattern?.count { it == '0' } ?: Int.MAX_VALUE val user = getUserUseCase().getOrNull()
if (user != null) {
val digits = user.phone.orEmpty().filter { it.isDigit() }
val selectedPattern = phoneNumberPatterns.firstOrNull { pattern ->
val codeDigits = pattern.countryCode.filter { it.isDigit() }
digits.startsWith(codeDigits) && digits.length >= codeDigits.length
} ?: getDefaultPhoneNumberPatternUseCase.execute()?.mapToUI()
?: phoneNumberPatterns.firstOrNull()
_formStateProfile.update { selectedPattern?.let { chosenPattern.value = it }
it.copy(
firstName = user.firstName.orEmpty(),
lastName = user.lastName.orEmpty(),
email = user.email,
phone = digitsWithoutCode.take(maxDigits)
)
}
}
}
}
private fun loadPhonePatterns() { val digitsWithoutCode = selectedPattern?.let {
phoneNumberPatterns.clear() val codeDigits = it.countryCode.filter { d -> d.isDigit() }
phoneNumberPatterns.addAll( if (digits.startsWith(codeDigits)) digits.drop(codeDigits.length) else digits
PhoneNumberPatternsProvider.phoneNumberPatterns.map { it.mapToUI() } } ?: digits
)
if (chosenPattern.value == null) { val maxDigits = selectedPattern?.pattern?.count { it == '0' } ?: Int.MAX_VALUE
val defaultPattern = getDefaultPhoneNumberPatternUseCase.execute()?.mapToUI()
chosenPattern.value = defaultPattern ?: phoneNumberPatterns.firstOrNull() _formStateProfile.update {
} it.copy(
} firstName = user.firstName.orEmpty(),
lastName = user.lastName.orEmpty(),
email = user.email,
phone = digitsWithoutCode.take(maxDigits)
)
}
realState.emit(_formStateProfile.value)
}
}
}
init {
update()
}
private fun loadPhonePatterns() {
phoneNumberPatterns.clear()
phoneNumberPatterns.addAll(
PhoneNumberPatternsProvider.phoneNumberPatterns.map { it.mapToUI() }
)
if (chosenPattern.value == null) {
val defaultPattern = getDefaultPhoneNumberPatternUseCase.execute()?.mapToUI()
chosenPattern.value = defaultPattern ?: phoneNumberPatterns.firstOrNull()
}
}
} }