feat: main screen implemented

This commit is contained in:
MaximOksiuta
2025-11-22 04:53:03 +03:00
parent 82e5066950
commit 09ff18cb02
36 changed files with 812 additions and 644 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,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 {
@@ -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)
}
}
@@ -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() ?: "Ошибка"
)
@@ -28,7 +28,7 @@ fun TTasksApp(
context: Context,
sessionDestination: AppDestination? = null
) {
MoscowHackatonTemplateTheme() {
MoscowHackatonTemplateTheme {
val snackbarHostState = remember { SnackbarHostState() }
val bottomBarState = remember { mutableStateOf<Int?>(null) }
@@ -144,11 +144,10 @@ fun ErrorCollectorScope.FillProfileScreen(
style = typography.titleLarge,
fontSize = 31.sp
)
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
Icon(
painter = painterResource(R.drawable.app_logo),
contentDescription = null,
modifier = Modifier.size(140.dp),
contentScale = ContentScale.Crop
modifier = Modifier.size(140.dp)
)
}
Spacer(Modifier.height(20.dp))
@@ -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
@@ -14,269 +44,138 @@ fun ErrorCollectorScope.MainScreen(
modifier: Modifier = Modifier,
viewModel: MainScreenViewModel = koinViewModel()
) {
Text("Main screen will be here soon")
// val openCalendarModal = remember { mutableStateOf(false) }
// val openTaskAddSheet = remember { mutableStateOf(false) }
// val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// val tasks = viewModel.taskList.collectAsLazyPagingItems()
//
// val selectedTask = remember { mutableStateOf<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) {
val resume = items[it]
resume?.let {
ResumeShortInfoCard(info = it) {
}
}
}
item {
if (items.loadState.append.endOfPaginationReached.not()) {
CircularProgressIndicator()
}
}
}
}
}
val context = LocalContext.current
TTFloatingActionButton(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = Paddings.medium),
onClick = {
Toast.makeText(context, "Will be soon...", Toast.LENGTH_SHORT).show()
},
text = "Добавить резюме"
)
}
}
@Composable
fun ResumeShortInfoCard(
modifier: Modifier = Modifier,
info: UIResumeBaseInfo,
onClick: () -> Unit
) {
val typography = MaterialTheme.typography
Card(
modifier = modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.small,
onClick = onClick
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(Paddings.medium),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
Text(info.positionName, style = typography.labelLarge, fontSize = 20.sp)
Row {
Text(
"Ожидаемая ЗП: ",
style = typography.labelLarge,
fontSize = 18.sp
)
Text(
"${info.salary}",
style = typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontSize = 18.sp
)
}
}
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(R.drawable.ic_arr_details),
contentDescription = "Open details"
)
}
}
}
@@ -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() } }
}
@@ -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)
@@ -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
)
}
+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>