fix: fixing bugs with phone input field. feat: абсолютно готов экран profile

This commit is contained in:
dany
2025-11-22 04:17:22 +03:00
parent 336472a5b8
commit 82e5066950
7 changed files with 350 additions and 83 deletions
@@ -19,6 +19,8 @@ import androidx.compose.ui.Modifier
import androidx.navigation.compose.currentBackStackEntryAsState
import com.prodhack.moscow2025.presentation.components.TBottomNavigation
import com.prodhack.moscow2025.presentation.theme.MoscowHackatonTemplateTheme
import com.prodhack.moscow2025.presentation.utils.ui.AppSnackbarVisuals
import com.prodhack.moscow2025.presentation.utils.ui.SnackbarStyle
@Composable
fun TTasksApp(
@@ -53,10 +55,19 @@ fun TTasksApp(
SnackbarHost(
hostState = snackbarHostState,
snackbar = { data ->
val style = (data.visuals as? AppSnackbarVisuals)?.style ?: SnackbarStyle.Error
val containerColor = when (style) {
SnackbarStyle.Success -> MaterialTheme.colorScheme.tertiaryContainer
SnackbarStyle.Error -> MaterialTheme.colorScheme.errorContainer
}
val contentColor = when (style) {
SnackbarStyle.Success -> MaterialTheme.colorScheme.onTertiaryContainer
SnackbarStyle.Error -> MaterialTheme.colorScheme.onErrorContainer
}
Snackbar(
snackbarData = data,
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
containerColor = containerColor,
contentColor = contentColor,
shape = MaterialTheme.shapes.medium
)
}
@@ -94,7 +94,10 @@ fun TTasksNavHost(
composable(AppDestination.Profile.route)
{
ProfileScreen(
snackbarHostState = snackbarHostState
snackbarHostState = snackbarHostState,
navigateToLoginScreen = {
navController.navigate(AppDestination.Login.route)
}
)
}
}
@@ -95,9 +95,11 @@ class FillProfileViewModel(
}
fun onPhoneChange(value: String) {
val maxDigits = chosenPattern.value?.pattern?.count { it == '0' } ?: Int.MAX_VALUE
val digits = value.filter { it.isDigit() }.take(maxDigits)
_formStateFillProfile.update {
it.copy(
phone = value,
phone = digits,
errors = it.errors - AuthField.Phone
)
}
@@ -1,20 +1,36 @@
package com.prodhack.moscow2025.presentation.screens.profile
import androidx.compose.foundation.Image
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.Spacer
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
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.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -24,27 +40,38 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
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 androidx.compose.ui.unit.sp
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.TTTextField
import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
import com.prodhack.moscow2025.presentation.utils.PhoneVisualTransformation
import com.prodhack.moscow2025.presentation.utils.UIState
import com.prodhack.moscow2025.presentation.utils.ui.SnackbarStyle
import com.prodhack.moscow2025.presentation.utils.ui.showSnackbar
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ErrorCollectorScope.ProfileScreen(
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState,
navigateToLoginScreen: () -> Unit,
viewModel: ProfileScreenViewModel = koinViewModel()
) {
val typography = androidx.compose.material3.MaterialTheme.typography
val sheetState = rememberModalBottomSheetState()
val isSheetOpen = remember { mutableStateOf(false) }
val formState by viewModel.formStateFillProfile.collectAsState()
@@ -71,6 +98,7 @@ fun ErrorCollectorScope.ProfileScreen(
if (profileState is UIState.Success) {
snackbarHostState.showSnackbar(
message = "Данные профиля обновлены",
style = SnackbarStyle.Success,
duration = SnackbarDuration.Short
)
}
@@ -85,75 +113,171 @@ fun ErrorCollectorScope.ProfileScreen(
}
}
Box(
modifier = modifier
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
contentAlignment = Alignment.BottomStart
) {
Image(
painter = painterResource(R.drawable.lottie),
contentDescription = null,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 16.dp)
.fillMaxWidth(0.35f),
contentScale = ContentScale.FillWidth
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 30.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
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)
) {
Spacer(Modifier.height(32.dp))
Text(
text = "Профиль",
style = typography.titleLarge,
fontSize = 32.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))
FieldWrapper(
modifier = Modifier
.width(IntrinsicSize.Min)
.fillMaxHeight()
) {
BasicTextField(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 16.dp)
.clip(Shapes.smallRoundedBox)
.background(
color = colorScheme.primary,
shape = Shapes.smallRoundedBox
),
value = viewModel.chosenPattern.value?.prefix ?: "",
onValueChange = {},
readOnly = true,
textStyle = TextStyle(
color = colorScheme.onPrimary
),
decorationBox = { innerTextField ->
Row(
modifier = Modifier
.clickable { isSheetOpen.value = true }
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.weight(1f)) {
innerTextField()
}
Icon(
modifier = Modifier.size(15.dp),
painter = painterResource(R.drawable.ic_arr_dropdown),
tint = colorScheme.onPrimary,
contentDescription = null
)
}
}
)
}
TTTextField(
value = formState.phone,
onValueChange = viewModel::onPhoneChange,
label = "Телефон",
error = formState.errors[AuthField.Phone],
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone)
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone
),
visualTransformation = viewModel.chosenPattern.value?.pattern?.let {
PhoneVisualTransformation(it, '0')
} ?: VisualTransformation.None,
error = formState.errors[AuthField.Phone]
)
}
Spacer(modifier = Modifier.height(24.dp))
BigButton(
onClick = viewModel::submit,
modifier = Modifier.fillMaxWidth(),
buttonText = "Сохранить",
isLoading = profileState is UIState.Loading
)
Spacer(modifier = Modifier.height(48.dp))
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 },
) {
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
}
)
}
}
}
}
}
}
@@ -6,18 +6,26 @@ import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.provider.MediaStore
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.viewModelScope
import androidx.paging.map
import coil.ImageLoader
import coil.request.ImageRequest
import com.prodhack.moscow2025.data.data_providers.PhoneNumberPatternsProvider
import com.prodhack.moscow2025.domain.interfaces.GalleryRepository
import com.prodhack.moscow2025.domain.models.UpdateUserData
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.domain.usecase.auth.GetUserUseCase
import com.prodhack.moscow2025.domain.usecase.auth.LogOutUseCase
import com.prodhack.moscow2025.domain.usecase.auth.UpdateUserUseCase
import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase
import com.prodhack.moscow2025.domain.usecase.GetDefaultPhoneNumberPatternUseCase
import com.prodhack.moscow2025.presentation.screens.fillProfile.UIPhoneNumberPattern
import com.prodhack.moscow2025.presentation.screens.fillProfile.mapToUI
import com.prodhack.moscow2025.presentation.utils.UIState
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.StateFlow
@@ -68,6 +76,8 @@ 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())
@@ -76,6 +86,9 @@ class ProfileScreenViewModel(
private val _profileState = MutableUIStateFlow<String>()
val profileState: StateFlow<UIState<String>> = _profileState
val chosenPattern = mutableStateOf<UIPhoneNumberPattern?>(null)
val phoneNumberPatterns = mutableStateListOf<UIPhoneNumberPattern>()
fun onEmailChange(value: String) {
_formStateProfile.update {
it.copy(
@@ -104,9 +117,11 @@ class ProfileScreenViewModel(
}
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 = value,
phone = digits,
errors = it.errors - AuthField.Phone
)
}
@@ -167,38 +182,93 @@ class ProfileScreenViewModel(
phone = _formStateProfile.value.phone
)
if (!validation.isValid) {
_formStateProfile.update { it.copy(errors = validation.errors) }
val errors = validation.errors.toMutableMap()
val pattern = chosenPattern.value
if (pattern == null) {
errors[AuthField.Phone] = "Выберите код страны"
} else {
val expectedDigits = pattern.pattern.count { it == '0' }
if (_formStateProfile.value.phone.length != expectedDigits) {
errors[AuthField.Phone] = "Номер должен содержать $expectedDigits цифр"
}
}
if (errors.isNotEmpty()) {
_formStateProfile.update { it.copy(errors = errors) }
return@launch
}
_profileState.emit(UIState.Loading())
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 = _formStateProfile.value.phone
phone = formattedPhone
)
)
result.map { it.id }.collectRequest(_profileState)
}
}
fun logout() {
viewModelScope.launch {
logOutUseCase()
}
}
init {
viewModelScope.launch {
loadPhonePatterns()
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()
selectedPattern?.let { chosenPattern.value = it }
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 = user.phone.orEmpty()
phone = digitsWithoutCode.take(maxDigits)
)
}
}
}
}
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()
}
}
}
@@ -29,7 +29,19 @@ class PhoneVisualTransformation(val mask: String, val maskNumber: Char) : Visual
}
}
return TransformedText(annotatedString, PhoneOffsetMapper(mask, maskNumber))
if (annotatedString.isEmpty()) {
return TransformedText(annotatedString, OffsetMapping.Identity)
}
return TransformedText(
annotatedString,
PhoneOffsetMapper(
mask = mask,
numberChar = maskNumber,
transformedLength = annotatedString.length,
maxDigits = trimmed.length
)
)
}
override fun equals(other: Any?): Boolean {
@@ -45,19 +57,30 @@ class PhoneVisualTransformation(val mask: String, val maskNumber: Char) : Visual
}
}
private class PhoneOffsetMapper(val mask: String, val numberChar: Char) : OffsetMapping {
private class PhoneOffsetMapper(
val mask: String,
val numberChar: Char,
private val transformedLength: Int,
private val maxDigits: Int
) : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
var noneDigitCount = 0
var i = 0
while (i < offset + noneDigitCount) {
if (mask[i++] != numberChar) noneDigitCount++
if (offset <= 0) return 0
var digitsSeen = 0
var index = 0
val targetDigits = offset.coerceAtMost(maxDigits)
while (index < mask.length && digitsSeen < targetDigits) {
if (mask[index] == numberChar) {
digitsSeen++
}
index++
}
return offset + noneDigitCount
return index.coerceAtMost(transformedLength)
}
override fun transformedToOriginal(offset: Int): Int =
offset - mask.take(offset).count { it != numberChar }
mask.take(offset.coerceAtMost(transformedLength)).count { it == numberChar }
}
fun convertNumberToPattern(pattern: PhoneNumberPattern, number: String): String {
@@ -0,0 +1,34 @@
package com.prodhack.moscow2025.presentation.utils.ui
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarVisuals
enum class SnackbarStyle {
Success,
Error,
}
data class AppSnackbarVisuals(
override val message: String,
override val actionLabel: String? = null,
override val withDismissAction: Boolean = false,
override val duration: SnackbarDuration = SnackbarDuration.Short,
val style: SnackbarStyle = SnackbarStyle.Error
) : SnackbarVisuals
suspend fun SnackbarHostState.showSnackbar(
message: String,
style: SnackbarStyle,
actionLabel: String? = null,
withDismissAction: Boolean = false,
duration: SnackbarDuration = SnackbarDuration.Short
) = showSnackbar(
AppSnackbarVisuals(
message = message,
actionLabel = actionLabel,
withDismissAction = withDismissAction,
duration = duration,
style = style
)
)