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
# hide the original source file name.
#-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,
"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')"
]
}
}
@@ -28,9 +28,7 @@ class App : Application() {
androidContext(this@App)
analytics()
modules(
listOf(
AppModules().module
)
AppModules().module
)
}
FirebaseApp.initializeApp(this@App)
@@ -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/"
}
@@ -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
/**
@@ -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()
}
@@ -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
@OptIn(ExperimentalPagingApi::class)
class BaseRemoteMediator<DBEntity : BaseEntity>(
class BaseRemoteMediator<DBEntity : Any>(
private val db: RoomDatabase,
private val dao: BasePaginationDAO<DBEntity>,
private val makeRequest: suspend (page: Long, pageCount: Int) -> Result<List<DBEntity>>
@@ -26,17 +26,12 @@ class BaseRemoteMediator<DBEntity : BaseEntity>(
)
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<DBEntity : BaseEntity>(
if (loadType == LoadType.REFRESH) {
dao.clearAll()
}
val beerEntities = data
dao.upsertAll(beerEntities)
dao.upsertAll(data)
}
MediatorResult.Success(
endOfPaginationReached = data.size < state.config.pageSize
@@ -19,157 +19,157 @@ import kotlin.time.Duration
abstract class BaseRepository {
// Caching module ==============================================================================
private val internalCacheStorage = mutableMapOf<String, CacheEntry<*>>()
// Caching module ==============================================================================
private val internalCacheStorage = mutableMapOf<String, CacheEntry<*>>()
private data class CacheEntry<T>(
val value: T,
val expirationTime: Long
)
private data class CacheEntry<T>(
val value: T,
val expirationTime: Long
)
fun <T> putCache(cacheConfiguration: Pair<String, Duration>, value: T) {
internalCacheStorage[cacheConfiguration.first] =
CacheEntry(value, cacheConfiguration.second.inWholeSeconds)
}
fun <T> putCache(cacheConfiguration: Pair<String, Duration>, value: T) {
internalCacheStorage[cacheConfiguration.first] =
CacheEntry(value, cacheConfiguration.second.inWholeSeconds)
}
@Suppress("UNCHECKED_CAST")
fun <T> 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 <T> 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 <reified T> networkRequest(
ktorClient: HttpClient? = this.defaultKtorClient,
cacheConfiguration: Pair<String, Duration>? = null,
block: HttpRequestBuilder.() -> Unit
): Result<T> {
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 <reified T> networkRequest(
ktorClient: HttpClient? = this.defaultKtorClient,
cacheConfiguration: Pair<String, Duration>? = null,
block: HttpRequestBuilder.() -> Unit
): Result<T> {
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<T>()
}
} 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<T>()
}
} 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 <reified T> internalCachedRequest(
ktorClient: HttpClient? = this.defaultKtorClient,
cacheConfiguration: Pair<String, Duration>,
block: HttpRequestBuilder.() -> Unit
): Result<T> {
val cachedResult = getCache<T>(cacheConfiguration.first)
internal suspend inline fun <reified T> internalCachedRequest(
ktorClient: HttpClient? = this.defaultKtorClient,
cacheConfiguration: Pair<String, Duration>,
block: HttpRequestBuilder.() -> Unit
): Result<T> {
val cachedResult = getCache<T>(cacheConfiguration.first)
return if (cachedResult != null) {
Result.success(cachedResult)
} else {
networkRequest<T>(ktorClient, cacheConfiguration, block)
}
}
return if (cachedResult != null) {
Result.success(cachedResult)
} else {
networkRequest<T>(ktorClient, cacheConfiguration, block)
}
}
@OptIn(ExperimentalPagingApi::class)
protected fun <Value : BaseEntity> 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<Value>,
makeRequest: suspend (page: Long, pageSize: Int) -> Result<List<Value>>
): Flow<PagingData<Value>> {
assertDBSpecify()
@OptIn(ExperimentalPagingApi::class)
protected fun <Value : Any> 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<Value>,
makeRequest: suspend (offset: Long, pageSize: Int) -> Result<List<Value>>
): Flow<PagingData<Value>> {
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
}
}
@@ -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.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
}
@@ -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()
}
@@ -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.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(
@@ -58,28 +31,3 @@ 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
)
}
@@ -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
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 {
@@ -24,6 +24,25 @@ data class ValidationResult(
@Single
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(
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() {
private companion object {
const val TAG = "MainActivity"
}
private val checkSessionUseCase: CheckSessionUseCase by inject()
private val sessionDestinationState = MutableStateFlow<AppDestination?>(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)
}
}
@@ -4,6 +4,8 @@ import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -59,18 +61,16 @@ fun TBottomNavigation(modifier: Modifier = Modifier, selectedPage: Int, onSelect
}
target?.let { (it - center).toDp() }
}
AnimatedVisibility(indicatorOffset != null) {
indicatorOffset?.let {
Box(
modifier = Modifier
.size(85.dp, 45.dp)
.offset(x = animateDpAsState(it).value)
.background(
MaterialTheme.colorScheme.primary,
shape = Shapes.smallRoundedBox
)
)
}
indicatorOffset?.let {
Box(
modifier = Modifier
.size(85.dp, 45.dp)
.offset(x = animateDpAsState(it).value)
.background(
MaterialTheme.colorScheme.primary,
shape = Shapes.smallRoundedBox
)
)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Icon(
@@ -0,0 +1,158 @@
package com.prodhack.moscow2025.presentation.components.standart
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.presentation.screens.fillProfile.UIPhoneNumberPattern
import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes
import com.prodhack.moscow2025.presentation.utils.PhoneVisualTransformation
@Composable
fun TPhoneField(
modifier: Modifier = Modifier,
currentPattern: UIPhoneNumberPattern?,
currentPhone: String,
onPhoneChange: (String) -> Unit,
error: String?,
onOpenCountryList: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = modifier.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(
Paddings.medium
)
) {
FieldWrapper(
modifier = Modifier
.width(IntrinsicSize.Min)
.fillMaxHeight()
) {
BasicTextField(
modifier = Modifier
.fillMaxSize()
.offset(y = 5.dp)
.padding(bottom = 16.dp)
.background(colorScheme.primary, Shapes.smallRoundedBox)
.clip(Shapes.smallRoundedBox),
value = currentPattern?.prefix ?: "",
onValueChange = {},
readOnly = true,
textStyle = TextStyle(
color = colorScheme.onPrimary
),
decorationBox = { innerTextField ->
Row(
modifier = Modifier
.clickable {
onOpenCountryList()
}
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.weight(1f)) {
innerTextField()
}
Icon(
modifier = Modifier.size(15.dp),
painter = painterResource(R.drawable.ic_arr_dropdown),
tint = colorScheme.onPrimary,
contentDescription = null
)
}
}
)
}
TTTextField(
value = currentPhone,
onValueChange = onPhoneChange,
label = "Ваш телефон",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone
),
visualTransformation = currentPattern?.let {
PhoneVisualTransformation(
it.pattern,
'0'
)
} ?: VisualTransformation.None,
error = error
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TPhoneCountryList(
modifier: Modifier = Modifier,
isSheetOpen: MutableState<Boolean>,
sheetState: SheetState,
patternList: List<UIPhoneNumberPattern>,
setPattern: (UIPhoneNumberPattern) -> Unit
) {
if (isSheetOpen.value) {
ModalBottomSheet(
modifier = modifier,
sheetState = sheetState,
onDismissRequest = {
isSheetOpen.value = false
},
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(patternList) { pattern ->
Text(
text = pattern.name,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
.clickable {
setPattern(pattern)
isSheetOpen.value = false
}
)
}
}
}
}
}
}
@@ -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
)
}
}
@@ -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() ?: "Ошибка"
)
@@ -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"
}
}
@@ -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(
@@ -26,7 +28,7 @@ fun TTasksApp(
context: Context,
sessionDestination: AppDestination? = null
) {
MoscowHackatonTemplateTheme() {
MoscowHackatonTemplateTheme {
val snackbarHostState = remember { SnackbarHostState() }
val bottomBarState = remember { mutableStateOf<Int?>(null) }
@@ -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
)
}
@@ -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,27 +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()
}
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)
}
}
}
}
@@ -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.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
@@ -144,11 +145,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))
@@ -166,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(
@@ -242,32 +188,12 @@ fun ErrorCollectorScope.FillProfileScreen(
}
}
if (isSheetOpen.value) {
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = {
isSheetOpen.value = false
},
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(viewModel.phoneNumberPatterns) { pattern ->
Text(
text = pattern.name,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
.clickable {
viewModel.chosenPattern.value = pattern
isSheetOpen.value = false
}
)
}
}
}
TPhoneCountryList(
isSheetOpen = isSheetOpen,
sheetState = sheetState,
patternList = viewModel.phoneNumberPatterns,
setPattern = {
viewModel.currentPattern.value = it
}
}
)
}
@@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel
import org.koin.core.annotation.Single
data class FillProfileFormState(
val firstName: String = "",
@@ -95,9 +94,11 @@ class FillProfileViewModel(
}
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 {
it.copy(
phone = value,
phone = digits,
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>()
fun update() {
// Load default pattern
chosenPattern.value = getDefaultPhoneNumberPatternUseCase.execute()?.mapToUI()
currentPattern.value = getDefaultPhoneNumberPatternUseCase.execute()?.mapToUI()
// Load all phone number patterns
phoneNumberPatterns.clear()
@@ -171,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) {
@@ -185,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
@@ -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)
@@ -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
@@ -12,271 +42,140 @@ import org.koin.androidx.compose.koinViewModel
@Composable
fun ErrorCollectorScope.MainScreen(
modifier: Modifier = Modifier,
openResumeDetails: (String) -> Unit,
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<UITaskModel?>(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) {
items[it]?.let { resume ->
ResumeShortInfoCard(info = resume) {
openResumeDetails(resume.id)
}
}
}
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"
)
}
}
}
@@ -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 <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()
// }
val resumeList = loadResumeListUseCase().map { it -> it.map { it.mapToBaseUIInfo() } }
}
@@ -1,9 +1,239 @@
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.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.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
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.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)
@@ -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 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
)
}
@@ -29,7 +29,19 @@ class PhoneVisualTransformation(val mask: String, val maskNumber: Char) : Visual
}
}
return TransformedText(annotatedString, PhoneOffsetMapper(mask, maskNumber))
if (annotatedString.isEmpty()) {
return TransformedText(annotatedString, OffsetMapping.Identity)
}
return TransformedText(
annotatedString,
PhoneOffsetMapper(
mask = mask,
numberChar = maskNumber,
transformedLength = annotatedString.length,
maxDigits = trimmed.length
)
)
}
override fun equals(other: Any?): Boolean {
@@ -45,19 +57,30 @@ class PhoneVisualTransformation(val mask: String, val maskNumber: Char) : Visual
}
}
private class PhoneOffsetMapper(val mask: String, val numberChar: Char) : OffsetMapping {
private class PhoneOffsetMapper(
val mask: String,
val numberChar: Char,
private val transformedLength: Int,
private val maxDigits: Int
) : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
var noneDigitCount = 0
var i = 0
while (i < offset + noneDigitCount) {
if (mask[i++] != numberChar) noneDigitCount++
if (offset <= 0) return 0
var digitsSeen = 0
var index = 0
val targetDigits = offset.coerceAtMost(maxDigits)
while (index < mask.length && digitsSeen < targetDigits) {
if (mask[index] == numberChar) {
digitsSeen++
}
index++
}
return offset + noneDigitCount
return index.coerceAtMost(transformedLength)
}
override fun transformedToOriginal(offset: Int): Int =
offset - mask.take(offset).count { it != numberChar }
mask.take(offset.coerceAtMost(transformedLength)).count { it == numberChar }
}
fun convertNumberToPattern(pattern: PhoneNumberPattern, number: String): String {
@@ -0,0 +1,34 @@
package com.prodhack.moscow2025.presentation.utils.ui
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarVisuals
enum class SnackbarStyle {
Success,
Error,
}
data class AppSnackbarVisuals(
override val message: String,
override val actionLabel: String? = null,
override val withDismissAction: Boolean = false,
override val duration: SnackbarDuration = SnackbarDuration.Short,
val style: SnackbarStyle = SnackbarStyle.Error
) : SnackbarVisuals
suspend fun SnackbarHostState.showSnackbar(
message: String,
style: SnackbarStyle,
actionLabel: String? = null,
withDismissAction: Boolean = false,
duration: SnackbarDuration = SnackbarDuration.Short
) = showSnackbar(
AppSnackbarVisuals(
message = message,
actionLabel = actionLabel,
withDismissAction = withDismissAction,
duration = duration,
style = style
)
)
+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>
<string name="app_name">MoscowHackatonTemplate</string>
<string name="app_name">Rekomenci fluon</string>
</resources>