Merge branch 'master' of gitlab.prodcontest.com:team-39/mobile

* 'master' of gitlab.prodcontest.com:team-39/mobile:
  feat: added template for resume details screen
  fix: fix phone field on profile screen, bottom bar beautify; feat: show buttons only after change, on profile edit
  feat: main screen implemented
  fix: fixing bugs with phone input field. feat: абсолютно готов экран profile
  feat: added profile edir screen
  feat: added view model for profile screen
This commit is contained in:
ITQ
2025-11-22 07:31:17 +03:00
50 changed files with 1725 additions and 788 deletions
+6
View File
@@ -19,3 +19,9 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
# Keep annotation definitions
-keep class org.koin.core.annotation.** { *; }
# Keep classes annotated with Koin annotations
-keep @org.koin.core.annotation.* class * { *; }
@@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "bf664fe902e116c42af432814d63d6a7", "identityHash": "3e896e9a3d3b2f61149f8c0fde7e5964",
"entities": [ "entities": [
{ {
"tableName": "users", "tableName": "users",
@@ -52,11 +52,69 @@
"id" "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": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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')"
] ]
} }
} }
@@ -28,10 +28,8 @@ class App : Application() {
androidContext(this@App) androidContext(this@App)
analytics() analytics()
modules( modules(
listOf(
AppModules().module AppModules().module
) )
)
} }
FirebaseApp.initializeApp(this@App) FirebaseApp.initializeApp(this@App)
} }
@@ -1,5 +1,5 @@
package com.prodhack.moscow2025.common package com.prodhack.moscow2025.common
object Constants { 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/"
} }
@@ -1,6 +1,7 @@
package com.prodhack.moscow2025.common.di package com.prodhack.moscow2025.common.di
import com.prodhack.moscow2025.data.data_providers.local_db.DatabaseProvider import com.prodhack.moscow2025.data.data_providers.local_db.DatabaseProvider
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module import org.koin.core.annotation.Module
/** /**
@@ -1,7 +1,11 @@
package com.prodhack.moscow2025.common.di 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.ComponentScan
import org.koin.core.annotation.Module import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
@Module @Module
@ComponentScan("com.prodhack.moscow2025.presentation") @ComponentScan("com.prodhack.moscow2025.presentation")
@@ -13,4 +17,13 @@ class DomainModule
@Module @Module
@ComponentScan("com.prodhack.moscow2025.data") @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()
}
@@ -1,6 +0,0 @@
package com.prodhack.moscow2025.data.base
interface BaseEntity {
val id: Number
}
@@ -8,7 +8,7 @@ import androidx.room.RoomDatabase
import androidx.room.withTransaction import androidx.room.withTransaction
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
class BaseRemoteMediator<DBEntity : BaseEntity>( class BaseRemoteMediator<DBEntity : Any>(
private val db: RoomDatabase, private val db: RoomDatabase,
private val dao: BasePaginationDAO<DBEntity>, private val dao: BasePaginationDAO<DBEntity>,
private val makeRequest: suspend (page: Long, pageCount: Int) -> Result<List<DBEntity>> private val makeRequest: suspend (page: Long, pageCount: Int) -> Result<List<DBEntity>>
@@ -26,17 +26,12 @@ class BaseRemoteMediator<DBEntity : BaseEntity>(
) )
LoadType.APPEND -> { LoadType.APPEND -> {
val lastItem = state.lastItemOrNull() state.pages.size + 1
if (lastItem == null) {
1
} else {
(lastItem.id.toLong() / state.config.pageSize) + 1
}
} }
} }
val result = makeRequest( val result = makeRequest(
loadKey, (loadKey.toLong() - 1) * state.config.pageSize,
state.config.pageSize state.config.pageSize
) )
@@ -46,8 +41,7 @@ class BaseRemoteMediator<DBEntity : BaseEntity>(
if (loadType == LoadType.REFRESH) { if (loadType == LoadType.REFRESH) {
dao.clearAll() dao.clearAll()
} }
val beerEntities = data dao.upsertAll(data)
dao.upsertAll(beerEntities)
} }
MediatorResult.Success( MediatorResult.Success(
endOfPaginationReached = data.size < state.config.pageSize endOfPaginationReached = data.size < state.config.pageSize
@@ -141,7 +141,7 @@ abstract class BaseRepository {
} }
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
protected fun <Value : BaseEntity> paginatedRequest( protected fun <Value : Any> paginatedRequest(
pageSize: Int = 10, pageSize: Int = 10,
prefetchDistance: Int = pageSize, prefetchDistance: Int = pageSize,
enablePlaceholders: Boolean = true, enablePlaceholders: Boolean = true,
@@ -149,7 +149,7 @@ abstract class BaseRepository {
maxSize: Int = Int.MAX_VALUE, maxSize: Int = Int.MAX_VALUE,
jumpThreshold: Int = Int.MIN_VALUE, jumpThreshold: Int = Int.MIN_VALUE,
dbDao: BasePaginationDAO<Value>, dbDao: BasePaginationDAO<Value>,
makeRequest: suspend (page: Long, pageSize: Int) -> Result<List<Value>> makeRequest: suspend (offset: Long, pageSize: Int) -> Result<List<Value>>
): Flow<PagingData<Value>> { ): Flow<PagingData<Value>> {
assertDBSpecify() assertDBSpecify()
@@ -1,5 +0,0 @@
package com.prodhack.moscow2025.data.base
interface DBMappableDTO <T> {
fun mapToDB(): T
}
@@ -1,5 +0,0 @@
package com.prodhack.moscow2025.data.base
interface DomainMappableDTO <T> {
fun mapToDomain(): T
}
@@ -3,11 +3,13 @@ package com.prodhack.moscow2025.data.data_providers.local_db
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase 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.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.dao.UserDao
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity
import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity
@Database( @Database(
entities = [UserEntity::class], entities = [UserEntity::class, ResumeEntity::class],
version = 1, version = 1,
exportSchema = true exportSchema = true
) )
@@ -15,4 +17,5 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao abstract fun userDao(): UserDao
abstract fun cleanUpDao(): CleanUpDao abstract fun cleanUpDao(): CleanUpDao
abstract fun resumeDao(): ResumeDao
} }
@@ -8,12 +8,5 @@ import org.koin.core.annotation.Single
@Module @Module
class DatabaseProvider { class DatabaseProvider {
@Single
fun provideDatabase(context: Context): AppDatabase =
Room.databaseBuilder(
context,
AppDatabase::class.java,
"t_tasks.db"
).fallbackToDestructiveMigration()
.build()
} }
@@ -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<ResumeEntity> {
@Query("DELETE FROM resumes")
override suspend fun clearAll()
@Upsert
override suspend fun upsertAll(data: List<ResumeEntity>)
@Query("SELECT * FROM resumes")
override fun getPaginatedData(): PagingSource<Int, ResumeEntity>
}
@@ -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("|")
)
}
@@ -7,33 +7,6 @@ import com.prodhack.moscow2025.domain.models.User
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable 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 @Serializable
data class UserLoginRequest( data class UserLoginRequest(
@@ -58,28 +31,3 @@ data class TokenResponse(
@SerialName("access_token") @SerialName("access_token")
val token: String 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
)
}
@@ -0,0 +1,8 @@
package com.prodhack.moscow2025.data.dto
import kotlinx.serialization.Serializable
@Serializable
data class ErrorNetworkDTO(
val detail: String
)
@@ -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<String>,
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<String>
)
@Serializable
data class ResumeListDTO(
val resumes: List<ResumeDTO>
)
@@ -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
)
}
@@ -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<ResumeModel> = paginatedRequest(
pageSize = 20,
dbDao = resumeDao,
makeRequest = { offset, pageSize ->
networkRequest<ResumeListDTO> {
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() } }
}
@@ -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<ResumeModel>
}
@@ -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<String>,
val experienceType: ExperienceType,
val prediction: Pair<Int?, Int?>,
val recommendedSkills: List<String>
)
enum class ExperienceType {
NoExperience,
LessThan1,
Between1And3,
Between3And6,
MoreThan6
}
@@ -1,5 +1,6 @@
package com.prodhack.moscow2025.domain.usecase.auth package com.prodhack.moscow2025.domain.usecase.auth
import android.util.Log
import com.prodhack.moscow2025.domain.interfaces.AuthRepository import com.prodhack.moscow2025.domain.interfaces.AuthRepository
import com.prodhack.moscow2025.domain.interfaces.UserRepository import com.prodhack.moscow2025.domain.interfaces.UserRepository
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
@@ -16,14 +17,22 @@ class CheckSessionUseCase(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val userRepository: UserRepository 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 = suspend operator fun invoke(): SessionState =
if (authRepository.fetchLoginState().firstOrNull() == true) { if (authRepository.fetchLoginState().firstOrNull() == true) {
Log.d(TAG, "user authorized, requesting profile")
if (userRepository.fetchProfile().getOrNull()?.firstName.isNullOrBlank()) { if (userRepository.fetchProfile().getOrNull()?.firstName.isNullOrBlank()) {
Log.d(TAG, "user authorized, first name is blank -> need fill profile")
SessionState.NotFilledProfile SessionState.NotFilledProfile
} else { } else {
Log.d(TAG, "user authorized, first name is filled -> user already fill profile")
SessionState.FilledAndAuthorized SessionState.FilledAndAuthorized
} }
} else { } else {
@@ -24,6 +24,25 @@ data class ValidationResult(
@Single @Single
class ValidateAuthFieldsUseCase { class ValidateAuthFieldsUseCase {
fun validateProfile(
chosenPattern: PhoneNumberPattern?,
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")
val maxCount = chosenPattern!!.pattern.count { it == '0' }
if (phone.isNotBlank() && !isPhoneValid(phone) && phone.length != maxCount) put(
AuthField.Phone,
"Некорректный номер телефона"
)
}
return ValidationResult(errors)
}
fun validateFillProfile( fun validateFillProfile(
chosenPattern: PhoneNumberPattern?, chosenPattern: PhoneNumberPattern?,
@@ -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<ResumeModel> = resumeRepository.loadResumeList()
// Mocked data
operator fun invoke(): RemotePagingWrapper<ResumeModel> = 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")
)
)
)
)
}
}
@@ -26,6 +26,10 @@ import kotlin.getValue
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private companion object {
const val TAG = "MainActivity"
}
private val checkSessionUseCase: CheckSessionUseCase by inject() private val checkSessionUseCase: CheckSessionUseCase by inject()
private val sessionDestinationState = MutableStateFlow<AppDestination?>(null) private val sessionDestinationState = MutableStateFlow<AppDestination?>(null)
@@ -42,8 +46,11 @@ class MainActivity : ComponentActivity() {
runBlocking { runBlocking {
val sessionState = try { val sessionState = try {
checkSessionUseCase() checkSessionUseCase().also {
Log.d(TAG, "SessionState received $it")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Exception in session state getting process", e)
SessionState.NotAuthorized SessionState.NotAuthorized
} }
sessionDestinationState.value = sessionDestinationState.value =
@@ -67,7 +74,6 @@ class MainActivity : ComponentActivity() {
.addOnCompleteListener { task -> .addOnCompleteListener { task ->
if (task.isSuccessful) { if (task.isSuccessful) {
val token = task.result val token = task.result
Log.d("TOKEN", token)
} }
} }
@@ -4,6 +4,8 @@ import android.util.Log
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -59,7 +61,6 @@ fun TBottomNavigation(modifier: Modifier = Modifier, selectedPage: Int, onSelect
} }
target?.let { (it - center).toDp() } target?.let { (it - center).toDp() }
} }
AnimatedVisibility(indicatorOffset != null) {
indicatorOffset?.let { indicatorOffset?.let {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -71,7 +72,6 @@ fun TBottomNavigation(modifier: Modifier = Modifier, selectedPage: Int, onSelect
) )
) )
} }
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Icon( Icon(
modifier = Modifier modifier = Modifier
@@ -0,0 +1,158 @@
package com.prodhack.moscow2025.presentation.components.standart
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.presentation.screens.fillProfile.UIPhoneNumberPattern
import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes
import com.prodhack.moscow2025.presentation.utils.PhoneVisualTransformation
@Composable
fun TPhoneField(
modifier: Modifier = Modifier,
currentPattern: UIPhoneNumberPattern?,
currentPhone: String,
onPhoneChange: (String) -> Unit,
error: String?,
onOpenCountryList: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = modifier.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(
Paddings.medium
)
) {
FieldWrapper(
modifier = Modifier
.width(IntrinsicSize.Min)
.fillMaxHeight()
) {
BasicTextField(
modifier = Modifier
.fillMaxSize()
.offset(y = 5.dp)
.padding(bottom = 16.dp)
.background(colorScheme.primary, Shapes.smallRoundedBox)
.clip(Shapes.smallRoundedBox),
value = currentPattern?.prefix ?: "",
onValueChange = {},
readOnly = true,
textStyle = TextStyle(
color = colorScheme.onPrimary
),
decorationBox = { innerTextField ->
Row(
modifier = Modifier
.clickable {
onOpenCountryList()
}
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.weight(1f)) {
innerTextField()
}
Icon(
modifier = Modifier.size(15.dp),
painter = painterResource(R.drawable.ic_arr_dropdown),
tint = colorScheme.onPrimary,
contentDescription = null
)
}
}
)
}
TTTextField(
value = currentPhone,
onValueChange = onPhoneChange,
label = "Ваш телефон",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone
),
visualTransformation = currentPattern?.let {
PhoneVisualTransformation(
it.pattern,
'0'
)
} ?: VisualTransformation.None,
error = error
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TPhoneCountryList(
modifier: Modifier = Modifier,
isSheetOpen: MutableState<Boolean>,
sheetState: SheetState,
patternList: List<UIPhoneNumberPattern>,
setPattern: (UIPhoneNumberPattern) -> Unit
) {
if (isSheetOpen.value) {
ModalBottomSheet(
modifier = modifier,
sheetState = sheetState,
onDismissRequest = {
isSheetOpen.value = false
},
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(patternList) { pattern ->
Text(
text = pattern.name,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
.clickable {
setPattern(pattern)
isSheetOpen.value = false
}
)
}
}
}
}
}
}
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -28,17 +29,17 @@ fun TopLogo(
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Image( Icon(
modifier = Modifier.size(100.dp), modifier = Modifier.size(100.dp),
painter = painterResource(R.drawable.ic_launcher_foreground), painter = painterResource(R.drawable.app_logo),
contentDescription = "App logo" contentDescription = "App logo"
) )
Spacer(modifier = Modifier.width(Paddings.medium)) Spacer(modifier = Modifier.width(Paddings.medium))
Text( Text(
text = stringResource(R.string.app_name), text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleMedium,
fontSize = 48.sp fontSize = 24.sp
) )
} }
} }
@@ -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() ?: "Ошибка"
)
@@ -16,4 +16,9 @@ sealed class AppDestination(val route: String) {
data object Profile : AppDestination("app/profile") 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"
} }
}
@@ -19,6 +19,8 @@ import androidx.compose.ui.Modifier
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import com.prodhack.moscow2025.presentation.components.TBottomNavigation import com.prodhack.moscow2025.presentation.components.TBottomNavigation
import com.prodhack.moscow2025.presentation.theme.MoscowHackatonTemplateTheme import com.prodhack.moscow2025.presentation.theme.MoscowHackatonTemplateTheme
import com.prodhack.moscow2025.presentation.utils.ui.AppSnackbarVisuals
import com.prodhack.moscow2025.presentation.utils.ui.SnackbarStyle
@Composable @Composable
fun TTasksApp( fun TTasksApp(
@@ -26,7 +28,7 @@ fun TTasksApp(
context: Context, context: Context,
sessionDestination: AppDestination? = null sessionDestination: AppDestination? = null
) { ) {
MoscowHackatonTemplateTheme() { MoscowHackatonTemplateTheme {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val bottomBarState = remember { mutableStateOf<Int?>(null) } val bottomBarState = remember { mutableStateOf<Int?>(null) }
@@ -53,10 +55,19 @@ fun TTasksApp(
SnackbarHost( SnackbarHost(
hostState = snackbarHostState, hostState = snackbarHostState,
snackbar = { data -> 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( Snackbar(
snackbarData = data, snackbarData = data,
containerColor = MaterialTheme.colorScheme.errorContainer, containerColor = containerColor,
contentColor = MaterialTheme.colorScheme.onErrorContainer, contentColor = contentColor,
shape = MaterialTheme.shapes.medium shape = MaterialTheme.shapes.medium
) )
} }
@@ -1,9 +1,11 @@
package com.prodhack.moscow2025.presentation.navigation package com.prodhack.moscow2025.presentation.navigation
import android.content.Context import android.content.Context
import android.os.Bundle
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.os.bundleOf
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable 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.login.LoginScreen
import com.prodhack.moscow2025.presentation.screens.profile.ProfileScreen import com.prodhack.moscow2025.presentation.screens.profile.ProfileScreen
import com.prodhack.moscow2025.presentation.screens.register.RegisterScreen 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.ErrorCallbacks
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
import org.koin.compose.viewmodel.koinActivityViewModel import org.koin.compose.viewmodel.koinActivityViewModel
@@ -88,12 +91,25 @@ fun TTasksNavHost(
} }
composable(AppDestination.Main.route) { composable(AppDestination.Main.route) {
MainScreen() MainScreen(openResumeDetails = { id ->
navController.navigate(AppDestination.ResumeDetails.route, Bundle().apply {
putString(AppDestination.ResumeDetails.ARG_ID, id)
})
})
} }
composable(AppDestination.Profile.route) composable(AppDestination.Profile.route)
{ {
ProfileScreen() ProfileScreen(
snackbarHostState = snackbarHostState,
navigateToLoginScreen = {
navController.navigate(AppDestination.Login.route)
}
)
}
composable(AppDestination.ResumeDetails.route) {
ResumeDetailsScreen(navBackStackEntry = it)
} }
} }
} }
@@ -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)
}
}
@@ -44,7 +44,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -56,6 +55,8 @@ import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.domain.usecase.auth.AuthField import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.presentation.components.standart.BigButton import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.FieldWrapper import com.prodhack.moscow2025.presentation.components.standart.FieldWrapper
import com.prodhack.moscow2025.presentation.components.standart.TPhoneCountryList
import com.prodhack.moscow2025.presentation.components.standart.TPhoneField
import com.prodhack.moscow2025.presentation.components.standart.TTTextField import com.prodhack.moscow2025.presentation.components.standart.TTTextField
import com.prodhack.moscow2025.presentation.theme.Paddings import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes import com.prodhack.moscow2025.presentation.theme.Shapes
@@ -144,11 +145,10 @@ fun ErrorCollectorScope.FillProfileScreen(
style = typography.titleLarge, style = typography.titleLarge,
fontSize = 31.sp fontSize = 31.sp
) )
Image( Icon(
painter = painterResource(R.drawable.ic_launcher_foreground), painter = painterResource(R.drawable.app_logo),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(140.dp), modifier = Modifier.size(140.dp)
contentScale = ContentScale.Crop
) )
} }
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
@@ -166,70 +166,16 @@ fun ErrorCollectorScope.FillProfileScreen(
error = formState.errors[AuthField.LastName], error = formState.errors[AuthField.LastName],
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
TPhoneField(
Row( currentPattern = viewModel.currentPattern.value,
modifier = Modifier.height(IntrinsicSize.Min), currentPhone = formState.phone,
verticalAlignment = Alignment.CenterVertically, onPhoneChange = viewModel::onPhoneChange,
horizontalArrangement = Arrangement.spacedBy( error = formState.errors[AuthField.Phone],
Paddings.medium onOpenCountryList =
) {
) {
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 isSheetOpen.value = true
} }
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.weight(1f)) {
innerTextField()
}
Icon(
modifier = Modifier.size(15.dp),
painter = painterResource(R.drawable.ic_arr_dropdown),
tint = colorScheme.onPrimary,
contentDescription = null
) )
}
}
)
}
TTTextField(
value = formState.phone,
onValueChange = viewModel::onPhoneChange,
label = "Ваш телефон",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone
),
visualTransformation = viewModel.chosenPattern.value?.pattern?.let {
PhoneVisualTransformation(
it,
'0'
)
} ?: VisualTransformation.None,
error = formState.errors[AuthField.Phone]
)
Log.d("Test", formState.errors[AuthField.Phone].toString())
}
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
BigButton( BigButton(
@@ -242,32 +188,12 @@ fun ErrorCollectorScope.FillProfileScreen(
} }
} }
if (isSheetOpen.value) { TPhoneCountryList(
ModalBottomSheet( isSheetOpen = isSheetOpen,
sheetState = sheetState, sheetState = sheetState,
onDismissRequest = { patternList = viewModel.phoneNumberPatterns,
isSheetOpen.value = false setPattern = {
}, viewModel.currentPattern.value = it
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(viewModel.phoneNumberPatterns) { pattern ->
Text(
text = pattern.name,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
.clickable {
viewModel.chosenPattern.value = pattern
isSheetOpen.value = false
} }
) )
} }
}
}
}
}
}
@@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel import org.koin.android.annotation.KoinViewModel
import org.koin.core.annotation.Single
data class FillProfileFormState( data class FillProfileFormState(
val firstName: String = "", val firstName: String = "",
@@ -95,9 +94,11 @@ class FillProfileViewModel(
} }
fun onPhoneChange(value: String) { fun onPhoneChange(value: String) {
val maxDigits = currentPattern.value?.pattern?.count { it == '0' } ?: Int.MAX_VALUE
val digits = value.filter { it.isDigit() }.take(maxDigits)
_formStateFillProfile.update { _formStateFillProfile.update {
it.copy( it.copy(
phone = value, phone = digits,
errors = it.errors - AuthField.Phone errors = it.errors - AuthField.Phone
) )
} }
@@ -151,14 +152,14 @@ class FillProfileViewModel(
} }
val chosenPattern = mutableStateOf<UIPhoneNumberPattern?>(null) val currentPattern = mutableStateOf<UIPhoneNumberPattern?>(null)
val phoneNumberPatterns = mutableStateListOf<UIPhoneNumberPattern>() val phoneNumberPatterns = mutableStateListOf<UIPhoneNumberPattern>()
fun update() { fun update() {
// Load default pattern // Load default pattern
chosenPattern.value = getDefaultPhoneNumberPatternUseCase.execute()?.mapToUI() currentPattern.value = getDefaultPhoneNumberPatternUseCase.execute()?.mapToUI()
// Load all phone number patterns // Load all phone number patterns
phoneNumberPatterns.clear() phoneNumberPatterns.clear()
@@ -171,7 +172,7 @@ class FillProfileViewModel(
firstName = _formStateFillProfile.value.firstName, firstName = _formStateFillProfile.value.firstName,
lastName = _formStateFillProfile.value.lastName, lastName = _formStateFillProfile.value.lastName,
phone = _formStateFillProfile.value.phone, phone = _formStateFillProfile.value.phone,
chosenPattern = chosenPattern.value?.mapToDomain() chosenPattern = currentPattern.value?.mapToDomain()
) )
if (!validation.isValid) { if (!validation.isValid) {
@@ -185,7 +186,7 @@ class FillProfileViewModel(
UpdateUserData( UpdateUserData(
firstName = _formStateFillProfile.value.firstName, firstName = _formStateFillProfile.value.firstName,
lastName = _formStateFillProfile.value.lastName, lastName = _formStateFillProfile.value.lastName,
phone = chosenPattern.value?.mapToDomain()?.let { phoneNumberPattern -> phone = currentPattern.value?.mapToDomain()?.let { phoneNumberPattern ->
convertNumberToPattern( convertNumberToPattern(
phoneNumberPattern, phoneNumberPattern,
_formStateFillProfile.value.phone _formStateFillProfile.value.phone
@@ -16,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@@ -127,8 +128,8 @@ fun ErrorCollectorScope.LoginScreen(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Image( Icon(
painter = painterResource(R.drawable.ic_launcher_foreground), painter = painterResource(R.drawable.app_logo),
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(200.dp) .size(200.dp)
@@ -1,9 +1,39 @@
package com.prodhack.moscow2025.presentation.screens.main 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@@ -12,271 +42,140 @@ import org.koin.androidx.compose.koinViewModel
@Composable @Composable
fun ErrorCollectorScope.MainScreen( fun ErrorCollectorScope.MainScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
openResumeDetails: (String) -> Unit,
viewModel: MainScreenViewModel = koinViewModel() viewModel: MainScreenViewModel = koinViewModel()
) { ) {
Text("Main screen will be here soon") val typography = MaterialTheme.typography
// val openCalendarModal = remember { mutableStateOf(false) } val colorScheme = MaterialTheme.colorScheme
// val openTaskAddSheet = remember { mutableStateOf(false) } val shapes = MaterialTheme.shapes
// val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// val tasks = viewModel.taskList.collectAsLazyPagingItems() Box {
// Column(
// val selectedTask = remember { mutableStateOf<UITaskModel?>(null) } modifier = modifier
// .fillMaxSize()
// Box( .padding(horizontal = 20.dp),
// modifier = modifier horizontalAlignment = Alignment.CenterHorizontally
// .fillMaxSize() ) {
// .padding(horizontal = Paddings.large), TopLogo()
// contentAlignment = Alignment.BottomCenter Spacer(modifier = Modifier.height(Paddings.medium))
// ) { Text(
// Column( text = "Ваши резюме",
// modifier = Modifier.fillMaxSize(), style = typography.titleLarge,
// horizontalAlignment = Alignment.CenterHorizontally fontSize = 32.sp,
// ) { color = colorScheme.onBackground
// Spacer(modifier = Modifier.height(Paddings.large)) )
// TopLogo()
// Spacer(modifier = Modifier.height(Paddings.large)) Spacer(modifier = Modifier.height(Paddings.large))
//
// MainScreenFilters(viewModel = viewModel) { val items = viewModel.resumeList.collectAsLazyPagingItems()
// openCalendarModal.value = true
// } if (items.itemCount == 0 && items.loadState.append.endOfPaginationReached) {
// Text(
// Spacer(modifier = Modifier.height(Paddings.large)) text = "Здесь пока ничего нет",
// style = typography.labelLarge,
// viewModel.topicList.FoldUIStateWithGlobalCallbacks { topics -> textAlign = TextAlign.Center,
// BubbledCategoryFilters( fontSize = 24.sp,
// categories = topics, color = colorScheme.onBackground
// selectedItemId = viewModel.selectedTopicId.value ?: -1 )
// ) { categoryId ->
// viewModel.selectTopic(categoryId) BigButton(onClick = {
// } TODO()
// } }, buttonText = "Создать резюме", isLoading = false)
// Spacer(modifier = Modifier.height(Paddings.large)) } else if (items.loadState.hasError) {
// Text(
// if (tasks.loadState.hasError) { modifier = Modifier
// Text( .fillMaxWidth()
// "Упс, кажется что-то пошло не так :(\nНо мы уже со всем разбираемся!", .background(colorScheme.error, shape = shapes.small)
// style = Typography.titleMedium, .padding(Paddings.medium),
// textAlign = TextAlign.Center, text = "Кажется что-то пошло не так, но мы уже чиним 🛠️",
// fontSize = 18.sp, style = typography.labelLarge,
// color = MaterialTheme.colorScheme.error textAlign = TextAlign.Center,
// ) fontSize = 24.sp,
// } else if (tasks.loadState.append.endOfPaginationReached && tasks.itemCount == 0) { color = colorScheme.onError
// Spacer(modifier = Modifier.weight(1f)) )
// } else {
// Text( LazyColumn(
// "Сделал дело - гуляй смело\nСамое время запланировать новую поездку", horizontalAlignment = Alignment.CenterHorizontally,
// style = Typography.titleMedium, verticalArrangement = Arrangement.spacedBy(
// textAlign = TextAlign.Center, Paddings.medium
// fontSize = 18.sp, )
// color = MaterialTheme.colorScheme.onBackground ) {
// ) items(items.itemCount) {
// Spacer(modifier = Modifier.height(Paddings.large)) items[it]?.let { resume ->
// BigButton(buttonText = "Начать", onClick = { ResumeShortInfoCard(info = resume) {
// openResumeDetails(resume.id)
// }, isLoading = false) }
// }
// Spacer(modifier = Modifier.weight(3f)) }
//
// } else { item {
// LazyColumn( if (items.loadState.append.endOfPaginationReached.not()) {
// verticalArrangement = Arrangement.spacedBy(Paddings.small), CircularProgressIndicator()
// horizontalAlignment = Alignment.CenterHorizontally }
// ) { }
// items(tasks.itemCount) { it -> }
// val task = tasks[it] }
// task?.let { }
// TaskCard(
// onClick = { val context = LocalContext.current
// selectedTask.value = it TTFloatingActionButton(
// }, modifier = Modifier
// taskInfo = it, .align(Alignment.BottomCenter)
// isArchiveWaiting = it.id in viewModel.archiveWaitingTasksIds.value .padding(bottom = Paddings.medium),
// ) { onClick = {
// viewModel.toggleTaskAsDone( Toast.makeText(context, "Will be soon...", Toast.LENGTH_SHORT).show()
// tripId = it.tripId, },
// taskId = it.id, text = "Добавить резюме"
// currState = it.archived )
// ) }
// tasks.refresh() }
// }
// } @Composable
// } fun ResumeShortInfoCard(
// modifier: Modifier = Modifier,
// item { info: UIResumeBaseInfo,
// if (!tasks.loadState.append.endOfPaginationReached) { onClick: () -> Unit
// CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) ) {
// } val typography = MaterialTheme.typography
// } Card(
// } modifier = modifier.fillMaxWidth(),
// } shape = MaterialTheme.shapes.small,
// } onClick = onClick
// ) {
// TTFloatingActionButton( Row(
// modifier = Modifier modifier = Modifier
// .align(Alignment.BottomCenter) .fillMaxWidth()
// .padding(bottom = Paddings.medium), .padding(Paddings.medium),
// onClick = { verticalAlignment = Alignment.CenterVertically,
// openTaskAddSheet.value = true horizontalArrangement = Arrangement.SpaceBetween
// }, ) {
// text = "Добавить задачу" Column(
// ) verticalArrangement = Arrangement.spacedBy(Paddings.small)
// } ) {
// Text(info.positionName, style = typography.labelLarge, fontSize = 20.sp)
// Row {
// AnimatedVisibility(openCalendarModal.value) { Text(
// DateRangePickerModal({ "Ожидаемая ЗП: ",
// Log.d("DatePicker", it.toString()) style = typography.labelLarge,
// if (it.first != null && it.second != null) { fontSize = 18.sp
// viewModel.setDate(Pair(it.first!!, it.second!!)) )
// openCalendarModal.value = false Text(
// } "${info.salary}",
// }) { style = typography.titleMedium,
// openCalendarModal.value = false color = MaterialTheme.colorScheme.primary,
// } fontSize = 18.sp
// } )
// }
// if (openTaskAddSheet.value) {
// AddTaskBottomSheet( }
// sheetState = sheetState,
// onDismiss = { Icon(
// openTaskAddSheet.value = false modifier = Modifier.size(24.dp),
// } painter = painterResource(R.drawable.ic_arr_details),
// ) contentDescription = "Open details"
// } )
// }
// 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
// }
// }
// }
} }
@@ -1,143 +1,17 @@
package com.prodhack.moscow2025.presentation.screens.main 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 com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import kotlinx.coroutines.flow.map
import org.koin.android.annotation.KoinViewModel import org.koin.android.annotation.KoinViewModel
@KoinViewModel @KoinViewModel
class MainScreenViewModel( class MainScreenViewModel(
// private val loadTasksUseCase: LoadTasksUseCase, loadResumeListUseCase: LoadResumeListUseCase
// private val loadTasksTopicsListUseCase: LoadTasksTopicListUseCase,
// private val setFinishedStateToTaskUseCase: SetFinishedStateToTaskUseCase,
// private val changeDeadlineUseCase: ChangeDeadlineUseCase
) : BaseViewModel() { ) : BaseViewModel() {
val resumeList = loadResumeListUseCase().map { it -> it.map { it.mapToBaseUIInfo() } }
// 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 <dateString>, 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<Long, Long>) {
// 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<Int?>(null)
//
// val topicList = MutableUIStateFlow<List<UITaskTopicModel>>()
//
// 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<Long, Job>()
//
// 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()
// }
} }
@@ -1,9 +1,239 @@
package com.prodhack.moscow2025.presentation.screens.profile package com.prodhack.moscow2025.presentation.screens.profile
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.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 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 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.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
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 @Composable
fun ProfileScreen(modifier: Modifier = Modifier) { 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()
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 = "Данные профиля обновлены",
style = SnackbarStyle.Success,
duration = SnackbarDuration.Short
)
}
}
LaunchedEffect(errorText) {
if (errorText.isNotEmpty()) {
snackbarHostState.showSnackbar(
message = "Ошибка: $errorText",
duration = SnackbarDuration.Short
)
}
}
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
)
) {
Text(
text = "Отменить",
style = typography.titleMedium,
fontSize = 24.sp
)
}
Spacer(Modifier.height(Paddings.medium))
}
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(Paddings.large))
}
TPhoneCountryList(
isSheetOpen = isSheetOpen,
sheetState = sheetState,
patternList = viewModel.phoneNumberPatterns,
setPattern = {
viewModel.chosenPattern.value = it
}
)
} }
@@ -0,0 +1,296 @@
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 android.util.Log
import androidx.compose.runtime.derivedStateOf
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.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel
data class ProfileState(
val email: String = "",
val firstName: String = "",
val lastName: String = "",
val phone: String = "",
val avatar: ByteArray? = null,
val errors: Map<AuthField, String> = emptyMap()
) {
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 logOutUseCase: LogOutUseCase,
private val getDefaultPhoneNumberPatternUseCase: GetDefaultPhoneNumberPatternUseCase,
galleryRepository: GalleryRepository
) : BaseViewModel() {
private val _formStateProfile = MutableStateFlow(ProfileState())
val formStateFillProfile: StateFlow<ProfileState> = _formStateProfile
private val _profileState = MutableUIStateFlow<String>()
val profileState: StateFlow<UIState<String>> = _profileState
val chosenPattern = mutableStateOf<UIPhoneNumberPattern?>(null)
val phoneNumberPatterns = mutableStateListOf<UIPhoneNumberPattern>()
private val realState = MutableStateFlow(_formStateProfile.value)
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 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 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) {
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
)
}
}
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 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
)
val errors = validation.errors.toMutableMap()
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 = formattedPhone
)
)
result.map { it.id }.collectRequest(_profileState)
update()
}
}
fun logout() {
viewModelScope.launch {
logOutUseCase()
}
}
fun update() {
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
} ?: getDefaultPhoneNumberPatternUseCase.execute()?.mapToUI()
?: 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 = 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()
}
}
}
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@@ -111,8 +112,8 @@ fun ErrorCollectorScope.RegisterScreen(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Image( Icon(
painter = painterResource(R.drawable.ic_launcher_foreground), painter = painterResource(R.drawable.app_logo),
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(200.dp) .size(200.dp)
@@ -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, "") ?: ""}")
}
@@ -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() {
}
@@ -3,6 +3,7 @@ package com.prodhack.moscow2025.presentation.theme
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
@@ -149,6 +150,10 @@ fun MoscowHackatonTemplateTheme(
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography, typography = Typography,
shapes = Shapes(
extraSmall = com.prodhack.moscow2025.presentation.theme.Shapes.verySmallRoundedBox,
small = com.prodhack.moscow2025.presentation.theme.Shapes.smallRoundedBox
),
content = content content = content
) )
} }
@@ -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 { 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 { override fun originalToTransformed(offset: Int): Int {
var noneDigitCount = 0 if (offset <= 0) return 0
var i = 0 var digitsSeen = 0
while (i < offset + noneDigitCount) { var index = 0
if (mask[i++] != numberChar) noneDigitCount++ val targetDigits = offset.coerceAtMost(maxDigits)
while (index < mask.length && digitsSeen < targetDigits) {
if (mask[index] == numberChar) {
digitsSeen++
} }
return offset + noneDigitCount index++
}
return index.coerceAtMost(transformedLength)
} }
override fun transformedToOriginal(offset: Int): Int = 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 { fun convertNumberToPattern(pattern: PhoneNumberPattern, number: String): String {
@@ -0,0 +1,34 @@
package com.prodhack.moscow2025.presentation.utils.ui
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarVisuals
enum class SnackbarStyle {
Success,
Error,
}
data class AppSnackbarVisuals(
override val message: String,
override val actionLabel: String? = null,
override val withDismissAction: Boolean = false,
override val duration: SnackbarDuration = SnackbarDuration.Short,
val style: SnackbarStyle = SnackbarStyle.Error
) : SnackbarVisuals
suspend fun SnackbarHostState.showSnackbar(
message: String,
style: SnackbarStyle,
actionLabel: String? = null,
withDismissAction: Boolean = false,
duration: SnackbarDuration = SnackbarDuration.Short
) = showSnackbar(
AppSnackbarVisuals(
message = message,
actionLabel = actionLabel,
withDismissAction = withDismissAction,
duration = duration,
style = style
)
)
+36
View File
@@ -0,0 +1,36 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="1024dp"
android:height="1024dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:fillColor="#FF000000"
android:pathData="M435.9,733L178.4,733C150.7,733 145.8,739.9 145.8,713.2L145.7,168.1C145.7,162.1 143.7,142.8 148.5,139C153.2,138.6 160.2,138.9 165.2,138.9L717.9,139C748.3,139 739.6,146.3 739.6,179L739.9,425.6C740,437.5 727.3,435.8 727.2,425.8L727.1,150.8L157.8,150.8L157.8,387.6L157.8,721L534,721C544.7,721.1 545.9,732.9 536.4,733C527.3,733.1 518.2,733 509,733L447.9,733C448.3,737.7 447.9,746.5 447.9,751.6L447.8,843.5C447.8,848.5 451.7,875.6 437.8,863.9C434.2,860.8 435.9,817.9 435.9,811.9L435.9,733Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M305.7,247.4C296.3,247.6 243.5,248.4 238.9,246.4C233,243.9 235.6,237.2 241.5,235.6C248.5,235.5 307.8,233.1 310,239C311.6,243.4 310,246.2 305.7,247.4Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M444.3,453.6C430,455.6 435.9,420.7 435.9,409L435.9,269.7C435.9,262.2 433.5,240 438.6,235.6C448.2,234.6 447.7,240.8 447.8,248.3C448,263.8 447.9,279.4 447.9,295L447.9,426.9C447.9,434.5 450.1,449.1 444.3,453.6Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M578.5,260.8C579.7,253.2 577,239.6 584.6,235.6C596,235 592.6,252.7 592.5,260.8C637.5,267.4 636.2,323.5 621.6,311.1C616.3,307 623.4,289.8 602.8,276.8C560.7,249.5 518.5,334.1 591.6,338C633.7,340.2 650.7,415 591,429C590.6,436.5 592.9,448.2 588.8,453.6C575,458.3 578.4,437.1 578.5,429C524.8,412.5 542.7,362.7 552.2,382.3C561.9,439.1 621.9,417.3 617.9,380C613.8,341.8 576.6,356.3 556.4,340.4C526.7,317 539.6,264.8 578.5,260.8Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M366.7,327L288,327C272.9,327 257.8,327.1 242.7,326.8C233.5,326.6 233.6,316.8 241.5,315.1L328.5,315.1C341.4,315.1 354.9,314.5 367.6,315.6C372.3,316 373.9,324.3 366.7,327Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M368.5,402.8C365.7,403.2 357.9,402.9 354.5,402.9L274,402.9C266.5,402.9 223.6,408.2 239,391C241.5,390.5 249.4,390.9 252.4,390.9L362.1,390.9C370.6,391.7 377.1,396.6 368.5,402.8Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M735.8,639.9C690.6,642 652.2,607.1 650,561.9C647.8,516.7 682.5,478.3 727.7,475.9C773.1,473.6 811.7,508.5 814,553.9C816.2,599.3 781.1,637.8 735.8,639.9ZM722.8,488.8C684.8,493.8 657.9,528.6 662.7,566.7C667.5,604.7 702.2,631.8 740.3,627.2C778.7,622.6 806,587.6 801.1,549.2C796.3,510.9 761.2,483.8 722.8,488.8Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M417.5,543.9C410.6,544.3 400.4,543.9 393.3,543.9L278.2,543.8C274.5,543.8 214.4,548.1 237.8,531.6C241.2,531.2 250.6,531.6 254.6,531.6L408.4,531.7C411.5,531.9 415.6,531.6 418.6,532.5C424,534.3 421.6,542 417.5,543.9Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M490.7,622.9C483.2,623.3 472.8,622.9 465.2,622.9L298.3,622.9C289.2,622.9 241.4,624 236.6,622.2C231.2,620.2 232.8,612.3 237.8,610.7L468.1,610.7C474.9,610.7 483.9,610.5 490.8,611.3C497.2,612.8 495.8,620.4 490.7,622.9Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M846.8,865L618.9,865C582.7,865 586.5,871.1 586.5,835.6L586.5,735.1C586.5,705.6 584.3,678.1 619.9,671.4C620.4,671.4 620.8,671.4 621.3,671.4L824.8,671.4C866.1,671.4 879.5,678.8 879.5,722.6L879.5,849.1C879.5,871.9 870.2,865.1 846.8,865ZM624.3,683.5C598,686.4 599,702.8 599,723.4L599,852.7L649.6,852.7L649.6,797.6C649.6,791.9 647.3,769.9 651.8,766.3C669.7,752 661.9,844.6 661.9,852.7L804.5,852.7L804.5,817.6C804.5,803 804,786.4 805.1,771.8C805.5,765.5 816.5,766.1 816.8,774C817,782.9 816.9,791.7 816.9,800.6L816.9,852.7L867.5,852.7L867.5,723.8C867.5,686 858.6,683.4 823.1,683.5L624.3,683.5Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M8.512,4.43C8.663,4.301 8.859,4.237 9.058,4.252C9.256,4.268 9.44,4.361 9.569,4.512L15.569,11.512C15.686,11.648 15.75,11.821 15.75,12C15.75,12.179 15.686,12.352 15.569,12.488L9.569,19.488C9.438,19.632 9.255,19.718 9.061,19.73C8.867,19.742 8.676,19.677 8.528,19.551C8.38,19.424 8.287,19.245 8.269,19.051C8.251,18.857 8.309,18.664 8.431,18.512L14.012,12L8.431,5.488C8.302,5.337 8.237,5.141 8.252,4.943C8.267,4.745 8.36,4.561 8.511,4.431"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</vector>
+1 -1
View File
@@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">MoscowHackatonTemplate</string> <string name="app_name">Rekomenci fluon</string>
</resources> </resources>