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,18 +61,16 @@ fun TBottomNavigation(modifier: Modifier = Modifier, selectedPage: Int, onSelect
}
target?.let { (it - center).toDp() }
}
AnimatedVisibility(indicatorOffset != null) {
indicatorOffset?.let {
Box(
modifier = Modifier
.size(85.dp, 45.dp)
.offset(x = animateDpAsState(it).value)
.background(
MaterialTheme.colorScheme.primary,
shape = Shapes.smallRoundedBox
)
)
}
indicatorOffset?.let {
Box(
modifier = Modifier
.size(85.dp, 45.dp)
.offset(x = animateDpAsState(it).value)
.background(
MaterialTheme.colorScheme.primary,
shape = Shapes.smallRoundedBox
)
)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
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.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 {
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())
}
TPhoneField(
currentPattern = viewModel.currentPattern.value,
currentPhone = formState.phone,
onPhoneChange = viewModel::onPhoneChange,
error = formState.errors[AuthField.Phone],
onOpenCountryList =
{
isSheetOpen.value = true
}
)
Spacer(modifier = Modifier.height(20.dp))
BigButton(
@@ -241,32 +188,12 @@ fun ErrorCollectorScope.FillProfileScreen(
}
}
if (isSheetOpen.value) {
ModalBottomSheet(
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
}
)
}
}
}
TPhoneCountryList(
isSheetOpen = isSheetOpen,
sheetState = sheetState,
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
@@ -66,7 +68,7 @@ import org.koin.androidx.compose.koinViewModel
fun ErrorCollectorScope.ProfileScreen(
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState,
navigateToLoginScreen: () -> Unit,
navigateToLoginScreen: () -> Unit,
viewModel: ProfileScreenViewModel = koinViewModel()
) {
val typography = androidx.compose.material3.MaterialTheme.typography
@@ -113,171 +115,125 @@ fun ErrorCollectorScope.ProfileScreen(
}
}
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.systemBarsPadding()
.padding(horizontal = 30.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Spacer(Modifier.height(32.dp))
Text(
text = "Профиль",
style = typography.titleLarge,
fontSize = 40.sp
)
Spacer(Modifier.height(20.dp))
TTTextField(
value = formState.firstName,
onValueChange = viewModel::onFirstNameChange,
label = "Имя",
error = formState.errors[AuthField.FirstName],
)
Spacer(Modifier.height(12.dp))
TTTextField(
value = formState.lastName,
onValueChange = viewModel::onLastNameChange,
label = "Фамилия",
error = formState.errors[AuthField.LastName],
)
Spacer(Modifier.height(12.dp))
TTTextField(
value = formState.email,
onValueChange = viewModel::onEmailChange,
label = "Email",
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()
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.systemBarsPadding()
.padding(horizontal = Paddings.large),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Spacer(Modifier.height(Paddings.large))
Text(
text = "Профиль",
style = typography.titleLarge,
fontSize = 40.sp
)
Spacer(Modifier.height(Paddings.large))
TTTextField(
value = formState.firstName,
onValueChange = viewModel::onFirstNameChange,
label = "Имя",
error = formState.errors[AuthField.FirstName],
)
Spacer(Modifier.height(Paddings.medium))
TTTextField(
value = formState.lastName,
onValueChange = viewModel::onLastNameChange,
label = "Фамилия",
error = formState.errors[AuthField.LastName],
)
Spacer(Modifier.height(Paddings.medium))
TTTextField(
value = formState.email,
onValueChange = viewModel::onEmailChange,
label = "Email",
error = formState.errors[AuthField.Email],
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
)
Spacer(Modifier.height(Paddings.medium))
TPhoneField(
currentPattern = viewModel.chosenPattern.value,
currentPhone = formState.phone,
onPhoneChange = viewModel::onPhoneChange,
error = formState.errors[AuthField.Phone],
onOpenCountryList = {
isSheetOpen.value = true
}
)
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(
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
)
}
}
Text(
text = "Отменить",
style = typography.titleMedium,
fontSize = 24.sp
)
}
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]
)
Spacer(Modifier.height(Paddings.medium))
}
Spacer(modifier = Modifier.height(24.dp))
BigButton(
onClick = viewModel::submit,
modifier = Modifier.fillMaxWidth(),
buttonText = "Сохранить",
isLoading = profileState is UIState.Loading
)
Spacer(Modifier.height(15.dp))
Button(
modifier = modifier
.fillMaxWidth()
.height(60.dp),
shape = Shapes.smallRoundedBox,
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 },
Button(
modifier = modifier
.fillMaxWidth()
.height(60.dp),
shape = Shapes.smallRoundedBox,
onClick = {
viewModel.logout()
navigateToLoginScreen()
},
colors = ButtonColors(
containerColor = colorScheme.errorContainer,
contentColor = colorScheme.onErrorContainer,
disabledContainerColor = colorScheme.errorContainer,
disabledContentColor = colorScheme.onErrorContainer
)
) {
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
}
)
}
}
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(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.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,247 +30,267 @@ 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
data class ProfileState(
val email: String = "",
val firstName: String = "",
val lastName: String = "",
val phone: String = "",
val avatar: ByteArray? = null,
val errors: Map<AuthField, String> = emptyMap()
val email: String = "",
val firstName: String = "",
val lastName: String = "",
val phone: String = "",
val avatar: ByteArray? = null,
val errors: Map<AuthField, String> = emptyMap()
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ProfileState
other as ProfileState
if (email != other.email) return false
if (firstName != other.firstName) return false
if (lastName != other.lastName) return false
if (phone != other.phone) return false
if (!avatar.contentEquals(other.avatar)) return false
if (errors != other.errors) return false
if (email != other.email) return false
if (firstName != other.firstName) return false
if (lastName != other.lastName) return false
if (phone != other.phone) return false
if (!avatar.contentEquals(other.avatar)) return false
if (errors != other.errors) return false
return true
}
return true
}
override fun hashCode(): Int {
var result = email.hashCode()
result = 31 * result + firstName.hashCode()
result = 31 * result + lastName.hashCode()
result = 31 * result + phone.hashCode()
result = 31 * result + (avatar?.contentHashCode() ?: 0)
result = 31 * result + errors.hashCode()
return result
}
override fun hashCode(): Int {
var result = email.hashCode()
result = 31 * result + firstName.hashCode()
result = 31 * result + lastName.hashCode()
result = 31 * result + phone.hashCode()
result = 31 * result + (avatar?.contentHashCode() ?: 0)
result = 31 * result + errors.hashCode()
return result
}
}
@KoinViewModel
class ProfileScreenViewModel(
private val getUserUseCase: GetUserUseCase,
private val updateUserUseCase: UpdateUserUseCase,
private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase,
private val logOutUseCase: LogOutUseCase,
private val getDefaultPhoneNumberPatternUseCase: GetDefaultPhoneNumberPatternUseCase,
private val galleryRepository: GalleryRepository
): BaseViewModel() {
private val _formStateProfile = MutableStateFlow(ProfileState())
val formStateFillProfile: StateFlow<ProfileState> = _formStateProfile
private val getUserUseCase: GetUserUseCase,
private val updateUserUseCase: UpdateUserUseCase,
private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase,
private val logOutUseCase: LogOutUseCase,
private val getDefaultPhoneNumberPatternUseCase: GetDefaultPhoneNumberPatternUseCase,
galleryRepository: GalleryRepository
) : BaseViewModel() {
private val _formStateProfile = MutableStateFlow(ProfileState())
val formStateFillProfile: StateFlow<ProfileState> = _formStateProfile
private val _profileState = MutableUIStateFlow<String>()
val profileState: StateFlow<UIState<String>> = _profileState
private val _profileState = MutableUIStateFlow<String>()
val profileState: StateFlow<UIState<String>> = _profileState
val chosenPattern = mutableStateOf<UIPhoneNumberPattern?>(null)
val phoneNumberPatterns = mutableStateListOf<UIPhoneNumberPattern>()
val chosenPattern = mutableStateOf<UIPhoneNumberPattern?>(null)
val phoneNumberPatterns = mutableStateListOf<UIPhoneNumberPattern>()
fun onEmailChange(value: String) {
_formStateProfile.update {
it.copy(
email = value,
errors = it.errors - AuthField.Email
)
}
}
private val realState = MutableStateFlow(_formStateProfile.value)
fun onFirstNameChange(value: String) {
_formStateProfile.update {
it.copy(
firstName = value,
errors = it.errors - AuthField.FirstName
)
}
}
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 onLastNameChange(value: String) {
_formStateProfile.update {
it.copy(
lastName = value,
errors = it.errors - AuthField.LastName
)
}
}
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 onPhoneChange(value: String) {
val maxDigits = chosenPattern.value?.pattern?.count { it == '0' } ?: Int.MAX_VALUE
val digits = value.filter { it.isDigit() }.take(maxDigits)
_formStateProfile.update {
it.copy(
phone = digits,
errors = it.errors - AuthField.Phone
)
}
}
fun onEmailChange(value: String) {
_formStateProfile.update {
it.copy(
email = value,
errors = it.errors - AuthField.Email
)
}
}
val galleryItems = galleryRepository.getImagesIds().map {
it.map { id ->
ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id
)
}
}
fun onFirstNameChange(value: String) {
_formStateProfile.update {
it.copy(
firstName = value,
errors = it.errors - AuthField.FirstName
)
}
}
fun post(context: Context) {
viewModelScope.launch {
post(
(ImageLoader(context).execute(
ImageRequest.Builder(context)
.data(currentPhoto).build()
).drawable as BitmapDrawable).bitmap
)
}
}
fun onLastNameChange(value: String) {
_formStateProfile.update {
it.copy(
lastName = value,
errors = it.errors - AuthField.LastName
)
}
}
fun post(bitmap: Bitmap) {
viewModelScope.launch {
_formStateProfile.update {
it.copy(
avatar = bitmap.toByteArray()
)
}
}
}
fun onPhoneChange(value: String) {
val maxDigits = chosenPattern.value?.pattern?.count { it == '0' } ?: Int.MAX_VALUE
val digits = value.filter { it.isDigit() }.take(maxDigits)
_formStateProfile.update {
it.copy(
phone = digits,
errors = it.errors - AuthField.Phone
)
}
}
fun clearAvatar() {
viewModelScope.launch {
_formStateProfile.update {
it.copy(
avatar = null
)
}
}
}
val galleryItems = galleryRepository.getImagesIds().map {
it.map { id ->
ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
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) {
currentPhoto = photo
}
fun post(bitmap: Bitmap) {
viewModelScope.launch {
_formStateProfile.update {
it.copy(
avatar = bitmap.toByteArray()
)
}
}
}
fun submit() {
viewModelScope.launch {
val validation = validateAuthFieldsUseCase.validateProfile(
firstName = _formStateProfile.value.firstName,
lastName = _formStateProfile.value.lastName,
email = _formStateProfile.value.email,
phone = _formStateProfile.value.phone
)
fun clearAvatar() {
viewModelScope.launch {
_formStateProfile.update {
it.copy(
avatar = null
)
}
}
}
val errors = validation.errors.toMutableMap()
var currentPhoto: Uri? = null
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 цифр"
}
}
fun selectImage(photo: Uri) {
currentPhoto = photo
}
if (errors.isNotEmpty()) {
_formStateProfile.update { it.copy(errors = errors) }
return@launch
}
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,
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(
UpdateUserData(
firstName = _formStateProfile.value.firstName,
lastName = _formStateProfile.value.lastName,
email = _formStateProfile.value.email,
phone = formattedPhone
)
)
result.map { it.id }.collectRequest(_profileState)
}
}
if (errors.isNotEmpty()) {
_formStateProfile.update { it.copy(errors = errors) }
return@launch
}
fun logout() {
viewModelScope.launch {
logOutUseCase()
}
}
_profileState.emit(UIState.Loading())
init {
viewModelScope.launch {
loadPhonePatterns()
val formattedPhone = pattern?.mapToDomain()?.let { phonePattern ->
convertNumberToPattern(phonePattern, _formStateProfile.value.phone)
} ?: _formStateProfile.value.phone
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
} ?: phoneNumberPatterns.firstOrNull {
it.countryCodeISO.equals(
getDefaultPhoneNumberPatternUseCase.execute()?.countryCodeISO,
ignoreCase = true
)
} ?: phoneNumberPatterns.firstOrNull()
val result = updateUserUseCase(
UpdateUserData(
firstName = _formStateProfile.value.firstName,
lastName = _formStateProfile.value.lastName,
email = _formStateProfile.value.email,
phone = formattedPhone
)
)
result.map { it.id }.collectRequest(_profileState)
update()
}
}
selectedPattern?.let { chosenPattern.value = it }
fun logout() {
viewModelScope.launch {
logOutUseCase()
}
}
val digitsWithoutCode = selectedPattern?.let {
val codeDigits = it.countryCode.filter { d -> d.isDigit() }
if (digits.startsWith(codeDigits)) digits.drop(codeDigits.length) else digits
} ?: digits
fun update() {
viewModelScope.launch {
loadPhonePatterns()
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 {
it.copy(
firstName = user.firstName.orEmpty(),
lastName = user.lastName.orEmpty(),
email = user.email,
phone = digitsWithoutCode.take(maxDigits)
)
}
}
}
}
selectedPattern?.let { chosenPattern.value = it }
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()
}
}
val digitsWithoutCode = selectedPattern?.let {
val codeDigits = it.countryCode.filter { d -> d.isDigit() }
if (digits.startsWith(codeDigits)) digits.drop(codeDigits.length) else digits
} ?: digits
val maxDigits = selectedPattern?.pattern?.count { it == '0' } ?: Int.MAX_VALUE
_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()
}
}
}