From 15f8fe1d850e2c18f94107496ade88b61ce5d314 Mon Sep 17 00:00:00 2001 From: dany Date: Fri, 21 Nov 2025 20:02:55 +0300 Subject: [PATCH 1/6] feat: added view model for profile screen # Conflicts: # .idea/deploymentTargetSelector.xml # app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt --- .../usecase/auth/ValidateAuthFieldsUseCase.kt | 14 ++ .../screens/profile/ProfileScreen.kt | 7 +- .../screens/profile/ProfileScreenViewModel.kt | 204 ++++++++++++++++++ 3 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreenViewModel.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 636daeb..bb6ab1b 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 @@ -24,6 +24,20 @@ data class ValidationResult( @Single class ValidateAuthFieldsUseCase { + fun validateProfile( + firstName: String, + lastName: String, + email: String, + phone: String + ): ValidationResult { + val errors = buildMap { + 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, "Некорректный номер телефона") + } + return ValidationResult(errors) + } fun validateFillProfile( chosenPattern: PhoneNumberPattern?, 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 21ae1ed..0201697 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 @@ -2,8 +2,13 @@ package com.prodhack.moscow2025.presentation.screens.profile import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel +import org.koin.androidx.compose.koinViewModel @Composable -fun ProfileScreen(modifier: Modifier = Modifier) { +fun ProfileScreen( + viewModel: ProfileScreenViewModel = koinViewModel() +) : BaseViewModel { + } \ No newline at end of file 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 new file mode 100644 index 0000000..60669ed --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreenViewModel.kt @@ -0,0 +1,204 @@ +package com.prodhack.moscow2025.presentation.screens.profile + +import android.content.ContentUris +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.provider.MediaStore +import androidx.lifecycle.viewModelScope +import androidx.paging.map +import coil.ImageLoader +import coil.request.ImageRequest +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.UpdateUserUseCase +import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase +import com.prodhack.moscow2025.presentation.utils.UIState +import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel +import com.prodhack.moscow2025.presentation.utils.toByteArray +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +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() +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + 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 + + 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 + } + + +} + +@KoinViewModel +class ProfileScreenViewModel( + private val getUserUseCase: GetUserUseCase, + private val updateUserUseCase: UpdateUserUseCase, + private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase, + private val galleryRepository: GalleryRepository +): BaseViewModel() { + private val _formStateProfile = MutableStateFlow(ProfileState()) + val formStateFillProfile: StateFlow = _formStateProfile + + private val _profileState = MutableUIStateFlow() + val profileState: StateFlow> = _profileState + + fun onEmailChange(value: String) { + _formStateProfile.update { + it.copy( + email = value, + errors = it.errors - AuthField.Email + ) + } + } + + fun onFirstNameChange(value: String) { + _formStateProfile.update { + it.copy( + firstName = value, + errors = it.errors - AuthField.FirstName + ) + } + } + + fun onLastNameChange(value: String) { + _formStateProfile.update { + it.copy( + lastName = value, + errors = it.errors - AuthField.LastName + ) + } + } + + fun onPhoneChange(value: String) { + _formStateProfile.update { + it.copy( + phone = value, + errors = it.errors - AuthField.Phone + ) + } + } + + val galleryItems = galleryRepository.getImagesIds().map { + it.map { id -> + ContentUris.withAppendedId( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + id + ) + } + } + + fun post(context: Context) { + viewModelScope.launch { + post( + (ImageLoader(context).execute( + ImageRequest.Builder(context) + .data(currentPhoto).build() + ).drawable as BitmapDrawable).bitmap + ) + } + } + + fun post(bitmap: Bitmap) { + viewModelScope.launch { + _formStateProfile.update { + it.copy( + avatar = bitmap.toByteArray() + ) + } + } + } + + fun clearAvatar() { + viewModelScope.launch { + _formStateProfile.update { + it.copy( + avatar = null + ) + } + } + } + + var currentPhoto: Uri? = null + + fun selectImage(photo: Uri) { + currentPhoto = photo + } + + fun submit() { + viewModelScope.launch { + val validation = validateAuthFieldsUseCase.validateProfile( + firstName = _formStateProfile.value.firstName, + lastName = _formStateProfile.value.lastName, + email = _formStateProfile.value.email, + phone = _formStateProfile.value.phone + ) + + if (!validation.isValid) { + _formStateProfile.update { it.copy(errors = validation.errors) } + return@launch + } + + _profileState.emit(UIState.Loading()) + + val result = updateUserUseCase( + UpdateUserData( + firstName = _formStateProfile.value.firstName, + lastName = _formStateProfile.value.lastName, + email = _formStateProfile.value.email, + phone = _formStateProfile.value.phone + ) + ) + result.map { it.id }.collectRequest(_profileState) + } + } + + init { + viewModelScope.launch { + val user = getUserUseCase().getOrNull() + if (user != null) { + _formStateProfile.update { + it.copy( + firstName = user.firstName.orEmpty(), + lastName = user.lastName.orEmpty(), + email = user.email, + phone = user.phone.orEmpty() + ) + } + } + } + } +} \ No newline at end of file From 336472a5b836c79f07f21ac75d701c856b0b35eb Mon Sep 17 00:00:00 2001 From: dany Date: Fri, 21 Nov 2025 23:35:02 +0300 Subject: [PATCH 2/6] feat: added profile edir screen --- .../prodhack/moscow2025/common/Constants.kt | 2 +- .../presentation/navigation/TTasksNavHost.kt | 4 +- .../screens/profile/ProfileScreen.kt | 155 +++++++++++++++++- 3 files changed, 154 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/prodhack/moscow2025/common/Constants.kt b/app/src/main/java/com/prodhack/moscow2025/common/Constants.kt index d66fabc..d05c5be 100644 --- a/app/src/main/java/com/prodhack/moscow2025/common/Constants.kt +++ b/app/src/main/java/com/prodhack/moscow2025/common/Constants.kt @@ -1,5 +1,5 @@ package com.prodhack.moscow2025.common object Constants { - const val BASE_API_URL = "https://hackaton.paas.itqdev.xyz/" + const val BASE_API_URL = "https://team-39-alpha-gm5qjkou.hack.prodcontest.ru/" } \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt index 988e0c6..9a85457 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt @@ -93,7 +93,9 @@ fun TTasksNavHost( composable(AppDestination.Profile.route) { - ProfileScreen() + ProfileScreen( + snackbarHostState = snackbarHostState + ) } } } 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 0201697..2b3280c 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 @@ -1,14 +1,159 @@ package com.prodhack.moscow2025.presentation.screens.profile +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.systemBarsPadding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.KeyboardType +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.TTTextField +import com.prodhack.moscow2025.presentation.theme.Paddings +import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope +import com.prodhack.moscow2025.presentation.utils.UIState import org.koin.androidx.compose.koinViewModel @Composable -fun ProfileScreen( - viewModel: ProfileScreenViewModel = koinViewModel() -) : BaseViewModel { +fun ErrorCollectorScope.ProfileScreen( + modifier: Modifier = Modifier, + snackbarHostState: SnackbarHostState, + viewModel: ProfileScreenViewModel = koinViewModel() +) { + val typography = androidx.compose.material3.MaterialTheme.typography + val formState by viewModel.formStateFillProfile.collectAsState() -} \ No newline at end of file + var errorText by remember { mutableStateOf("") } + val profileState by viewModel.profileState.collectAsStateWithCallbacks( + onInputError = { + errorText = it.error + }, + onConnectionError = { + errorText = "Нет подключения к сети" + }, + onUnexpectedError = { + errorText = it.error + }, + onLoading = { + errorText = "" + }, + onSuccess = { + errorText = "" + } + ) + + LaunchedEffect(profileState) { + if (profileState is UIState.Success) { + snackbarHostState.showSnackbar( + message = "Данные профиля обновлены", + duration = SnackbarDuration.Short + ) + } + } + + LaunchedEffect(errorText) { + if (errorText.isNotEmpty()) { + snackbarHostState.showSnackbar( + message = "Ошибка: $errorText", + duration = SnackbarDuration.Short + ) + } + } + + 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 + ) { + 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)) + TTTextField( + value = formState.phone, + onValueChange = viewModel::onPhoneChange, + label = "Телефон", + error = formState.errors[AuthField.Phone], + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.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)) + } + } +} From 82e5066950643b15ff14a0c96eb02f5ece24c12d Mon Sep 17 00:00:00 2001 From: dany Date: Sat, 22 Nov 2025 04:17:22 +0300 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20fixing=20bugs=20with=20phone=20input?= =?UTF-8?q?=20field.=20feat:=20=D0=B0=D0=B1=D1=81=D0=BE=D0=BB=D1=8E=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=20=D1=8D=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/navigation/TTasksApp.kt | 15 +- .../presentation/navigation/TTasksNavHost.kt | 5 +- .../fillProfile/FillProfileViewModel.kt | 4 +- .../screens/profile/ProfileScreen.kt | 252 +++++++++++++----- .../screens/profile/ProfileScreenViewModel.kt | 82 +++++- .../presentation/utils/PhoneTransformation.kt | 41 ++- .../presentation/utils/ui/AppSnackbar.kt | 34 +++ 7 files changed, 350 insertions(+), 83 deletions(-) create mode 100644 app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/AppSnackbar.kt diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksApp.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksApp.kt index 56a4234..924fe40 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksApp.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksApp.kt @@ -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 ) } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt index 9a85457..e20efba 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt @@ -94,7 +94,10 @@ fun TTasksNavHost( composable(AppDestination.Profile.route) { ProfileScreen( - snackbarHostState = snackbarHostState + snackbarHostState = snackbarHostState, + navigateToLoginScreen = { + navController.navigate(AppDestination.Login.route) + } ) } } 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 982b6f8..2ee6f25 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 @@ -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 ) } 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 2b3280c..b577a53 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 @@ -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 + } + ) + } + } + } } } } 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 60669ed..763b83a 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,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() val profileState: StateFlow> = _profileState + val chosenPattern = mutableStateOf(null) + val phoneNumberPatterns = mutableStateListOf() + 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) ) } } } } -} \ No newline at end of file + + 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() + } + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/PhoneTransformation.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/PhoneTransformation.kt index 46dad8b..d23b318 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/PhoneTransformation.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/PhoneTransformation.kt @@ -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 { @@ -66,4 +89,4 @@ fun convertNumberToPattern(pattern: PhoneNumberPattern, number: String): String answer = answer.replaceFirst('*', i) } return "${pattern.countryCode} $answer" -} \ No newline at end of file +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/AppSnackbar.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/AppSnackbar.kt new file mode 100644 index 0000000..055a512 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/AppSnackbar.kt @@ -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 + ) +) From 09ff18cb022f5d0987dee4a50880c5a173e73438 Mon Sep 17 00:00:00 2001 From: MaximOksiuta <63787095+MaximOksiuta@users.noreply.github.com> Date: Sat, 22 Nov 2025 04:53:03 +0300 Subject: [PATCH 4/6] feat: main screen implemented --- app/proguard-rules.pro | 8 +- .../1.json | 62 ++- .../com/prodhack/moscow2025/common/App.kt | 4 +- .../moscow2025/common/di/AppModules.kt | 1 + .../moscow2025/common/di/ScanModules.kt | 15 +- .../moscow2025/data/base/BaseEntity.kt | 6 - .../data/base/BaseRemoteMediator.kt | 14 +- .../moscow2025/data/base/BaseRepository.kt | 272 +++++------ .../moscow2025/data/base/DBMappableDTO.kt | 5 - .../moscow2025/data/base/DomainMappableDTO.kt | 5 - .../data_providers/local_db/AppDatabase.kt | 5 +- .../local_db/DatabaseProvider.kt | 9 +- .../data_providers/local_db/dao/ResumeDao.kt | 21 + .../local_db/entities/ResumeEntity.kt | 37 ++ .../prodhack/moscow2025/data/dto/AuthDtos.kt | 54 +-- .../moscow2025/data/dto/ErrorNetworkDTO.kt | 8 + .../moscow2025/data/dto/ResumeDtos.kt | 85 ++++ .../prodhack/moscow2025/data/dto/UsersDtos.kt | 54 +++ .../ResumeRepositoryImpl.kt | 40 ++ .../interfaces/resumes/ResumeRepository.kt | 8 + .../moscow2025/domain/models/ResumeModel.kt | 19 + .../usecase/auth/CheckSessionUseCase.kt | 11 +- .../usecase/resumes/LoadResumeListUseCase.kt | 40 ++ .../moscow2025/presentation/MainActivity.kt | 10 +- .../components/standart/TTTopLogo.kt | 9 +- .../dataModels/UIResumeBaseInfo.kt | 17 + .../presentation/navigation/TTasksApp.kt | 2 +- .../screens/fillProfile/FillProfileScreen.kt | 7 +- .../presentation/screens/login/LoginScreen.kt | 5 +- .../presentation/screens/main/MainScreen.kt | 425 +++++++----------- .../screens/main/MainScreenViewModel.kt | 140 +----- .../screens/register/RegisterScreen.kt | 5 +- .../moscow2025/presentation/theme/Theme.kt | 5 + app/src/main/res/drawable/app_logo.xml | 36 ++ app/src/main/res/drawable/ic_arr_details.xml | 10 + app/src/main/res/values/strings.xml | 2 +- 36 files changed, 812 insertions(+), 644 deletions(-) delete mode 100644 app/src/main/java/com/prodhack/moscow2025/data/base/BaseEntity.kt delete mode 100644 app/src/main/java/com/prodhack/moscow2025/data/base/DBMappableDTO.kt delete mode 100644 app/src/main/java/com/prodhack/moscow2025/data/base/DomainMappableDTO.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/ResumeDao.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/data/dto/ErrorNetworkDTO.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/data/dto/UsersDtos.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/LoadResumeListUseCase.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/presentation/dataModels/UIResumeBaseInfo.kt create mode 100644 app/src/main/res/drawable/app_logo.xml create mode 100644 app/src/main/res/drawable/ic_arr_details.xml diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..f04d50d 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,10 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +# Keep annotation definitions +-keep class org.koin.core.annotation.** { *; } + +# Keep classes annotated with Koin annotations +-keep @org.koin.core.annotation.* class * { *; } \ No newline at end of file diff --git a/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json b/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json index 4877ef0..853ed4f 100644 --- a/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json +++ b/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "bf664fe902e116c42af432814d63d6a7", + "identityHash": "3e896e9a3d3b2f61149f8c0fde7e5964", "entities": [ { "tableName": "users", @@ -52,11 +52,69 @@ "id" ] } + }, + { + "tableName": "resumes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `experience_type` TEXT NOT NULL, `about_me` TEXT NOT NULL, `key_skills` TEXT NOT NULL, `position` TEXT NOT NULL, `from_salary` INTEGER, `to_salary` INTEGER, `recommended_skills` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "experienceType", + "columnName": "experience_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aboutMe", + "columnName": "about_me", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySkills", + "columnName": "key_skills", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromSalary", + "columnName": "from_salary", + "affinity": "INTEGER" + }, + { + "fieldPath": "toSalary", + "columnName": "to_salary", + "affinity": "INTEGER" + }, + { + "fieldPath": "recommendedSkills", + "columnName": "recommended_skills", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bf664fe902e116c42af432814d63d6a7')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3e896e9a3d3b2f61149f8c0fde7e5964')" ] } } \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/common/App.kt b/app/src/main/java/com/prodhack/moscow2025/common/App.kt index 00ce3ad..ce5a236 100644 --- a/app/src/main/java/com/prodhack/moscow2025/common/App.kt +++ b/app/src/main/java/com/prodhack/moscow2025/common/App.kt @@ -28,9 +28,7 @@ class App : Application() { androidContext(this@App) analytics() modules( - listOf( - AppModules().module - ) + AppModules().module ) } FirebaseApp.initializeApp(this@App) diff --git a/app/src/main/java/com/prodhack/moscow2025/common/di/AppModules.kt b/app/src/main/java/com/prodhack/moscow2025/common/di/AppModules.kt index 300fdfe..61c0d27 100644 --- a/app/src/main/java/com/prodhack/moscow2025/common/di/AppModules.kt +++ b/app/src/main/java/com/prodhack/moscow2025/common/di/AppModules.kt @@ -1,6 +1,7 @@ package com.prodhack.moscow2025.common.di import com.prodhack.moscow2025.data.data_providers.local_db.DatabaseProvider +import org.koin.core.annotation.Configuration import org.koin.core.annotation.Module /** diff --git a/app/src/main/java/com/prodhack/moscow2025/common/di/ScanModules.kt b/app/src/main/java/com/prodhack/moscow2025/common/di/ScanModules.kt index 2c55559..2ab5866 100644 --- a/app/src/main/java/com/prodhack/moscow2025/common/di/ScanModules.kt +++ b/app/src/main/java/com/prodhack/moscow2025/common/di/ScanModules.kt @@ -1,7 +1,11 @@ package com.prodhack.moscow2025.common.di +import android.content.Context +import androidx.room.Room +import com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module +import org.koin.core.annotation.Single @Module @ComponentScan("com.prodhack.moscow2025.presentation") @@ -13,4 +17,13 @@ class DomainModule @Module @ComponentScan("com.prodhack.moscow2025.data") -class DataModule +class DataModule{ + @Single + fun provideDatabase(context: Context): AppDatabase = + Room.databaseBuilder( + context, + AppDatabase::class.java, + "t_tasks.db" + ).fallbackToDestructiveMigration() + .build() +} diff --git a/app/src/main/java/com/prodhack/moscow2025/data/base/BaseEntity.kt b/app/src/main/java/com/prodhack/moscow2025/data/base/BaseEntity.kt deleted file mode 100644 index 17dcbd9..0000000 --- a/app/src/main/java/com/prodhack/moscow2025/data/base/BaseEntity.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.prodhack.moscow2025.data.base - - -interface BaseEntity { - val id: Number -} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/base/BaseRemoteMediator.kt b/app/src/main/java/com/prodhack/moscow2025/data/base/BaseRemoteMediator.kt index ddf62da..3d0b4e5 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/base/BaseRemoteMediator.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/base/BaseRemoteMediator.kt @@ -8,7 +8,7 @@ import androidx.room.RoomDatabase import androidx.room.withTransaction @OptIn(ExperimentalPagingApi::class) -class BaseRemoteMediator( +class BaseRemoteMediator( private val db: RoomDatabase, private val dao: BasePaginationDAO, private val makeRequest: suspend (page: Long, pageCount: Int) -> Result> @@ -26,17 +26,12 @@ class BaseRemoteMediator( ) LoadType.APPEND -> { - val lastItem = state.lastItemOrNull() - if (lastItem == null) { - 1 - } else { - (lastItem.id.toLong() / state.config.pageSize) + 1 - } + state.pages.size + 1 } } val result = makeRequest( - loadKey, + (loadKey.toLong() - 1) * state.config.pageSize, state.config.pageSize ) @@ -46,8 +41,7 @@ class BaseRemoteMediator( if (loadType == LoadType.REFRESH) { dao.clearAll() } - val beerEntities = data - dao.upsertAll(beerEntities) + dao.upsertAll(data) } MediatorResult.Success( endOfPaginationReached = data.size < state.config.pageSize diff --git a/app/src/main/java/com/prodhack/moscow2025/data/base/BaseRepository.kt b/app/src/main/java/com/prodhack/moscow2025/data/base/BaseRepository.kt index 21f0771..a875241 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/base/BaseRepository.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/base/BaseRepository.kt @@ -19,157 +19,157 @@ import kotlin.time.Duration abstract class BaseRepository { - // Caching module ============================================================================== - private val internalCacheStorage = mutableMapOf>() + // Caching module ============================================================================== + private val internalCacheStorage = mutableMapOf>() - private data class CacheEntry( - val value: T, - val expirationTime: Long - ) + private data class CacheEntry( + val value: T, + val expirationTime: Long + ) - fun putCache(cacheConfiguration: Pair, value: T) { - internalCacheStorage[cacheConfiguration.first] = - CacheEntry(value, cacheConfiguration.second.inWholeSeconds) - } + fun putCache(cacheConfiguration: Pair, value: T) { + internalCacheStorage[cacheConfiguration.first] = + CacheEntry(value, cacheConfiguration.second.inWholeSeconds) + } - @Suppress("UNCHECKED_CAST") - fun getCache(key: String): T? { - val entry = internalCacheStorage[key] ?: return null - if (entry.expirationTime < System.currentTimeMillis()) { - internalCacheStorage.remove(key) - return null - } - return entry.value as T - } + @Suppress("UNCHECKED_CAST") + fun getCache(key: String): T? { + val entry = internalCacheStorage[key] ?: return null + if (entry.expirationTime < System.currentTimeMillis()) { + internalCacheStorage.remove(key) + return null + } + return entry.value as T + } - // Base data sources =========================================================================== + // Base data sources =========================================================================== - protected open val defaultKtorClient: HttpClient? = null - protected open val db: RoomDatabase? = null + protected open val defaultKtorClient: HttpClient? = null + protected open val db: RoomDatabase? = null - companion object { - private const val TAG = "BaseRepository" - } + companion object { + private const val TAG = "BaseRepository" + } - // Internal methods ============================================================================ + // Internal methods ============================================================================ - private fun assertKtorClientSpecify() { - if (defaultKtorClient == null) { - Log.e(TAG, "You must specify ktor client for make network requests") - throw IllegalStateException("You must specify ktor client for make network requests") - } - } + private fun assertKtorClientSpecify() { + if (defaultKtorClient == null) { + Log.e(TAG, "You must specify ktor client for make network requests") + throw IllegalStateException("You must specify ktor client for make network requests") + } + } - private fun assertDBSpecify() { - if (db == null) { - throw IllegalStateException("You must specify db for use pagination/cashing") - } - } + private fun assertDBSpecify() { + if (db == null) { + throw IllegalStateException("You must specify db for use pagination/cashing") + } + } - // And methods for use :) ====================================================================== + // And methods for use :) ====================================================================== - /** - * Makes a network request using the provided Ktor client and request builder block. - * - * This function handles the common boilerplate for making a network request, - * including error handling and converting exceptions to a domain-specific `NetworkError`. - * - * @param T The expected successful response type. This type must be deserializable by Ktor. - * @param ktorClient The [HttpClient] to use for the request. Defaults to `this.defaultKtorClient`. - * An [IllegalStateException] will be thrown if no client is provided and `defaultKtorClient` is null. - * @param block A lambda function that configures the [HttpRequestBuilder] for the request. - * @return A [Result] object containing either the successful response of type [T] or a [NetworkError] if the request fails. - * @throws IllegalStateException if `ktorClient` is null and `defaultKtorClient` is also null. - */ - internal suspend inline fun networkRequest( - ktorClient: HttpClient? = this.defaultKtorClient, - cacheConfiguration: Pair? = null, - block: HttpRequestBuilder.() -> Unit - ): Result { - Log.d(TAG, "Network request! Asserting ktor client specify") - assertKtorClientSpecify() - Log.d(TAG, "ktor client is specified - continue network request") - return try { - Log.d(TAG, "Start request!") - val response = ktorClient!!.request(block = block) - Log.d(TAG, "Request was made without exceptions") + /** + * Makes a network request using the provided Ktor client and request builder block. + * + * This function handles the common boilerplate for making a network request, + * including error handling and converting exceptions to a domain-specific `NetworkError`. + * + * @param T The expected successful response type. This type must be deserializable by Ktor. + * @param ktorClient The [HttpClient] to use for the request. Defaults to `this.defaultKtorClient`. + * An [IllegalStateException] will be thrown if no client is provided and `defaultKtorClient` is null. + * @param block A lambda function that configures the [HttpRequestBuilder] for the request. + * @return A [Result] object containing either the successful response of type [T] or a [NetworkError] if the request fails. + * @throws IllegalStateException if `ktorClient` is null and `defaultKtorClient` is also null. + */ + internal suspend inline fun networkRequest( + ktorClient: HttpClient? = this.defaultKtorClient, + cacheConfiguration: Pair? = null, + block: HttpRequestBuilder.() -> Unit + ): Result { + Log.d(TAG, "Network request! Asserting ktor client specify") + assertKtorClientSpecify() + Log.d(TAG, "ktor client is specified - continue network request") + return try { + Log.d(TAG, "Start request!") + val response = ktorClient!!.request(block = block) + Log.d(TAG, "Request was made without exceptions") - if (response.status.isSuccess()) { - Result.success( - value = response - ).map { - it.body() - } - } else { - val firstCodeNum = response.status.value / 100 - val detail = (response.body() as? ErrorNetworkDTO)?.detail ?: "Unknown" - Result.failure( - when (firstCodeNum) { - 4 -> NetworkError.InputError(detail) - else -> NetworkError.Unexpected(detail) - } - ) - } - } catch (e: Exception) { - Log.e(TAG, "Exception in request process! $e") - Result.failure( - exception = e.convertToNetworkError() - ) - }.onSuccess { - Log.v(TAG, "Network request was successful") - if (cacheConfiguration != null) { - putCache(cacheConfiguration, it) - } - }.onFailure { - Log.e(TAG, "Network request has error! $it") - } - } + if (response.status.isSuccess()) { + Result.success( + value = response + ).map { + it.body() + } + } else { + val firstCodeNum = response.status.value / 100 + val detail = (response.body() as? ErrorNetworkDTO)?.detail ?: "Unknown" + Result.failure( + when (firstCodeNum) { + 4 -> NetworkError.InputError(detail) + else -> NetworkError.Unexpected(detail) + } + ) + } + } catch (e: Exception) { + Log.e(TAG, "Exception in request process! $e") + Result.failure( + exception = e.convertToNetworkError() + ) + }.onSuccess { + Log.v(TAG, "Network request was successful") + if (cacheConfiguration != null) { + putCache(cacheConfiguration, it) + } + }.onFailure { + Log.e(TAG, "Network request has error! $it") + } + } - internal suspend inline fun internalCachedRequest( - ktorClient: HttpClient? = this.defaultKtorClient, - cacheConfiguration: Pair, - block: HttpRequestBuilder.() -> Unit - ): Result { - val cachedResult = getCache(cacheConfiguration.first) + internal suspend inline fun internalCachedRequest( + ktorClient: HttpClient? = this.defaultKtorClient, + cacheConfiguration: Pair, + block: HttpRequestBuilder.() -> Unit + ): Result { + val cachedResult = getCache(cacheConfiguration.first) - return if (cachedResult != null) { - Result.success(cachedResult) - } else { - networkRequest(ktorClient, cacheConfiguration, block) - } - } + return if (cachedResult != null) { + Result.success(cachedResult) + } else { + networkRequest(ktorClient, cacheConfiguration, block) + } + } - @OptIn(ExperimentalPagingApi::class) - protected fun paginatedRequest( - pageSize: Int = 10, - prefetchDistance: Int = pageSize, - enablePlaceholders: Boolean = true, - initialLoadSize: Int = pageSize * 3, - maxSize: Int = Int.MAX_VALUE, - jumpThreshold: Int = Int.MIN_VALUE, - dbDao: BasePaginationDAO, - makeRequest: suspend (page: Long, pageSize: Int) -> Result> - ): Flow> { - assertDBSpecify() + @OptIn(ExperimentalPagingApi::class) + protected fun paginatedRequest( + pageSize: Int = 10, + prefetchDistance: Int = pageSize, + enablePlaceholders: Boolean = true, + initialLoadSize: Int = pageSize * 3, + maxSize: Int = Int.MAX_VALUE, + jumpThreshold: Int = Int.MIN_VALUE, + dbDao: BasePaginationDAO, + makeRequest: suspend (offset: Long, pageSize: Int) -> Result> + ): Flow> { + assertDBSpecify() - return Pager( - config = PagingConfig( - pageSize, - prefetchDistance, - enablePlaceholders, - initialLoadSize, - maxSize, - jumpThreshold - ), - remoteMediator = BaseRemoteMediator( - db = db!!, - dao = dbDao, - makeRequest = makeRequest - ), - pagingSourceFactory = { - dbDao.getPaginatedData() - } - ).flow - } + return Pager( + config = PagingConfig( + pageSize, + prefetchDistance, + enablePlaceholders, + initialLoadSize, + maxSize, + jumpThreshold + ), + remoteMediator = BaseRemoteMediator( + db = db!!, + dao = dbDao, + makeRequest = makeRequest + ), + pagingSourceFactory = { + dbDao.getPaginatedData() + } + ).flow + } } diff --git a/app/src/main/java/com/prodhack/moscow2025/data/base/DBMappableDTO.kt b/app/src/main/java/com/prodhack/moscow2025/data/base/DBMappableDTO.kt deleted file mode 100644 index 60c8262..0000000 --- a/app/src/main/java/com/prodhack/moscow2025/data/base/DBMappableDTO.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.prodhack.moscow2025.data.base - -interface DBMappableDTO { - fun mapToDB(): T -} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/base/DomainMappableDTO.kt b/app/src/main/java/com/prodhack/moscow2025/data/base/DomainMappableDTO.kt deleted file mode 100644 index 204b7d3..0000000 --- a/app/src/main/java/com/prodhack/moscow2025/data/base/DomainMappableDTO.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.prodhack.moscow2025.data.base - -interface DomainMappableDTO { - fun mapToDomain(): T -} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt index db2fb77..76fd244 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt @@ -3,11 +3,13 @@ package com.prodhack.moscow2025.data.data_providers.local_db import androidx.room.Database import androidx.room.RoomDatabase import com.prodhack.moscow2025.data.data_providers.local_db.dao.CleanUpDao +import com.prodhack.moscow2025.data.data_providers.local_db.dao.ResumeDao import com.prodhack.moscow2025.data.data_providers.local_db.dao.UserDao +import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity @Database( - entities = [UserEntity::class], + entities = [UserEntity::class, ResumeEntity::class], version = 1, exportSchema = true ) @@ -15,4 +17,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun userDao(): UserDao abstract fun cleanUpDao(): CleanUpDao + abstract fun resumeDao(): ResumeDao } diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/DatabaseProvider.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/DatabaseProvider.kt index 6b1976b..be57512 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/DatabaseProvider.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/DatabaseProvider.kt @@ -8,12 +8,5 @@ import org.koin.core.annotation.Single @Module class DatabaseProvider { - @Single - fun provideDatabase(context: Context): AppDatabase = - Room.databaseBuilder( - context, - AppDatabase::class.java, - "t_tasks.db" - ).fallbackToDestructiveMigration() - .build() + } diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/ResumeDao.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/ResumeDao.kt new file mode 100644 index 0000000..4e8fd60 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/ResumeDao.kt @@ -0,0 +1,21 @@ +package com.prodhack.moscow2025.data.data_providers.local_db.dao + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.prodhack.moscow2025.data.base.BasePaginationDAO +import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity + +@Dao +interface ResumeDao: BasePaginationDAO { + + @Query("DELETE FROM resumes") + override suspend fun clearAll() + + @Upsert + override suspend fun upsertAll(data: List) + + @Query("SELECT * FROM resumes") + override fun getPaginatedData(): PagingSource +} diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt new file mode 100644 index 0000000..fc73756 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt @@ -0,0 +1,37 @@ +package com.prodhack.moscow2025.data.data_providers.local_db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.prodhack.moscow2025.domain.models.ExperienceType +import com.prodhack.moscow2025.domain.models.ResumeModel +import kotlin.math.exp + +@Entity(tableName = "resumes") +data class ResumeEntity( + @PrimaryKey(autoGenerate = false) + val id: String, + @ColumnInfo("experience_type") + val experienceType: String, + @ColumnInfo("about_me") + val aboutMe: String, + @ColumnInfo("key_skills") + val keySkills: String, + val position: String, + @ColumnInfo("from_salary") + val fromSalary: Int?, + @ColumnInfo("to_salary") + val toSalary: Int?, + @ColumnInfo("recommended_skills") + val recommendedSkills: String +) { + fun mapToDomain(): ResumeModel = ResumeModel( + id = id, + position = position, + about = aboutMe, + experienceType = ExperienceType.valueOf(experienceType), + skills = keySkills.split("|"), + prediction = Pair(fromSalary, toSalary), + recommendedSkills = recommendedSkills.split("|") + ) +} diff --git a/app/src/main/java/com/prodhack/moscow2025/data/dto/AuthDtos.kt b/app/src/main/java/com/prodhack/moscow2025/data/dto/AuthDtos.kt index 1870dce..60b6f0b 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/dto/AuthDtos.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/dto/AuthDtos.kt @@ -7,33 +7,6 @@ import com.prodhack.moscow2025.domain.models.User import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Serializable -data class ErrorNetworkDTO( - val detail: String -) - -@Serializable -data class UserPatchRequest( - val email: String?, - @SerialName("display_name") - val displayName: String? = null, - @SerialName("first_name") - val firstName: String? = null, - @SerialName("last_name") - val lastName: String? = null, - @SerialName("avatar_url") - val avatarUrl: String? = null, - val phone: String? = null, -) - -fun UpdateUserData.mapToData(): UserPatchRequest = UserPatchRequest( - email = email, - displayName = displayName, - firstName = firstName, - lastName = lastName, - avatarUrl = avatarUrl, - phone = phone -) @Serializable data class UserLoginRequest( @@ -57,29 +30,4 @@ fun RegisterData.mapToData(): UserRegisterRequest = UserRegisterRequest(email, p data class TokenResponse( @SerialName("access_token") val token: String -) - -@Serializable -data class UserResponse( - val id: String, - val email: String, - @SerialName("display_name") - val displayName: String? = null, - @SerialName("first_name") - val firstName: String? = null, - @SerialName("last_name") - val lastName: String? = null, - @SerialName("avatar_url") - val avatarUrl: String? = null, - val phone: String? = null, -) { - fun mapToDomain(): User = User( - id = id, - email = email, - displayName = displayName, - firstName = firstName, - lastName = lastName, - avatarUrl = avatarUrl, - phone = phone - ) -} \ No newline at end of file +) \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/dto/ErrorNetworkDTO.kt b/app/src/main/java/com/prodhack/moscow2025/data/dto/ErrorNetworkDTO.kt new file mode 100644 index 0000000..ab505f8 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/dto/ErrorNetworkDTO.kt @@ -0,0 +1,8 @@ +package com.prodhack.moscow2025.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ErrorNetworkDTO( + val detail: String +) \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt b/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt new file mode 100644 index 0000000..15f70e8 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt @@ -0,0 +1,85 @@ +package com.prodhack.moscow2025.data.dto + +import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity +import com.prodhack.moscow2025.domain.models.ExperienceType +import com.prodhack.moscow2025.domain.models.ResumeModel +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class ExperienceTypeDTO { + @SerialName("noExperience") + NoExperience, + + @SerialName("lessThan1") + LessThan1, + + @SerialName("between1And3") + Between1And3, + + @SerialName("between3And6") + Between3And6, + + @SerialName("moreThan6") + MoreThan6; + + fun mapToDomain(): ExperienceType = when (this) { + NoExperience -> ExperienceType.NoExperience + LessThan1 -> ExperienceType.LessThan1 + Between1And3 -> ExperienceType.Between1And3 + Between3And6 -> ExperienceType.Between3And6 + MoreThan6 -> ExperienceType.MoreThan6 + } +} + +@Serializable +data class ResumeDTO( + val id: String, + @SerialName("experience_type") + val experienceType: ExperienceTypeDTO, + @SerialName("about_me") + val aboutMe: String, + @SerialName("key_skills") + val keySkills: List, + val position: String, + val prediction: PredictionDTO +) { + fun mapToDomain(): ResumeModel = ResumeModel( + id = id, + about = aboutMe, + skills = keySkills, + position = position, + experienceType = experienceType.mapToDomain(), + prediction = Pair( + prediction.fromSalary.toIntOrNull(), + prediction.toSalary.toIntOrNull() + ), + recommendedSkills = prediction.recommendedSkills + ) + + fun mapToDB(): ResumeEntity = ResumeEntity( + id = id, + aboutMe = aboutMe, + keySkills = keySkills.joinToString("|"), + position = position, + fromSalary = prediction.fromSalary.toIntOrNull(), + toSalary = prediction.toSalary.toIntOrNull(), + recommendedSkills = prediction.recommendedSkills.joinToString("|"), + experienceType = experienceType.mapToDomain().name + ) +} + +@Serializable +data class PredictionDTO( + @SerialName("from_salary") + val fromSalary: String, + @SerialName("to_salary") + val toSalary: String, + @SerialName("recommended_skills") + val recommendedSkills: List +) + +@Serializable +data class ResumeListDTO( + val resumes: List +) \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/dto/UsersDtos.kt b/app/src/main/java/com/prodhack/moscow2025/data/dto/UsersDtos.kt new file mode 100644 index 0000000..bed0054 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/dto/UsersDtos.kt @@ -0,0 +1,54 @@ +package com.prodhack.moscow2025.data.dto + +import com.prodhack.moscow2025.domain.models.UpdateUserData +import com.prodhack.moscow2025.domain.models.User +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UserPatchRequest( + val email: String?, + @SerialName("display_name") + val displayName: String? = null, + @SerialName("first_name") + val firstName: String? = null, + @SerialName("last_name") + val lastName: String? = null, + @SerialName("avatar_url") + val avatarUrl: String? = null, + val phone: String? = null, +) + +fun UpdateUserData.mapToData(): UserPatchRequest = UserPatchRequest( + email = email, + displayName = displayName, + firstName = firstName, + lastName = lastName, + avatarUrl = avatarUrl, + phone = phone +) + +@Serializable +data class UserResponse( + val id: String, + val email: String, + @SerialName("display_name") + val displayName: String? = null, + @SerialName("first_name") + val firstName: String? = null, + @SerialName("last_name") + val lastName: String? = null, + @SerialName("avatar_url") + val avatarUrl: String? = null, + val phone: String? = null, +) { + fun mapToDomain(): User = User( + id = id, + email = email, + displayName = displayName, + firstName = firstName, + lastName = lastName, + avatarUrl = avatarUrl, + phone = phone + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt new file mode 100644 index 0000000..553ca44 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt @@ -0,0 +1,40 @@ +package com.prodhack.moscow2025.data.repImplementations + +import androidx.paging.map +import com.prodhack.moscow2025.data.base.BaseRepository +import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient +import com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase +import com.prodhack.moscow2025.data.dto.ResumeListDTO +import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository +import com.prodhack.moscow2025.domain.models.ResumeModel +import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper +import io.ktor.client.request.url +import io.ktor.http.HttpMethod +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +class ResumeRepositoryImpl( + ktorClient: ApiKtorClient, + override val db: AppDatabase +) : ResumeRepository, BaseRepository() { + + override val defaultKtorClient = ktorClient.client + + private val resumeDao = db.resumeDao() + + override fun loadResumeList(): RemotePagingWrapper = paginatedRequest( + pageSize = 20, + dbDao = resumeDao, + makeRequest = { offset, pageSize -> + networkRequest { + method = HttpMethod.Get + url { + url("/resume/list") + parameters.append("limit", pageSize.toString()) + parameters.append("offset", offset.toString()) + } + }.map { it -> it.resumes.map { it.mapToDB() } } + } + ).map { it -> it.map { it.mapToDomain() } } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt new file mode 100644 index 0000000..6fbe291 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt @@ -0,0 +1,8 @@ +package com.prodhack.moscow2025.domain.interfaces.resumes + +import com.prodhack.moscow2025.domain.models.ResumeModel +import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper + +interface ResumeRepository { + fun loadResumeList(): RemotePagingWrapper +} diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt b/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt new file mode 100644 index 0000000..967beeb --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt @@ -0,0 +1,19 @@ +package com.prodhack.moscow2025.domain.models + +data class ResumeModel( + val id: String, + val position: String, + val about: String, + val skills: List, + val experienceType: ExperienceType, + val prediction: Pair, + val recommendedSkills: List +) + +enum class ExperienceType { + NoExperience, + LessThan1, + Between1And3, + Between3And6, + MoreThan6 +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/CheckSessionUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/CheckSessionUseCase.kt index c7e073e..b8c6928 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/CheckSessionUseCase.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/CheckSessionUseCase.kt @@ -1,5 +1,6 @@ package com.prodhack.moscow2025.domain.usecase.auth +import android.util.Log import com.prodhack.moscow2025.domain.interfaces.AuthRepository import com.prodhack.moscow2025.domain.interfaces.UserRepository import kotlinx.coroutines.flow.firstOrNull @@ -16,14 +17,22 @@ class CheckSessionUseCase( private val authRepository: AuthRepository, private val userRepository: UserRepository ) { + + private companion object { + const val TAG = "CheckSessionUseCase" + } + /** - * return session state with + * @return session state in enum format [SessionState] */ suspend operator fun invoke(): SessionState = if (authRepository.fetchLoginState().firstOrNull() == true) { + Log.d(TAG, "user authorized, requesting profile") if (userRepository.fetchProfile().getOrNull()?.firstName.isNullOrBlank()) { + Log.d(TAG, "user authorized, first name is blank -> need fill profile") SessionState.NotFilledProfile } else { + Log.d(TAG, "user authorized, first name is filled -> user already fill profile") SessionState.FilledAndAuthorized } } else { diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/LoadResumeListUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/LoadResumeListUseCase.kt new file mode 100644 index 0000000..ab40b6d --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/LoadResumeListUseCase.kt @@ -0,0 +1,40 @@ +package com.prodhack.moscow2025.domain.usecase.resumes + +import androidx.paging.PagingData +import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository +import com.prodhack.moscow2025.domain.models.ExperienceType +import com.prodhack.moscow2025.domain.models.ResumeModel +import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper +import kotlinx.coroutines.flow.flow +import org.koin.core.annotation.Single + +@Single +class LoadResumeListUseCase(private val resumeRepository: ResumeRepository) { +// operator fun invoke(): RemotePagingWrapper = resumeRepository.loadResumeList() + + // Mocked data + operator fun invoke(): RemotePagingWrapper = flow { + emit( + PagingData.from( + listOf( + ResumeModel( + id = "iajxioasdkmcaolsd,c", + position = "Android разработчик", + about = "Ну оооочень крутой андроид разраб, с огромным количеством опыта. " + + "И нет это я не про себя, это просто какие-то данные," + + " чтобы проверить, что это чудовище работает", + skills = listOf( + "Android SDK", + "Kotlin", + "Room", + "Ktor" + ), + experienceType = ExperienceType.Between3And6, + prediction = Pair(200000, 230000), + recommendedSkills = listOf("KMP") + ) + ) + ) + ) + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/MainActivity.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/MainActivity.kt index be63580..2acf616 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/MainActivity.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/MainActivity.kt @@ -26,6 +26,10 @@ import kotlin.getValue class MainActivity : ComponentActivity() { + private companion object { + const val TAG = "MainActivity" + } + private val checkSessionUseCase: CheckSessionUseCase by inject() private val sessionDestinationState = MutableStateFlow(null) @@ -42,8 +46,11 @@ class MainActivity : ComponentActivity() { runBlocking { val sessionState = try { - checkSessionUseCase() + checkSessionUseCase().also { + Log.d(TAG, "SessionState received $it") + } } catch (e: Exception) { + Log.e(TAG, "Exception in session state getting process", e) SessionState.NotAuthorized } sessionDestinationState.value = @@ -67,7 +74,6 @@ class MainActivity : ComponentActivity() { .addOnCompleteListener { task -> if (task.isSuccessful) { val token = task.result - Log.d("TOKEN", token) } } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTopLogo.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTopLogo.kt index d1c6b29..687246b 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTopLogo.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTopLogo.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -28,17 +29,17 @@ fun TopLogo( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { - Image( + Icon( modifier = Modifier.size(100.dp), - painter = painterResource(R.drawable.ic_launcher_foreground), + painter = painterResource(R.drawable.app_logo), contentDescription = "App logo" ) Spacer(modifier = Modifier.width(Paddings.medium)) Text( text = stringResource(R.string.app_name), - style = MaterialTheme.typography.titleLarge, - fontSize = 48.sp + style = MaterialTheme.typography.titleMedium, + fontSize = 24.sp ) } } \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/dataModels/UIResumeBaseInfo.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/dataModels/UIResumeBaseInfo.kt new file mode 100644 index 0000000..c7321f1 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/dataModels/UIResumeBaseInfo.kt @@ -0,0 +1,17 @@ +package com.prodhack.moscow2025.presentation.dataModels + +import com.prodhack.moscow2025.domain.models.ResumeModel + +data class UIResumeBaseInfo( + val id: String, + val positionName: String, + val salary: String +) + +fun ResumeModel.mapToBaseUIInfo(): UIResumeBaseInfo = UIResumeBaseInfo( + id = id, + positionName = position, + salary = prediction.first?.let { from -> + prediction.second?.let { to -> "$from-$to" } ?: from.toString() + } ?: prediction.second?.toString() ?: "Ошибка" +) \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksApp.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksApp.kt index 924fe40..8e7629a 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksApp.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksApp.kt @@ -28,7 +28,7 @@ fun TTasksApp( context: Context, sessionDestination: AppDestination? = null ) { - MoscowHackatonTemplateTheme() { + MoscowHackatonTemplateTheme { val snackbarHostState = remember { SnackbarHostState() } val bottomBarState = remember { mutableStateOf(null) } 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 73385ab..d9eac33 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 @@ -144,11 +144,10 @@ fun ErrorCollectorScope.FillProfileScreen( style = typography.titleLarge, fontSize = 31.sp ) - Image( - painter = painterResource(R.drawable.ic_launcher_foreground), + Icon( + painter = painterResource(R.drawable.app_logo), contentDescription = null, - modifier = Modifier.size(140.dp), - contentScale = ContentScale.Crop + modifier = Modifier.size(140.dp) ) } Spacer(Modifier.height(20.dp)) diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginScreen.kt index 5acd578..0bcf9e2 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState @@ -127,8 +128,8 @@ fun ErrorCollectorScope.LoginScreen( .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, ) { - Image( - painter = painterResource(R.drawable.ic_launcher_foreground), + Icon( + painter = painterResource(R.drawable.app_logo), contentDescription = null, modifier = Modifier .size(200.dp) diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt index e7f84d3..b85ce7c 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt @@ -1,9 +1,39 @@ package com.prodhack.moscow2025.presentation.screens.main +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.paging.compose.collectAsLazyPagingItems +import com.prodhack.moscow2025.R +import com.prodhack.moscow2025.presentation.components.standart.BigButton +import com.prodhack.moscow2025.presentation.components.standart.TTFloatingActionButton +import com.prodhack.moscow2025.presentation.components.standart.TopLogo +import com.prodhack.moscow2025.presentation.dataModels.UIResumeBaseInfo +import com.prodhack.moscow2025.presentation.theme.Paddings import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope import org.koin.androidx.compose.koinViewModel @@ -14,269 +44,138 @@ fun ErrorCollectorScope.MainScreen( modifier: Modifier = Modifier, viewModel: MainScreenViewModel = koinViewModel() ) { - Text("Main screen will be here soon") -// val openCalendarModal = remember { mutableStateOf(false) } -// val openTaskAddSheet = remember { mutableStateOf(false) } -// val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) -// val tasks = viewModel.taskList.collectAsLazyPagingItems() -// -// val selectedTask = remember { mutableStateOf(null) } -// -// Box( -// modifier = modifier -// .fillMaxSize() -// .padding(horizontal = Paddings.large), -// contentAlignment = Alignment.BottomCenter -// ) { -// Column( -// modifier = Modifier.fillMaxSize(), -// horizontalAlignment = Alignment.CenterHorizontally -// ) { -// Spacer(modifier = Modifier.height(Paddings.large)) -// TopLogo() -// Spacer(modifier = Modifier.height(Paddings.large)) -// -// MainScreenFilters(viewModel = viewModel) { -// openCalendarModal.value = true -// } -// -// Spacer(modifier = Modifier.height(Paddings.large)) -// -// viewModel.topicList.FoldUIStateWithGlobalCallbacks { topics -> -// BubbledCategoryFilters( -// categories = topics, -// selectedItemId = viewModel.selectedTopicId.value ?: -1 -// ) { categoryId -> -// viewModel.selectTopic(categoryId) -// } -// } -// Spacer(modifier = Modifier.height(Paddings.large)) -// -// if (tasks.loadState.hasError) { -// Text( -// "Упс, кажется что-то пошло не так :(\nНо мы уже со всем разбираемся!", -// style = Typography.titleMedium, -// textAlign = TextAlign.Center, -// fontSize = 18.sp, -// color = MaterialTheme.colorScheme.error -// ) -// } else if (tasks.loadState.append.endOfPaginationReached && tasks.itemCount == 0) { -// Spacer(modifier = Modifier.weight(1f)) -// -// Text( -// "Сделал дело - гуляй смело\nСамое время запланировать новую поездку", -// style = Typography.titleMedium, -// textAlign = TextAlign.Center, -// fontSize = 18.sp, -// color = MaterialTheme.colorScheme.onBackground -// ) -// Spacer(modifier = Modifier.height(Paddings.large)) -// BigButton(buttonText = "Начать", onClick = { -// -// }, isLoading = false) -// -// Spacer(modifier = Modifier.weight(3f)) -// -// } else { -// LazyColumn( -// verticalArrangement = Arrangement.spacedBy(Paddings.small), -// horizontalAlignment = Alignment.CenterHorizontally -// ) { -// items(tasks.itemCount) { it -> -// val task = tasks[it] -// task?.let { -// TaskCard( -// onClick = { -// selectedTask.value = it -// }, -// taskInfo = it, -// isArchiveWaiting = it.id in viewModel.archiveWaitingTasksIds.value -// ) { -// viewModel.toggleTaskAsDone( -// tripId = it.tripId, -// taskId = it.id, -// currState = it.archived -// ) -// tasks.refresh() -// } -// } -// } -// -// item { -// if (!tasks.loadState.append.endOfPaginationReached) { -// CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) -// } -// } -// } -// } -// } -// -// TTFloatingActionButton( -// modifier = Modifier -// .align(Alignment.BottomCenter) -// .padding(bottom = Paddings.medium), -// onClick = { -// openTaskAddSheet.value = true -// }, -// text = "Добавить задачу" -// ) -// } -// -// -// AnimatedVisibility(openCalendarModal.value) { -// DateRangePickerModal({ -// Log.d("DatePicker", it.toString()) -// if (it.first != null && it.second != null) { -// viewModel.setDate(Pair(it.first!!, it.second!!)) -// openCalendarModal.value = false -// } -// }) { -// openCalendarModal.value = false -// } -// } -// -// if (openTaskAddSheet.value) { -// AddTaskBottomSheet( -// sheetState = sheetState, -// onDismiss = { -// openTaskAddSheet.value = false -// } -// ) -// } -// -// val cs = MaterialTheme.colorScheme -// -// val viewSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) -// -// if (selectedTask.value != null) { -// -// val openCalendarModal2 = remember { mutableStateOf(false) } -// -// ModalBottomSheet( -// onDismissRequest = { -// selectedTask.value = null -// }, -// sheetState = viewSheetState, -// dragHandle = {}, -// shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp) -// ) { -// Column( -// modifier = Modifier -// .padding(horizontal = 24.dp, vertical = 16.dp) -// .verticalScroll(rememberScrollState()), -// horizontalAlignment = Alignment.CenterHorizontally -// ) { -// Text( -// text = "Просмотр задачи", -// color = cs.onSurface, -// style = Typography.titleMedium, -// fontSize = 22.sp, -// textAlign = TextAlign.Center, -// modifier = Modifier -// .fillMaxWidth() -// .padding(bottom = 24.dp, top = 8.dp) -// ) -// -// Spacer(modifier = Modifier.height(Paddings.medium)) -// -// Text( -// text = selectedTask.value!!.name, -// color = cs.onSurface, -// style = Typography.titleMedium, -// fontSize = 20.sp, -// textAlign = TextAlign.Center, -// modifier = Modifier -// .fillMaxWidth() -// .padding(bottom = 24.dp, top = 8.dp) -// ) -// -// Spacer(modifier = Modifier.height(Paddings.medium)) -// -// -// Text( -// text = "Что нужно сделать", -// color = cs.onSurface, -// style = Typography.titleMedium, -// fontSize = 18.sp, -// modifier = Modifier -// .fillMaxWidth() -// .padding(bottom = 24.dp, top = 8.dp) -// ) -// -// Spacer(modifier = Modifier.height(Paddings.small)) -// -// Text( -// text = selectedTask.value!!.whatNeedToDo, -// color = cs.onSurface, -// style = Typography.labelLarge, -// fontSize = 16.sp, -// modifier = Modifier -// .fillMaxWidth() -// .padding(bottom = 24.dp, top = 8.dp) -// ) -// -// Spacer(modifier = Modifier.height(Paddings.medium)) -// -// Text( -// text = "Для чего", -// color = cs.onSurface, -// style = Typography.titleMedium, -// fontSize = 18.sp, -// modifier = Modifier -// .fillMaxWidth() -// .padding(bottom = 24.dp, top = 8.dp) -// ) -// -// Spacer(modifier = Modifier.height(Paddings.small)) -// -// Text( -// text = selectedTask.value!!.reason, -// color = cs.onSurface, -// style = Typography.labelLarge, -// fontSize = 16.sp, -// modifier = Modifier -// .fillMaxWidth() -// .padding(bottom = 24.dp, top = 8.dp) -// ) -// -// Spacer(modifier = Modifier.height(Paddings.large)) -// -// TTTextField( -// onClick = { -// openCalendarModal2.value = true -// }, -// value = timestampToDateWithYear(selectedTask.value!!.deadline), -// readOnly = true, -// onValueChange = {}, -// label = "Дедлайн", -// trailingIcon = { -// Icon( -// modifier = Modifier -// .size(24.dp), -// painter = painterResource( -// R.drawable.ic_calendar -// ), -// tint = MaterialTheme.colorScheme.onPrimary, -// contentDescription = null -// ) -// } -// ) -// } -// } -// -// AnimatedVisibility(openCalendarModal2.value) { -// DatePickerModal({ -// Log.d("DatePicker", it.toString()) -// it?.let { date -> -// viewModel.changeTaskDeadline(selectedTask.value, date) -// selectedTask.value = null -// openCalendarModal.value = false -// } -// }) { -// openCalendarModal.value = false -// } -// } -// } + val typography = MaterialTheme.typography + val colorScheme = MaterialTheme.colorScheme + val shapes = MaterialTheme.shapes + + Box { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TopLogo() + Spacer(modifier = Modifier.height(Paddings.medium)) + Text( + text = "Ваши резюме", + style = typography.titleLarge, + fontSize = 32.sp, + color = colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(Paddings.large)) + + val items = viewModel.resumeList.collectAsLazyPagingItems() + + if (items.itemCount == 0 && items.loadState.append.endOfPaginationReached) { + Text( + text = "Здесь пока ничего нет", + style = typography.labelLarge, + textAlign = TextAlign.Center, + fontSize = 24.sp, + color = colorScheme.onBackground + ) + + BigButton(onClick = { + TODO() + }, buttonText = "Создать резюме", isLoading = false) + } else if (items.loadState.hasError) { + Text( + modifier = Modifier + .fillMaxWidth() + .background(colorScheme.error, shape = shapes.small) + .padding(Paddings.medium), + text = "Кажется что-то пошло не так, но мы уже чиним 🛠️", + style = typography.labelLarge, + textAlign = TextAlign.Center, + fontSize = 24.sp, + color = colorScheme.onError + ) + } else { + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + Paddings.medium + ) + ) { + items(items.itemCount) { + val resume = items[it] + resume?.let { + ResumeShortInfoCard(info = it) { + + } + } + } + + item { + if (items.loadState.append.endOfPaginationReached.not()) { + CircularProgressIndicator() + } + } + } + } + } + + val context = LocalContext.current + TTFloatingActionButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = Paddings.medium), + onClick = { + Toast.makeText(context, "Will be soon...", Toast.LENGTH_SHORT).show() + }, + text = "Добавить резюме" + ) + } +} + +@Composable +fun ResumeShortInfoCard( + modifier: Modifier = Modifier, + info: UIResumeBaseInfo, + onClick: () -> Unit +) { + val typography = MaterialTheme.typography + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.small, + onClick = onClick + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(Paddings.medium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + verticalArrangement = Arrangement.spacedBy(Paddings.small) + ) { + Text(info.positionName, style = typography.labelLarge, fontSize = 20.sp) + Row { + Text( + "Ожидаемая ЗП: ", + style = typography.labelLarge, + fontSize = 18.sp + ) + Text( + "${info.salary}₽", + style = typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontSize = 18.sp + ) + } + + } + + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_arr_details), + contentDescription = "Open details" + ) + } + } } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreenViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreenViewModel.kt index adbc27e..7df1569 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreenViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreenViewModel.kt @@ -1,143 +1,17 @@ package com.prodhack.moscow2025.presentation.screens.main +import androidx.paging.map +import com.prodhack.moscow2025.domain.usecase.resumes.LoadResumeListUseCase +import com.prodhack.moscow2025.presentation.dataModels.UIResumeBaseInfo +import com.prodhack.moscow2025.presentation.dataModels.mapToBaseUIInfo import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel +import kotlinx.coroutines.flow.map import org.koin.android.annotation.KoinViewModel @KoinViewModel class MainScreenViewModel( -// private val loadTasksUseCase: LoadTasksUseCase, -// private val loadTasksTopicsListUseCase: LoadTasksTopicListUseCase, -// private val setFinishedStateToTaskUseCase: SetFinishedStateToTaskUseCase, -// private val changeDeadlineUseCase: ChangeDeadlineUseCase + loadResumeListUseCase: LoadResumeListUseCase ) : BaseViewModel() { - -// var userChanged = false -// -// // Date filter -// private val defaultDateFilterState = -// getStartOfTodayTimestamp().let { Pair(it, it + 86400000) } -// -// -// private val dateState = -// mutableStateOf(defaultDateFilterState) -// -// val dateString = derivedStateOf { -// Log.d( -// "MainScreenViewModel", -// "deriving state , defaultDateFilterState - $defaultDateFilterState" -// ) -// when (dateState.value.first) { -// defaultDateFilterState.first -> "Сегодня" -// defaultDateFilterState.second -> "Завтра" -// else -> timestampToDate(dateState.value.first) -// } + "-" + -// when (dateState.value.second) { -// defaultDateFilterState.first -> "Сегодня" -// defaultDateFilterState.second -> "Завтра" -// else -> timestampToDate(dateState.value.second) -// } -// } -// -// fun setDate(dates: Pair) { -// userChanged = true -// dateState.value = -// Pair( -// convertGMTToSystemTimezone(dates.first), -// convertGMTToSystemTimezone(dates.second) -// ) -// -// Log.d("MainScreenViewModel", "updated dates ${dateState.value}") -// } -// -// // Other -// val onlyMyTasksState = mutableStateOf(true) -// -// val showFinished = mutableStateOf(false) -// -// // Topic filters -// -// val selectedTopicId = mutableStateOf(null) -// -// val topicList = MutableUIStateFlow>() -// -// fun loadTopicList() { -// loadTasksTopicsListUseCase().map { it -> it.map { it -> it.map { it.mapToUI() } } } -// .collectRequest(topicList) -// } -// -// fun selectTopic(id: Int) { -// if (selectedTopicId.value == id) { -// selectedTopicId.value = null -// } else { -// selectedTopicId.value = id -// } -// } -// -// // Tasks -// @OptIn(ExperimentalCoroutinesApi::class) -// val taskList = snapshotFlow { -// val dates = dateState.value -// TaskFilters( -// dateStart = dates.first, -// dateEnd = dates.second, -// topicId = selectedTopicId.value, -// onlySelf = onlyMyTasksState.value, -// showArchived = showFinished.value -// ) -// }.flatMapLatest { -// loadTasksUseCase(it) -// }.map { it -> it.map { it.mapToUI() } } -// -// private val archiveWaitingTaskJobs = mutableStateMapOf() -// -// val archiveWaitingTasksIds = derivedStateOf { archiveWaitingTaskJobs.keys } -// -// fun toggleTaskAsDone(tripId: Long, taskId: Long, currState: Boolean) { -// if (currState) { -// viewModelScope.launch { -// setFinishedStateToTaskUseCase( -// tripId = tripId, -// taskId = taskId, -// finishedState = false -// ) -// } -// } else { -// if (taskId in archiveWaitingTasksIds.value) { -// archiveWaitingTaskJobs[taskId]?.let { job -> -// if (!job.isCompleted) { -// job.cancel() -// } -// } -// archiveWaitingTaskJobs.remove(taskId) -// } else { -// archiveWaitingTaskJobs[taskId] = viewModelScope.launch { -// delay(1000) -// setFinishedStateToTaskUseCase( -// tripId = tripId, -// taskId = taskId, -// finishedState = true -// ) -// }.also { -// it.start() -// } -// } -// } -// } -// -// fun update() { -// loadTopicList() -// } -// -// fun changeTaskDeadline(value: UITaskModel?, date: Long) { -// viewModelScope.launch { -// value?.let { -// changeDeadlineUseCase(value.tripId, value.id, date) -// } -// } -// } -// -// init { -// update() -// } + val resumeList = loadResumeListUseCase().map { it -> it.map { it.mapToBaseUIInfo() } } } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt index 3074686..deda99a 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState @@ -111,8 +112,8 @@ fun ErrorCollectorScope.RegisterScreen( .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - Image( - painter = painterResource(R.drawable.ic_launcher_foreground), + Icon( + painter = painterResource(R.drawable.app_logo), contentDescription = null, modifier = Modifier .size(200.dp) diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Theme.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Theme.kt index 09fb7de..f25fdbf 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Theme.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Theme.kt @@ -3,6 +3,7 @@ package com.prodhack.moscow2025.presentation.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme @@ -149,6 +150,10 @@ fun MoscowHackatonTemplateTheme( MaterialTheme( colorScheme = colorScheme, typography = Typography, + shapes = Shapes( + extraSmall = com.prodhack.moscow2025.presentation.theme.Shapes.verySmallRoundedBox, + small = com.prodhack.moscow2025.presentation.theme.Shapes.smallRoundedBox + ), content = content ) } \ No newline at end of file diff --git a/app/src/main/res/drawable/app_logo.xml b/app/src/main/res/drawable/app_logo.xml new file mode 100644 index 0000000..f46ff69 --- /dev/null +++ b/app/src/main/res/drawable/app_logo.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_arr_details.xml b/app/src/main/res/drawable/ic_arr_details.xml new file mode 100644 index 0000000..ac8fc7a --- /dev/null +++ b/app/src/main/res/drawable/ic_arr_details.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 74d295c..05850e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ - MoscowHackatonTemplate + Rekomenci fluon \ No newline at end of file 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 5/6] 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() + } + } } From 2c2fb5a4f4c7709880b6583ce14819b507e6c3f7 Mon Sep 17 00:00:00 2001 From: MaximOksiuta <63787095+MaximOksiuta@users.noreply.github.com> Date: Sat, 22 Nov 2025 06:01:42 +0300 Subject: [PATCH 6/6] feat: added template for resume details screen --- .../presentation/navigation/AppDestination.kt | 7 ++- .../presentation/navigation/TTasksNavHost.kt | 59 +++++++++++-------- .../presentation/navigation/navigate.kt | 16 +++++ .../presentation/screens/main/MainScreen.kt | 8 +-- .../resumeDetails/ResumeDetailsScreen.kt | 22 +++++++ .../resumeDetails/ResumeDetailsViewModel.kt | 11 ++++ 6 files changed, 94 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/prodhack/moscow2025/presentation/navigation/navigate.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsScreen.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsViewModel.kt diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt index e9e9c4a..74daa21 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt @@ -15,5 +15,10 @@ sealed class AppDestination(val route: String) { data object Profile : AppDestination("app/profile") - data object FillProfile : AppDestination("app/fill_profile") + data object FillProfile : AppDestination("app/fill_profile") + + data object ResumeDetails : AppDestination("resume/details") { + const val ARG_ID = "id" + } } + diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt index e20efba..af7827e 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt @@ -1,9 +1,11 @@ package com.prodhack.moscow2025.presentation.navigation import android.content.Context +import android.os.Bundle import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.core.os.bundleOf import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -13,6 +15,7 @@ import com.prodhack.moscow2025.presentation.screens.fillProfile.FillProfileScree import com.prodhack.moscow2025.presentation.screens.login.LoginScreen import com.prodhack.moscow2025.presentation.screens.profile.ProfileScreen import com.prodhack.moscow2025.presentation.screens.register.RegisterScreen +import com.prodhack.moscow2025.presentation.screens.resumeDetails.ResumeDetailsScreen import com.prodhack.moscow2025.presentation.utils.ErrorCallbacks import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope import org.koin.compose.viewmodel.koinActivityViewModel @@ -74,32 +77,40 @@ fun TTasksNavHost( ) } - composable(AppDestination.FillProfile.route) { - FillProfileScreen( - snackbarHostState = snackbarHostState, - onSuccess = { - navController.navigate(AppDestination.Main.route) { - popUpTo(AppDestination.FillProfile.route) { - inclusive = true - } - } - } - ) - } - - composable(AppDestination.Main.route) { - MainScreen() + composable(AppDestination.FillProfile.route) { + FillProfileScreen( + snackbarHostState = snackbarHostState, + onSuccess = { + navController.navigate(AppDestination.Main.route) { + popUpTo(AppDestination.FillProfile.route) { + inclusive = true + } + } + } + ) } - composable(AppDestination.Profile.route) - { - ProfileScreen( - snackbarHostState = snackbarHostState, - navigateToLoginScreen = { - navController.navigate(AppDestination.Login.route) - } - ) - } + composable(AppDestination.Main.route) { + MainScreen(openResumeDetails = { id -> + navController.navigate(AppDestination.ResumeDetails.route, Bundle().apply { + putString(AppDestination.ResumeDetails.ARG_ID, id) + }) + }) + } + + composable(AppDestination.Profile.route) + { + ProfileScreen( + snackbarHostState = snackbarHostState, + navigateToLoginScreen = { + navController.navigate(AppDestination.Login.route) + } + ) + } + + composable(AppDestination.ResumeDetails.route) { + ResumeDetailsScreen(navBackStackEntry = it) + } } } } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/navigate.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/navigate.kt new file mode 100644 index 0000000..ecd8073 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/navigate.kt @@ -0,0 +1,16 @@ +package com.prodhack.moscow2025.presentation.navigation + +import android.os.Bundle +import androidx.navigation.NavController +import androidx.navigation.NavOptions +import androidx.navigation.Navigator + +fun NavController.navigate( + route: String, + args: Bundle +) { + val nodeId = graph.findNode(route = route)?.id + if (nodeId != null) { + navigate(nodeId, args, null, null) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt index b85ce7c..2397a76 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt @@ -42,6 +42,7 @@ import org.koin.androidx.compose.koinViewModel @Composable fun ErrorCollectorScope.MainScreen( modifier: Modifier = Modifier, + openResumeDetails: (String) -> Unit, viewModel: MainScreenViewModel = koinViewModel() ) { val typography = MaterialTheme.typography @@ -100,10 +101,9 @@ fun ErrorCollectorScope.MainScreen( ) ) { items(items.itemCount) { - val resume = items[it] - resume?.let { - ResumeShortInfoCard(info = it) { - + items[it]?.let { resume -> + ResumeShortInfoCard(info = resume) { + openResumeDetails(resume.id) } } } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsScreen.kt new file mode 100644 index 0000000..d2be0de --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsScreen.kt @@ -0,0 +1,22 @@ +package com.prodhack.moscow2025.presentation.screens.resumeDetails + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.navigation.NavBackStackEntry +import com.prodhack.moscow2025.presentation.navigation.AppDestination +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun ResumeDetailsScreen( + navBackStackEntry: NavBackStackEntry, + viewModel: ResumeDetailsViewModel = koinViewModel { + parametersOf( + navBackStackEntry.arguments?.getString(AppDestination.ResumeDetails.ARG_ID, "") ?: "" + ) + } +) { + + Text("Opened resume details for id ${navBackStackEntry.arguments?.getString(AppDestination.ResumeDetails.ARG_ID, "") ?: ""}") + +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsViewModel.kt new file mode 100644 index 0000000..69e1744 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsViewModel.kt @@ -0,0 +1,11 @@ +package com.prodhack.moscow2025.presentation.screens.resumeDetails + +import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel +import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.Provided + +@KoinViewModel +class ResumeDetailsViewModel( + @Provided resumeId: String +) : BaseViewModel() { +} \ No newline at end of file