From d1e38cdfe8859864267bb7a54cac84b28b24db5a Mon Sep 17 00:00:00 2001 From: MaximOksiuta <63787095+MaximOksiuta@users.noreply.github.com> Date: Sat, 22 Nov 2025 05:43:40 +0300 Subject: [PATCH] fix: fix phone field on profile screen, bottom bar beautify; feat: show buttons only after change, on profile edit --- .../usecase/auth/ValidateAuthFieldsUseCase.kt | 7 +- .../components/BottomNavigation.kt | 24 +- .../components/standart/TPhoneField.kt | 158 +++++++ .../screens/fillProfile/FillProfileScreen.kt | 111 +---- .../fillProfile/FillProfileViewModel.kt | 11 +- .../screens/profile/ProfileScreen.kt | 276 +++++------- .../screens/profile/ProfileScreenViewModel.kt | 418 +++++++++--------- 7 files changed, 536 insertions(+), 469 deletions(-) create mode 100644 app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TPhoneField.kt diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateAuthFieldsUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateAuthFieldsUseCase.kt index bb6ab1b..58684c3 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateAuthFieldsUseCase.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateAuthFieldsUseCase.kt @@ -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) } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/BottomNavigation.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/BottomNavigation.kt index 29556da..ade1e78 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/components/BottomNavigation.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/BottomNavigation.kt @@ -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( diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TPhoneField.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TPhoneField.kt new file mode 100644 index 0000000..e37ea7a --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TPhoneField.kt @@ -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, + sheetState: SheetState, + patternList: List, + 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 + } + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt index d9eac33..240aa4b 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt @@ -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 } - } + ) } \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileViewModel.kt index 2ee6f25..cb94799 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileViewModel.kt @@ -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(null) + val currentPattern = mutableStateOf(null) val phoneNumberPatterns = mutableStateListOf() 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 diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreen.kt index b577a53..cbd3d48 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreen.kt @@ -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 + } + ) } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreenViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreenViewModel.kt index 763b83a..dd3822c 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreenViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreenViewModel.kt @@ -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 = emptyMap() + val email: String = "", + val firstName: String = "", + val lastName: String = "", + val phone: String = "", + val avatar: ByteArray? = null, + val errors: Map = 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 = _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 = _formStateProfile - private val _profileState = MutableUIStateFlow() - val profileState: StateFlow> = _profileState + private val _profileState = MutableUIStateFlow() + val profileState: StateFlow> = _profileState - val chosenPattern = mutableStateOf(null) - val phoneNumberPatterns = mutableStateListOf() + val chosenPattern = mutableStateOf(null) + val phoneNumberPatterns = mutableStateListOf() - 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() + } + } }