Initial with template

This commit is contained in:
MaximOksiuta
2025-11-21 13:19:14 +03:00
commit d710525123
142 changed files with 6343 additions and 0 deletions
@@ -0,0 +1,24 @@
package com.prodhack.moscow2025
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.prodhack.moscow2025", appContext.packageName)
}
}
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".common.App"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MoscowHackatonTemplate">
<activity
android:name=".presentation.MainActivity"
android:exported="true"
android:theme="@style/Theme.MoscowHackatonTemplate">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".FirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="default_channel_id" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_launcher_background" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/purple_200" />
</application>
</manifest>
+1
View File
@@ -0,0 +1 @@
Omt0ei1zZGstcndzQUJmNlltQ0s0UDcwbDdkcnRqT2FXRWo4czlXUnUxN1Z1bFd5cmZjTQ==
@@ -0,0 +1,81 @@
package com.prodhack.moscow2025
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.prodhack.moscow2025.presentation.MainActivity
class FirebaseMessagingService : FirebaseMessagingService() {
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onMessageReceived(message: RemoteMessage) {
val title = message.data["title"]
val text = message.data["body"]
Log.e(
"fcm",
"title=$title" +
"\ntext=$text"
)
title?.let { it1 -> text?.let { it2 -> sendNotification(it1, it2) } }
}
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.e("fcm", "token=$token")
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
"default_channel_id",
"MainAppNotifications",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Channel for main motifications"
enableLights(true)
lightColor = Color.Red.toArgb()
enableVibration(true)
vibrationPattern = longArrayOf(0, 500, 200, 500)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
}
val notificationManager =
getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
private fun sendNotification(title: String?, messageBody: String?) {
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val notificationBuilder = NotificationCompat.Builder(this, "default_channel_id")
.setSmallIcon(R.drawable.ic_launcher_background) // замените на свою иконку
.setContentTitle(title ?: "Уведомление")
.setContentText(messageBody)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
val notificationManager =
getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(0, notificationBuilder.build())
}
}
@@ -0,0 +1,68 @@
package com.prodhack.moscow2025.common
import android.app.Application
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import com.google.firebase.FirebaseApp
import com.prodhack.moscow2025.common.di.AppModule
import com.prodhack.moscow2025.common.di.DataModule
import com.prodhack.moscow2025.common.di.DomainModule
import com.prodhack.moscow2025.data.data_providers.local_db.DatabaseProvider
import io.kotzilla.sdk.analytics.koin.analytics
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
import org.koin.ksp.generated.module
class App : Application() {
companion object {
lateinit var instance: Application
lateinit var version: String
}
override fun onCreate() {
super.onCreate()
instance = this
version = getAppVersion()
startKoin {
androidContext(this@App)
analytics()
modules(
listOf(
AppModule().module,
DataModule().module,
DomainModule().module,
DatabaseProvider().module
)
)
}
FirebaseApp.initializeApp(this@App)
}
private fun getAppVersion(): String {
var pInfo: PackageInfo? = null
try {
val pm = packageManager
if (pm != null) {
pInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
} else {
pm.getPackageInfo(packageName, 0)
}
}
} catch (e: Exception) {
Log.d("App", "method: getAppVersion - error: $e")
}
if (pInfo == null) {
pInfo = PackageInfo()
pInfo.versionName = "0.0.0"
pInfo.longVersionCode = 0
}
var version = pInfo.versionName + "."
version += pInfo.longVersionCode
return version
}
}
@@ -0,0 +1,5 @@
package com.prodhack.moscow2025.common
object Constants {
const val BASE_API_URL = "https://hackaton.paas.itqdev.xyz/"
}
@@ -0,0 +1,16 @@
package com.prodhack.moscow2025.common.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
@Module
@ComponentScan("com.prodhack.moscow2025.presentation")
class AppModule
@Module
@ComponentScan("com.prodhack.moscow2025.domain")
class DomainModule
@Module
@ComponentScan("com.prodhack.moscow2025.data")
class DataModule
@@ -0,0 +1,6 @@
package com.prodhack.moscow2025.data.base
interface BaseEntity {
val id: Number
}
@@ -0,0 +1,10 @@
package com.prodhack.moscow2025.data.base
import androidx.paging.PagingSource
interface BasePaginationDAO<T : Any> {
suspend fun clearAll()
suspend fun upsertAll(data: List<T>)
fun getPaginatedData(): PagingSource<Int, T>
}
@@ -0,0 +1,62 @@
package com.prodhack.moscow2025.data.base
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.RoomDatabase
import androidx.room.withTransaction
@OptIn(ExperimentalPagingApi::class)
class BaseRemoteMediator<DBEntity : BaseEntity>(
private val db: RoomDatabase,
private val dao: BasePaginationDAO<DBEntity>,
private val makeRequest: suspend (page: Long, pageCount: Int) -> Result<List<DBEntity>>
) : RemoteMediator<Int, DBEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, DBEntity>
): MediatorResult {
return try {
val loadKey = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(
endOfPaginationReached = true
)
LoadType.APPEND -> {
val lastItem = state.lastItemOrNull()
if (lastItem == null) {
1
} else {
(lastItem.id.toLong() / state.config.pageSize) + 1
}
}
}
val result = makeRequest(
loadKey,
state.config.pageSize
)
if (result.isSuccess) {
val data = result.getOrNull()!!
db.withTransaction {
if (loadType == LoadType.REFRESH) {
dao.clearAll()
}
val beerEntities = data
dao.upsertAll(beerEntities)
}
MediatorResult.Success(
endOfPaginationReached = data.size < state.config.pageSize
)
} else {
MediatorResult.Error(result.exceptionOrNull()!!)
}
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
}
@@ -0,0 +1,175 @@
package com.prodhack.moscow2025.data.base
import android.util.Log
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.room.RoomDatabase
import com.prodhack.moscow2025.data.dto.ErrorNetworkDTO
import com.prodhack.moscow2025.domain.utils.NetworkError
import com.prodhack.moscow2025.domain.utils.convertToNetworkError
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.request
import io.ktor.http.isSuccess
import kotlinx.coroutines.flow.Flow
import kotlin.time.Duration
abstract class BaseRepository {
// Caching module ==============================================================================
private val internalCacheStorage = mutableMapOf<String, CacheEntry<*>>()
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)
}
@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 ===========================================================================
protected open val defaultKtorClient: HttpClient? = null
protected open val db: RoomDatabase? = null
companion object {
private const val TAG = "BaseRepository"
}
// 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 assertDBSpecify() {
if (db == null) {
throw IllegalStateException("You must specify db for use pagination/cashing")
}
}
// 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")
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)
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()
return Pager(
config = PagingConfig(
pageSize,
prefetchDistance,
enablePlaceholders,
initialLoadSize,
maxSize,
jumpThreshold
),
remoteMediator = BaseRemoteMediator(
db = db!!,
dao = dbDao,
makeRequest = makeRequest
),
pagingSourceFactory = {
dbDao.getPaginatedData()
}
).flow
}
}
@@ -0,0 +1,5 @@
package com.prodhack.moscow2025.data.base
interface DBMappableDTO <T> {
fun mapToDB(): T
}
@@ -0,0 +1,5 @@
package com.prodhack.moscow2025.data.base
interface DomainMappableDTO <T> {
fun mapToDomain(): T
}
@@ -0,0 +1,60 @@
package com.prodhack.moscow2025.data.data_providers
import android.content.ContentResolver
import android.provider.MediaStore
import androidx.paging.PagingSource
import androidx.paging.PagingState
class GalleryPagingSource(
private val contentResolver: ContentResolver
) : PagingSource<Int, Long>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Long> {
return try {
val page = params.key ?: 0
val pageSize = params.loadSize
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DATE_ADDED
)
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val cursor = contentResolver.query(
uri,
projection,
null,
null,
"$sortOrder LIMIT $pageSize OFFSET ${page * pageSize}"
)
val images = mutableListOf<Long>()
cursor?.use {
val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
while (it.moveToNext()) {
images.add(it.getLong(idColumn))
}
}
val nextKey = if (images.size < pageSize) null else page + 1
LoadResult.Page(
data = images,
prevKey = if (page == 0) null else page - 1,
nextKey = nextKey
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, Long>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
}
@@ -0,0 +1,79 @@
package com.prodhack.moscow2025.data.data_providers.api
import com.prodhack.moscow2025.common.Constants
import com.prodhack.moscow2025.data.data_providers.localInfo.AuthorizationDataStore
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.ANDROID
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Single
// Configuration Ktor client for request to API
@Single
class ApiKtorClient(authorizationDataStore: AuthorizationDataStore) {
val client = HttpClient(OkHttp) {
install(Logging) {
logger = Logger.ANDROID
level = LogLevel.ALL
}
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 3)
exponentialDelay()
}
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
defaultRequest {
url(Constants.BASE_API_URL)
}
install(Auth) {
bearer {
sendWithoutRequest { request ->
val segments = request.url.pathSegments
val endpointsWithoutAuth = listOf(
"sign_in",
"sign_up"
)
endpointsWithoutAuth.any { segments.contains(it) }.not()
}
loadTokens {
return@loadTokens authorizationDataStore.token.first()
.toBearerTokens()
}
refreshTokens {
CoroutineScope(Dispatchers.IO).launch {
authorizationDataStore.clearToken()
}
return@refreshTokens null
}
}
}
}
private fun String.toBearerTokens(): BearerTokens {
return BearerTokens(this, null)
}
}
@@ -0,0 +1,12 @@
package com.prodhack.moscow2025.data.data_providers.api.utils
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
internal fun String.parseToTimestamp(): Long? {
val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
dateFormatter.timeZone = TimeZone.getTimeZone("UTC")
return dateFormatter.parse(this)?.time
}
@@ -0,0 +1,55 @@
package com.prodhack.moscow2025.data.data_providers.localInfo
import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.io.IOException
import org.koin.core.annotation.Single
@Single
class AuthorizationDataStore(
context: Context
) {
private val Context.dataStore by preferencesDataStore(
name = "authTokens"
)
private val dataStore = context.dataStore
private companion object {
const val TAG = "AuthorizationDataStore"
val ACCESS_TOKEN = stringPreferencesKey("accessToken")
}
suspend fun saveToken(accessToken: String) {
dataStore.edit { preferences ->
preferences[ACCESS_TOKEN] = accessToken
}
}
suspend fun clearToken() {
dataStore.edit { preferences ->
preferences[ACCESS_TOKEN] = ""
}
}
val token
get() = dataStore.data
.catch {
Log.e(TAG, "Error reading preferences.", it)
if (it is IOException) {
Log.e(TAG, "return empty prefs")
emit(emptyPreferences())
} else {
throw it
}
}.map { preferences ->
preferences[ACCESS_TOKEN] ?: ""
}
}
@@ -0,0 +1,18 @@
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.UserDao
import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity
@Database(
entities = [UserEntity::class],
version = 1,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
abstract fun cleanUpDao(): CleanUpDao
}
@@ -0,0 +1,19 @@
package com.prodhack.moscow2025.data.data_providers.local_db
import android.content.Context
import androidx.room.Room
import org.koin.core.annotation.Module
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,17 @@
package com.prodhack.moscow2025.data.data_providers.local_db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
@Dao
interface CleanUpDao {
@Query("DELETE FROM users")
suspend fun cleanUpUsers()
@Transaction
suspend fun cleanUp() {
cleanUpUsers()
}
}
@@ -0,0 +1,24 @@
package com.prodhack.moscow2025.data.data_providers.local_db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface UserDao {
@Query("SELECT * FROM users LIMIT 1")
fun observeUser(): Flow<UserEntity?>
@Query("SELECT * FROM users LIMIT 1")
suspend fun getUser(): UserEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(user: UserEntity)
@Query("DELETE FROM users")
suspend fun clear()
}
@@ -0,0 +1,44 @@
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.User
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey(autoGenerate = false)
val id: String,
val email: String,
@ColumnInfo(name = "first_name")
val firstName: String?,
@ColumnInfo(name = "last_name")
val lastName: String?,
@ColumnInfo(name = "display_name")
val displayName: String?,
@ColumnInfo(name = "avatar_url")
val avatarUrl: String?,
val phone: String?
) {
fun mapToDomain(): User {
return User(
id = id,
firstName = firstName,
lastName = lastName,
displayName = displayName,
email = email,
avatarUrl = avatarUrl,
phone = phone
)
}
}
fun User.mapToDB(): UserEntity = UserEntity(
id = id,
firstName = firstName,
lastName = lastName,
displayName = displayName,
phone = phone,
email = email,
avatarUrl = avatarUrl
)
@@ -0,0 +1,85 @@
package com.prodhack.moscow2025.data.dto
import com.prodhack.moscow2025.domain.models.LoginData
import com.prodhack.moscow2025.domain.models.RegisterData
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 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(
val email: String,
val password: String
)
fun LoginData.mapToData(): UserLoginRequest = UserLoginRequest(email, password)
@Serializable
data class UserRegisterRequest(
val email: String,
val password: String
)
fun RegisterData.mapToData(): UserRegisterRequest = UserRegisterRequest(email, password)
@Serializable
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,60 @@
package com.prodhack.moscow2025.data.repImplementations
import com.prodhack.moscow2025.data.base.BaseRepository
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
import com.prodhack.moscow2025.data.data_providers.localInfo.AuthorizationDataStore
import com.prodhack.moscow2025.data.dto.TokenResponse
import com.prodhack.moscow2025.data.dto.mapToData
import com.prodhack.moscow2025.domain.interfaces.AuthRepository
import com.prodhack.moscow2025.domain.models.LoginData
import com.prodhack.moscow2025.domain.models.RegisterData
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.contentType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Single
@Single
class AuthRepositoryImpl(
ktorClient: ApiKtorClient,
private val authorizationDataStore: AuthorizationDataStore
) : AuthRepository, BaseRepository() {
override val defaultKtorClient = ktorClient.client
override fun fetchLoginState(): Flow<Boolean> =
authorizationDataStore.token.map { it.isNotBlank() }
override suspend fun signUpRequest(request: RegisterData): Result<String> =
networkRequest<TokenResponse> {
url {
method = HttpMethod.Post
url("/auth/sign_up/email")
setBody(request.mapToData())
contentType(ContentType.Application.Json)
}
}.map {
authorizationDataStore.saveToken(it.token)
"Success"
}
override suspend fun signInRequest(request: LoginData): Result<String> =
networkRequest<TokenResponse> {
url {
method = HttpMethod.Post
url("/auth/sign_up/email")
setBody(request.mapToData())
contentType(ContentType.Application.Json)
}
}.map {
authorizationDataStore.saveToken(it.token)
"Success"
}
override suspend fun clearLoginData() {
authorizationDataStore.clearToken()
}
}
@@ -0,0 +1,23 @@
package com.prodhack.moscow2025.data.repImplementations
import android.app.Application
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.prodhack.moscow2025.data.data_providers.GalleryPagingSource
import com.prodhack.moscow2025.domain.interfaces.GalleryRepository
import kotlinx.coroutines.flow.Flow
import org.koin.core.annotation.Single
@Single
class GalleryRepositoryImpl(private val application: Application) : GalleryRepository {
override fun getImagesIds(): Flow<PagingData<Long>> = Pager(
config = PagingConfig(
pageSize = 50,
enablePlaceholders = false
),
pagingSourceFactory = {
GalleryPagingSource(application.contentResolver)
}
).flow
}
@@ -0,0 +1,75 @@
package com.prodhack.moscow2025.data.repImplementations
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.data_providers.local_db.entities.mapToDB
import com.prodhack.moscow2025.data.dto.UserResponse
import com.prodhack.moscow2025.data.dto.mapToData
import com.prodhack.moscow2025.domain.interfaces.UserRepository
import com.prodhack.moscow2025.domain.models.UpdateUserData
import com.prodhack.moscow2025.domain.models.User
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.contentType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
@Single
class UserRepositoryImpl(
ktorClient: ApiKtorClient,
override val db: AppDatabase
) : UserRepository, BaseRepository() {
override val defaultKtorClient = ktorClient.client
private val userDao = db.userDao()
override fun observeUser(): Flow<User?> {
CoroutineScope(Dispatchers.IO).launch {
fetchProfile()
}
return userDao.observeUser().map {
it?.mapToDomain()
}
}
private suspend fun writeProfileToDB(data: User) {
userDao.upsert(data.mapToDB())
}
override suspend fun fetchProfile(): Result<User> = networkRequest<UserResponse> {
url {
method = HttpMethod.Get
url("/profile")
}
}.map {
it.mapToDomain().also {
writeProfileToDB(it)
}
}
override suspend fun updateProfile(request: UpdateUserData): Result<User> {
return networkRequest<UserResponse> {
url {
method = HttpMethod.Patch
url("/profile")
setBody(request.mapToData())
contentType(ContentType.Application.Json)
}
}.map {
it.mapToDomain().also {
writeProfileToDB(it)
}
}
}
override suspend fun clearLocalUserData() {
userDao.clear()
}
}
@@ -0,0 +1,15 @@
package com.prodhack.moscow2025.domain.interfaces
import com.prodhack.moscow2025.domain.models.LoginData
import com.prodhack.moscow2025.domain.models.RegisterData
import kotlinx.coroutines.flow.Flow
interface AuthRepository {
fun fetchLoginState(): Flow<Boolean>
suspend fun signUpRequest(request: RegisterData): Result<String>
suspend fun signInRequest(request: LoginData): Result<String>
suspend fun clearLoginData()
}
@@ -0,0 +1,8 @@
package com.prodhack.moscow2025.domain.interfaces
import androidx.paging.PagingData
import kotlinx.coroutines.flow.Flow
interface GalleryRepository {
fun getImagesIds(): Flow<PagingData<Long>>
}
@@ -0,0 +1,15 @@
package com.prodhack.moscow2025.domain.interfaces
import com.prodhack.moscow2025.domain.models.UpdateUserData
import com.prodhack.moscow2025.domain.models.User
import kotlinx.coroutines.flow.Flow
interface UserRepository {
fun observeUser(): Flow<User?>
suspend fun fetchProfile(): Result<User>
suspend fun updateProfile(request: UpdateUserData): Result<User>
suspend fun clearLocalUserData()
}
@@ -0,0 +1,11 @@
package com.prodhack.moscow2025.domain.models
data class RegisterData(
val email: String,
val password: String
)
data class LoginData(
val email: String,
val password: String
)
@@ -0,0 +1,20 @@
package com.prodhack.moscow2025.domain.models
data class User(
val id: String,
val email: String,
val displayName: String?,
val firstName: String?,
val lastName: String?,
val avatarUrl: String?,
val phone: String?
)
data class UpdateUserData(
val email: String? = null,
val displayName: String? = null,
val firstName: String? = null,
val lastName: String? = null,
val avatarUrl: String? = null,
val phone: String? = null
)
@@ -0,0 +1,15 @@
package com.prodhack.moscow2025.domain.usecase.auth
import com.prodhack.moscow2025.domain.interfaces.AuthRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import org.koin.core.annotation.Single
@Single
class CheckSessionUseCase(
private val authRepository: AuthRepository
) {
operator suspend fun invoke(): Boolean {
return authRepository.fetchLoginState().firstOrNull() == true
}
}
@@ -0,0 +1,12 @@
package com.prodhack.moscow2025.domain.usecase.auth
import com.prodhack.moscow2025.domain.models.User
import com.prodhack.moscow2025.domain.interfaces.UserRepository
import org.koin.core.annotation.Single
@Single
class GetUserUseCase(
private val userRepository: UserRepository
) {
suspend operator fun invoke(): Result<User> = userRepository.fetchProfile()
}
@@ -0,0 +1,16 @@
package com.prodhack.moscow2025.domain.usecase.auth
import com.prodhack.moscow2025.domain.interfaces.AuthRepository
import com.prodhack.moscow2025.domain.interfaces.UserRepository
import org.koin.core.annotation.Single
@Single
class LogOutUseCase(
private val authRepository: AuthRepository,
private val userRepository: UserRepository
) {
suspend operator fun invoke() {
authRepository.clearLoginData()
userRepository.clearLocalUserData()
}
}
@@ -0,0 +1,14 @@
package com.prodhack.moscow2025.domain.usecase.auth
import com.prodhack.moscow2025.domain.models.LoginData
import com.prodhack.moscow2025.domain.interfaces.AuthRepository
import org.koin.core.annotation.Single
@Single
class LoginUserUseCase(
private val authRepository: AuthRepository
) {
suspend operator fun invoke(data: LoginData): Result<String> {
return authRepository.signInRequest(data)
}
}
@@ -0,0 +1,14 @@
package com.prodhack.moscow2025.domain.usecase.auth
import com.prodhack.moscow2025.domain.models.RegisterData
import com.prodhack.moscow2025.domain.interfaces.AuthRepository
import org.koin.core.annotation.Single
@Single
class RegisterUserUseCase(
private val authRepository: AuthRepository
) {
suspend operator fun invoke(data: RegisterData): Result<String> {
return authRepository.signUpRequest(data)
}
}
@@ -0,0 +1,15 @@
package com.prodhack.moscow2025.domain.usecase.auth
import com.prodhack.moscow2025.domain.models.UpdateUserData
import com.prodhack.moscow2025.domain.models.User
import com.prodhack.moscow2025.domain.interfaces.UserRepository
import org.koin.core.annotation.Single
@Single
class UpdateUserUseCase(
private val userRepository: UserRepository
) {
suspend operator fun invoke(data: UpdateUserData): Result<User> {
return userRepository.updateProfile(data)
}
}
@@ -0,0 +1,102 @@
package com.prodhack.moscow2025.domain.usecase.auth
import android.util.Patterns
import org.koin.core.annotation.Single
enum class AuthField {
FirstName,
SecondName,
Email,
Password,
ConfirmPassword,
Phone
}
data class ValidationResult(
val errors: Map<AuthField, String> = emptyMap()
) {
val isValid: Boolean
get() = errors.isEmpty()
}
@Single
class ValidateAuthFieldsUseCase {
fun validateFillProfile(
displayName: String,
firstName: String,
lastName: String,
phone: String
): ValidationResult {
val errors = buildMap {
if (displayName.isBlank()) put(AuthField.FirstName, "Введите никнейм")
if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя")
if (lastName.isBlank()) put(AuthField.SecondName, "Введите фамилию")
if (!isPhoneValid(phone)) put(AuthField.Phone, "Некорректный номер телефона")
}
return ValidationResult(errors)
}
fun validateSignUp(
email: String,
password: String,
confirmPassword: String
): ValidationResult {
val errors = buildMap {
if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email")
validatePassword(password)?.let { put(AuthField.Password, it) }
if (confirmPassword.isBlank()) put(AuthField.ConfirmPassword, "Повторите пароль")
if (password != confirmPassword) {
put(AuthField.ConfirmPassword, "Пароли не совпадают")
}
}
return ValidationResult(errors)
}
fun validatePassword(password: String): String? {
if (password.length < 8) {
return "Пароль должен быть не менее 8 символов"
}
if (!password.any { it.isUpperCase() }) {
return "Пароль должен содержать хотя бы одну заглавную букву"
}
if (!password.any { it.isDigit() }) {
return "Пароль должен содержать хотя бы одну цифру"
}
if (!password.any { !it.isLetterOrDigit() }) {
return "Пароль должен содержать хотя бы один специальный символ"
}
return null
}
fun validateLogin(
email: String,
password: String
): ValidationResult {
val errors = buildMap {
if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email")
validatePassword(password)?.let { put(AuthField.Password, it) }
}
return ValidationResult(errors)
}
fun validateProfile(
firstName: String,
secondName: String,
): ValidationResult {
val errors = buildMap {
if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя")
if (secondName.isBlank()) put(AuthField.SecondName, "Введите фамилию")
}
return ValidationResult(errors)
}
private fun isEmailValid(email: String): Boolean =
email.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(email).matches()
private fun isPhoneValid(phone: String): Boolean =
phone.isNotBlank() && Patterns.PHONE.matcher(phone).matches()
}
@@ -0,0 +1,38 @@
package com.prodhack.moscow2025.domain.utils
import com.prodhack.moscow2025.domain.utils.NetworkError.Connection
import com.prodhack.moscow2025.domain.utils.NetworkError.InputError
import com.prodhack.moscow2025.domain.utils.NetworkError.Unexpected
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.RedirectResponseException
import io.ktor.client.plugins.ServerResponseException
/**
* Network error wrapper class
*/
sealed class NetworkError : Throwable() {
/**
* Network connection error
*/
class Connection() : NetworkError()
/**
* Unexpected error for example HTTP code - 500 or exception when mapping data
*/
class Unexpected(val error: String) : NetworkError()
/**
* User input error - 400 codes
*/
class InputError(val error: String) : NetworkError()
}
fun Throwable.convertToNetworkError() =
when (this) {
is NetworkError -> this
is RedirectResponseException -> Unexpected(error = message)
is ClientRequestException -> InputError(error = message)
is ServerResponseException -> Unexpected(error = message)
else -> Connection()
}
@@ -0,0 +1,21 @@
package com.prodhack.moscow2025.domain.utils
import androidx.paging.PagingData
import kotlinx.coroutines.flow.Flow
/**
* Simple wrapper for convenience of network requests in repositories
*
* @see Flow
* @see Result
* @see NetworkError
*/
internal typealias RemoteWrapper<T> = Flow<Result<T>>
/**
* Simple wrapper for convenience of network paging requests in repositories
*
* @see Flow
* @see PagingData
*/
internal typealias RemotePagingWrapper<T> = Flow<PagingData<T>>
@@ -0,0 +1,133 @@
package com.prodhack.moscow2025.presentation
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Text
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import com.google.firebase.messaging.FirebaseMessaging
import com.prodhack.moscow2025.domain.usecase.auth.CheckSessionUseCase
import com.prodhack.moscow2025.presentation.navigation.AppDestination
import com.prodhack.moscow2025.presentation.navigation.TTasksApp
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import org.koin.android.ext.android.inject
import kotlin.getValue
class MainActivity : ComponentActivity() {
private val checkSessionUseCase: CheckSessionUseCase by inject()
private val sessionDestinationState = MutableStateFlow<AppDestination?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
var stateLoaded = false
splashScreen.setKeepOnScreenCondition {
stateLoaded.not()
}
super.onCreate(savedInstanceState)
enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false)
runBlocking {
val isAuthorized = try {
checkSessionUseCase()
} catch (e: Exception) {
false
}
sessionDestinationState.value =
if (isAuthorized) AppDestination.Main else AppDestination.Login
stateLoaded = true
}
setContent {
val sessionDestination by sessionDestinationState.collectAsState()
TTasksApp(sessionDestination = sessionDestination, context = this)
LaunchedEffect(Unit) {
requestPermissions(
arrayOf(Manifest.permission.ACCESS_NOTIFICATION_POLICY), 123
)
FirebaseMessaging.getInstance().token
.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
Log.d("TOKEN", token)
}
}
checkAndRequestNotificationPermission()
}
}
}
private fun checkAndRequestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
when {
ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED -> {
// Разрешение уже есть, получаем токен
getFCMToken()
}
else -> {
// Запрашиваем разрешение
requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
123
)
}
}
} else {
// Для версий ниже Android 13 разрешение не требуется
getFCMToken()
}
}
private fun getFCMToken() {
FirebaseMessaging.getInstance().token
.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
Log.d("TOKEN", token)
} else {
Log.e("TOKEN", "Failed to get token", task.exception)
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
deviceId: Int
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 123) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
getFCMToken()
}
}
}
}
@@ -0,0 +1,138 @@
package com.prodhack.moscow2025.presentation.components
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.presentation.theme.MoscowHackatonTemplateTheme
import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
@Composable
fun TBottomNavigation(modifier: Modifier = Modifier, selectedPage: Int, onSelect: (Int) -> Unit) {
Box(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(vertical = Paddings.small),
contentAlignment = Alignment.Center
) {
val firstIconPos = remember { mutableFloatStateOf(0f) }
val secondIconPos = remember { mutableFloatStateOf(0f) }
val thirdIconPos = remember { mutableFloatStateOf(0f) }
val indicatorOffset =
with(LocalDensity.current) {
when (selectedPage) {
0 -> firstIconPos.floatValue - secondIconPos.floatValue
1 -> 0f
2 -> thirdIconPos.floatValue - secondIconPos.floatValue
else -> null
}?.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
)
)
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Icon(
modifier = Modifier
.size(30.dp)
.onGloballyPositioned {
it.parentCoordinates?.positionInParent()?.let {
firstIconPos.floatValue = it.x
}
}
.noRippleClickable {
onSelect(0)
},
painter = painterResource(R.drawable.ic_trips),
tint = animateColorAsState(if (selectedPage == 0) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant).value,
contentDescription = "open trips list screen"
)
Icon(
modifier = Modifier
.size(30.dp)
.onGloballyPositioned {
it.parentCoordinates?.positionInParent()?.let {
secondIconPos.floatValue = it.x
}
}
.noRippleClickable {
onSelect(1)
},
painter = painterResource(R.drawable.ic_home),
tint = animateColorAsState(if (selectedPage == 1) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant).value,
contentDescription = "open tasks screen"
)
Icon(
modifier = Modifier
.size(30.dp)
.onGloballyPositioned {
it.parentCoordinates?.positionInParent()?.let {
thirdIconPos.floatValue = it.x
}
}
.noRippleClickable {
onSelect(2)
},
painter = painterResource(R.drawable.ic_profile),
tint = animateColorAsState(if (selectedPage == 2) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant).value,
contentDescription = "open tasks screen"
)
}
}
}
@Preview
@Composable
fun TBottomNavigationPreview() {
MoscowHackatonTemplateTheme {
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom) {
val page = remember { mutableIntStateOf(0) }
TBottomNavigation(selectedPage = page.intValue) {
Log.d("click", it.toString())
page.intValue = it
}
}
}
}
@@ -0,0 +1,84 @@
package com.prodhack.moscow2025.presentation.components.standart
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.presentation.theme.Shapes
@Composable
fun BigButton(
modifier: Modifier = Modifier,
onClick: () -> Unit,
buttonText: String,
isLoading: Boolean
) {
val colorScheme = MaterialTheme.colorScheme
val typography = MaterialTheme.typography
Button(
modifier = modifier
.fillMaxWidth()
.height(60.dp),
shape = Shapes.smallRoundedBox,
onClick = onClick,
enabled = !isLoading,
colors = ButtonColors(
containerColor = colorScheme.onPrimary,
contentColor = colorScheme.primary,
disabledContainerColor = colorScheme.onPrimary,
disabledContentColor = colorScheme.primary
)
){
if (isLoading) {
CircularProgressIndicator()
} else {
Text(
text = buttonText,
style = typography.labelMedium,
fontSize = 24.sp,
)
}
}
}
@Composable
fun MediumButton(
modifier: Modifier = Modifier,
onClick: () -> Unit,
buttonText: String,
isLoading: Boolean
) {
val colorScheme = MaterialTheme.colorScheme
val typography = MaterialTheme.typography
Button(
modifier = modifier
.fillMaxWidth()
.height(40.dp),
shape = Shapes.smallRoundedBox,
onClick = onClick,
enabled = !isLoading,
colors = ButtonColors(
containerColor = colorScheme.primary,
contentColor = colorScheme.onPrimary,
disabledContainerColor = colorScheme.primary,
disabledContentColor = colorScheme.onPrimary
)
){
if (isLoading) {
CircularProgressIndicator()
} else {
Text(
text = buttonText,
style = typography.labelMedium,
fontSize = 16.sp,
)
}
}
}
@@ -0,0 +1,38 @@
package com.prodhack.moscow2025.presentation.components.standart
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.presentation.theme.Shapes
@Composable
fun TCheckBox(modifier: Modifier = Modifier, checked: Boolean, color: Color) {
Box(
modifier = modifier
.background(Color.Transparent)
.border(width = 1.dp, color = color, shape = Shapes.verySmallRoundedBox)
) {
AnimatedContent(checked) {
if (it) {
Icon(
modifier = Modifier
.fillMaxSize()
.padding(2.dp),
painter = painterResource(R.drawable.ic_checkmark),
tint = color,
contentDescription = "checkmark"
)
}
}
}
}
@@ -0,0 +1,53 @@
package com.prodhack.moscow2025.presentation.components.standart
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R
@Composable
fun TTFloatingActionButton(
modifier: Modifier,
onClick: () -> Unit,
text: String
) {
val colorScheme = MaterialTheme.colorScheme
val typography = MaterialTheme.typography
ExtendedFloatingActionButton(
modifier = modifier,
onClick = {
onClick()
},
shape = RoundedCornerShape(10.dp),
containerColor = colorScheme.tertiaryContainer,
contentColor = colorScheme.onTertiaryContainer,
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 5.dp)
) {
Row {
Text(
text = text,
style = typography.titleMedium,
fontSize = 16.sp
)
Spacer(Modifier.width(10.dp))
Icon(
painter = painterResource(R.drawable.add_square_outline),
contentDescription = null,
modifier = Modifier.size(22.dp)
)
}
}
}
@@ -0,0 +1,47 @@
package com.prodhack.moscow2025.presentation.components.standart
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun TTNamedTextField(
name: String,
value: String,
onValueChange: (String) -> Unit,
error: String? = null,
singleLine: Boolean = true,
keyboardOptions: KeyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
onDone: (() -> Unit)? = null
) {
Column {
Text(
text = name,
style = typography.labelLarge,
fontSize = 14.sp,
color = Color.White
)
Spacer(Modifier.height(5.dp))
TTTextField(
value = value,
onValueChange = onValueChange,
error = error,
singleLine = singleLine,
keyboardOptions = keyboardOptions,
onDone = onDone
)
}
}
@@ -0,0 +1,127 @@
package com.prodhack.moscow2025.presentation.components.standart
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun TTPasswordField(
value: String,
onValueChange: (String) -> Unit,
label: String,
error: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
onDone: (() -> Unit)? = null
) {
val colorScheme = MaterialTheme.colorScheme
val typography = MaterialTheme.typography
var isVisible by remember { mutableStateOf(false) }
Box(
Modifier.height(70.dp)
) {
Box(
Modifier
.fillMaxWidth().height(56.dp)
.offset(x = 5.dp)
.background(
color = Color.White,
shape = RoundedCornerShape(15.dp)
)
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth().offset(y = 5.dp),
value = value,
onValueChange = onValueChange,
textStyle = typography.labelLarge,
placeholder = {
Text(
label,
style = typography.labelLarge,
fontSize = 14.sp
)
},
isError = error != null,
supportingText = {
if (error != null) {
Text(
text = error,
style = typography.labelLarge,
fontSize = 12.sp
)
}
},
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(
onDone = {
onDone?.invoke()
}
),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = colorScheme.primary,
unfocusedContainerColor = colorScheme.primary,
errorContainerColor = colorScheme.error,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
focusedPlaceholderColor = colorScheme.onPrimary,
unfocusedPlaceholderColor = colorScheme.onPrimary,
errorPlaceholderColor = colorScheme.onError,
focusedTextColor = colorScheme.onPrimary,
unfocusedTextColor = colorScheme.onPrimary,
errorTextColor = colorScheme.onError,
cursorColor = colorScheme.onPrimary,
errorCursorColor = colorScheme.onError
),
shape = RoundedCornerShape(15.dp),
visualTransformation = if (isVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val icon = if (isVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility
IconButton(onClick = { isVisible = !isVisible }) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = colorScheme.onPrimary
)
}
}
)
}
}
@@ -0,0 +1,402 @@
package com.prodhack.moscow2025.presentation.components.standart
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
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.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class)
@Composable
fun TTTextField(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
value: String,
onValueChange: (String) -> Unit,
readOnly: Boolean = false,
label: String = "",
error: String? = null,
singleLine: Boolean = true,
maxLines: Int = 1,
keyboardOptions: KeyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
onDone: (() -> Unit)? = null,
trailingIcon: @Composable () -> Unit = {}
) {
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
Box(
Modifier.height(70.dp),
) {
Box(
Modifier
.fillMaxWidth()
.height(56.dp)
.offset(x = 5.dp)
.background(
color = Color.White,
shape = RoundedCornerShape(15.dp)
)
)
OutlinedTextField(
modifier = modifier
.fillMaxWidth()
.offset(y = 5.dp),
value = value,
readOnly = readOnly,
onValueChange = onValueChange,
textStyle = typography.labelLarge,
placeholder = {
Text(
label,
style = typography.labelLarge,
fontSize = 14.sp,
)
},
isError = error != null,
supportingText = {
if (error != null) {
Spacer(Modifier.height(5.dp))
Text(
text = error,
style = typography.labelLarge,
fontSize = 12.sp
)
}
},
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(
onDone = {
onDone?.invoke()
}
),
singleLine = singleLine,
maxLines = maxLines,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = colorScheme.primary,
unfocusedContainerColor = colorScheme.primary,
errorContainerColor = colorScheme.error,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
focusedPlaceholderColor = colorScheme.onPrimary,
unfocusedPlaceholderColor = colorScheme.onPrimary,
errorPlaceholderColor = colorScheme.onError,
focusedTextColor = colorScheme.onPrimary,
unfocusedTextColor = colorScheme.onPrimary,
errorTextColor = colorScheme.onError,
cursorColor = colorScheme.onPrimary,
errorCursorColor = colorScheme.onError
),
shape = RoundedCornerShape(15.dp),
trailingIcon = trailingIcon
)
if (readOnly && onClick != null) {
Box(
modifier = Modifier
.fillMaxSize()
.noRippleClickable(onClick)
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T> TTTextFieldWithDropdown(
modifier: Modifier = Modifier,
value: String,
onValueChange: (String) -> Unit = {},
readOnly: Boolean = true,
label: String,
error: String? = null,
singleLine: Boolean = true,
maxLines: Int = 1,
keyboardOptions: KeyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
dropdownItems: List<T> = emptyList(),
onDropdownItemSelected: (T) -> Unit = {},
dropDownItem: @Composable (T) -> Unit,
trailingIcon: @Composable (Boolean) -> Unit = {
Icon(
modifier = Modifier
.size(24.dp)
.rotate(animateFloatAsState(if (it) 180f else 0f).value),
painter = painterResource(R.drawable.ic_arr_dropdown),
tint = MaterialTheme.colorScheme.onPrimary,
contentDescription = null
)
}
) {
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
var expanded by remember { mutableStateOf(false) }
Box(
modifier.height(70.dp),
) {
Box(
Modifier
.fillMaxWidth()
.height(56.dp)
.offset(x = 5.dp)
.background(
color = Color.White,
shape = RoundedCornerShape(15.dp)
)
)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = Modifier.offset(y = 5.dp)
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
value = value,
readOnly = readOnly,
onValueChange = onValueChange,
textStyle = typography.labelLarge,
placeholder = {
Text(
label,
style = typography.labelLarge,
fontSize = 14.sp,
)
},
isError = error != null,
supportingText = {
if (error != null) {
Spacer(Modifier.height(5.dp))
Text(
text = error,
style = typography.labelLarge,
fontSize = 12.sp
)
}
},
keyboardOptions = keyboardOptions,
singleLine = singleLine,
maxLines = maxLines,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = colorScheme.primary,
unfocusedContainerColor = colorScheme.primary,
errorContainerColor = colorScheme.error,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
focusedPlaceholderColor = colorScheme.onPrimary,
unfocusedPlaceholderColor = colorScheme.onPrimary,
errorPlaceholderColor = colorScheme.onError,
focusedTextColor = colorScheme.onPrimary,
unfocusedTextColor = colorScheme.onPrimary,
errorTextColor = colorScheme.onError,
cursorColor = colorScheme.onPrimary,
errorCursorColor = colorScheme.onError
),
shape = RoundedCornerShape(15.dp),
trailingIcon = {
trailingIcon(expanded)
}
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.exposedDropdownSize()
) {
if (dropdownItems.isEmpty()) {
DropdownMenuItem(
text = {
Text("Здесь пока ничего нет", style = typography.titleMedium)
},
onClick = {
expanded = false
}
)
}
dropdownItems.forEach { item ->
DropdownMenuItem(
text = {
dropDownItem(item)
},
onClick = {
onDropdownItemSelected(item)
expanded = false
}
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T> TTTextFieldWithSearch(
modifier: Modifier = Modifier,
value: String,
onValueChange: (String) -> Unit = {},
readOnly: Boolean = true,
label: String,
error: String? = null,
singleLine: Boolean = true,
maxLines: Int = 1,
keyboardOptions: KeyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
dropdownItems: List<T> = emptyList(),
onDropdownItemSelected: (T) -> Unit = {},
dropDownItem: @Composable (T) -> Unit,
trailingIcon: @Composable (Boolean) -> Unit = {}
) {
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
var expanded by remember { mutableStateOf(false) }
Box(
modifier.height(70.dp),
) {
Box(
Modifier
.fillMaxWidth()
.height(56.dp)
.offset(x = 5.dp)
.background(
color = Color.White,
shape = RoundedCornerShape(15.dp)
)
)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = Modifier.offset(y = 5.dp)
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable),
value = value,
readOnly = readOnly,
onValueChange = onValueChange,
textStyle = typography.labelLarge,
placeholder = {
Text(
label,
style = typography.labelLarge,
fontSize = 14.sp,
)
},
isError = error != null,
supportingText = {
if (error != null) {
Spacer(Modifier.height(5.dp))
Text(
text = error,
style = typography.labelLarge,
fontSize = 12.sp
)
}
},
keyboardOptions = keyboardOptions,
singleLine = singleLine,
maxLines = maxLines,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = colorScheme.primary,
unfocusedContainerColor = colorScheme.primary,
errorContainerColor = colorScheme.error,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
focusedPlaceholderColor = colorScheme.onPrimary,
unfocusedPlaceholderColor = colorScheme.onPrimary,
errorPlaceholderColor = colorScheme.onError,
focusedTextColor = colorScheme.onPrimary,
unfocusedTextColor = colorScheme.onPrimary,
errorTextColor = colorScheme.onError,
cursorColor = colorScheme.onPrimary,
errorCursorColor = colorScheme.onError
),
shape = RoundedCornerShape(15.dp),
trailingIcon = {
trailingIcon(expanded)
}
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.exposedDropdownSize()
) {
dropdownItems.forEach { item ->
DropdownMenuItem(
text = {
dropDownItem(item)
},
onClick = {
onDropdownItemSelected(item)
expanded = false
}
)
}
}
}
}
}
@@ -0,0 +1,44 @@
package com.prodhack.moscow2025.presentation.components.standart
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
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.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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.presentation.theme.Paddings
@Composable
fun TopLogo(
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Image(
modifier = Modifier.size(100.dp),
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = "App logo"
)
Spacer(modifier = Modifier.width(Paddings.medium))
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
fontSize = 48.sp
)
}
}
@@ -0,0 +1,18 @@
package com.prodhack.moscow2025.presentation.navigation
/**
* Centralized list of application destinations.
*
* Keeping the routes in one place helps to avoid
* string duplication and makes refactoring safer.
*/
sealed class AppDestination(val route: String) {
data object Login : AppDestination("app/login")
data object Register : AppDestination("app/register")
data object Main : AppDestination("app/main")
data object Profile : AppDestination("app/profile")
}
@@ -0,0 +1,99 @@
package com.prodhack.moscow2025.presentation.navigation
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.navigation.compose.currentBackStackEntryAsState
import com.prodhack.moscow2025.presentation.components.TBottomNavigation
import com.prodhack.moscow2025.presentation.theme.MoscowHackatonTemplateTheme
@Composable
fun TTasksApp(
appState: TTasksAppState = rememberTTasksAppState(),
context: Context,
sessionDestination: AppDestination? = null
) {
MoscowHackatonTemplateTheme() {
val snackbarHostState = remember { SnackbarHostState() }
val bottomBarState = remember { mutableStateOf<Int?>(null) }
when (appState.navController.currentBackStackEntryAsState().value?.destination?.route) {
AppDestination.Login.route -> {
bottomBarState.value = null
}
AppDestination.Register.route -> {
bottomBarState.value = null
}
AppDestination.Main.route -> {
bottomBarState.value = 1
}
AppDestination.Profile.route -> {
bottomBarState.value = 2
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState,
snackbar = { data ->
Snackbar(
snackbarData = data,
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
shape = MaterialTheme.shapes.medium
)
}
)
},
bottomBar = {
bottomBarState.value?.let { bbState ->
TBottomNavigation(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer)
.windowInsetsPadding(WindowInsets.navigationBars),
selectedPage = bbState
) { newPage ->
when (newPage) {
0 -> {
TODO()
}
1 -> {
appState.navController.navigate(AppDestination.Main.route)
}
2 -> {
appState.navController.navigate(AppDestination.Profile.route)
}
}
}
}
},
) { padding ->
TTasksNavHost(
navController = appState.navController,
modifier = Modifier.padding(padding),
sessionDestination = sessionDestination,
snackbarHostState = snackbarHostState,
context = context
)
}
}
}
@@ -0,0 +1,40 @@
package com.prodhack.moscow2025.presentation.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.CoroutineScope
@Stable
class TTasksAppState(
val navController: NavHostController,
val coroutineScope: CoroutineScope
) {
val currentDestination: NavDestination?
get() = navController.currentDestination
fun navigateTo(
destination: AppDestination,
builder: NavOptionsBuilder.() -> Unit = {}
) {
navController.navigate(destination.route, builder)
}
fun navigateBack(): Boolean = navController.popBackStack()
}
@Composable
fun rememberTTasksAppState(
navController: NavHostController = rememberNavController(),
coroutineScope: CoroutineScope = rememberCoroutineScope()
): TTasksAppState = remember(navController, coroutineScope) {
TTasksAppState(
navController = navController,
coroutineScope = coroutineScope
)
}
@@ -0,0 +1,80 @@
package com.prodhack.moscow2025.presentation.navigation
import android.content.Context
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.prodhack.moscow2025.presentation.screens.main.MainScreen
import com.prodhack.moscow2025.domain.utils.NetworkError
import com.prodhack.moscow2025.presentation.screens.login.LoginScreen
import com.prodhack.moscow2025.presentation.screens.register.RegisterScreen
import com.prodhack.moscow2025.presentation.utils.ErrorCallbacks
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
import org.koin.compose.viewmodel.koinActivityViewModel
@Composable
fun TTasksNavHost(
navController: NavHostController,
modifier: Modifier = Modifier,
sessionDestination: AppDestination? = null,
context: Context,
snackbarHostState: SnackbarHostState
) {
val startDestination = sessionDestination?.route ?: AppDestination.Login.route
ErrorCollectorScope(context, navController, object : ErrorCallbacks {
override fun processConnectionError(networkError: NetworkError.Connection) {
}
override fun processUnexpectedError(networkError: NetworkError.Unexpected) {
}
}) {
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier
) {
composable(AppDestination.Login.route) {
LoginScreen(
snackbarHostState = snackbarHostState,
onRegisterClick = {
navController.navigate(AppDestination.Register.route)
},
onSuccess = {
navController.navigate(AppDestination.Main.route) {
popUpTo(AppDestination.Login.route) {
inclusive = true
}
}
}
)
}
composable(AppDestination.Register.route) {
RegisterScreen(
snackbarHostState = snackbarHostState,
onLoginClick = {
navController.popBackStack()
},
onSuccess = {
navController.navigate(AppDestination.Main.route) {
popUpTo(AppDestination.Register.route) {
inclusive = true
}
}
}
)
}
composable(AppDestination.Main.route) {
MainScreen()
}
}
}
}
@@ -0,0 +1,9 @@
package com.prodhack.moscow2025.presentation.screens.fillProfile
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun FillProfileScreen() {
Text("Fill profile will be here soon :)")
}
@@ -0,0 +1,185 @@
package com.prodhack.moscow2025.presentation.screens.fillProfile
import android.content.ContentUris
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.provider.MediaStore
import androidx.lifecycle.viewModelScope
import androidx.paging.map
import coil.ImageLoader
import coil.request.ImageRequest
import com.prodhack.moscow2025.domain.interfaces.GalleryRepository
import com.prodhack.moscow2025.domain.models.UpdateUserData
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.domain.usecase.auth.UpdateUserUseCase
import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase
import com.prodhack.moscow2025.presentation.utils.UIState
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import com.prodhack.moscow2025.presentation.utils.toByteArray
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class FillProfileFormState(
val displayName: 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 FillProfileFormState
if (displayName != other.displayName) 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 = displayName.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
}
}
class FillProfileViewModel(
private val updateUserUseCase: UpdateUserUseCase,
private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase,
private val galleryRepository: GalleryRepository
) : BaseViewModel() {
private val _formStateFillProfile = MutableStateFlow(FillProfileFormState())
val formStateSignUp: StateFlow<FillProfileFormState> = _formStateFillProfile
private val _profileFillState = MutableUIStateFlow<String>()
val profileFillState: StateFlow<UIState<String>> = _profileFillState
fun onDisplayNameChange(value: String) {
_formStateFillProfile.update {
it.copy(
displayName = value,
errors = it.errors - AuthField.Email
)
}
}
fun onFirstNameChange(value: String) {
_formStateFillProfile.update {
it.copy(
firstName = value,
errors = it.errors - AuthField.Email
)
}
}
fun onLastNameChange(value: String) {
_formStateFillProfile.update {
it.copy(
lastName = value,
errors = it.errors - AuthField.Email
)
}
}
fun onPhoneChange(value: String) {
_formStateFillProfile.update {
it.copy(
phone = value,
errors = it.errors - AuthField.Email
)
}
}
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 {
_formStateFillProfile.update {
it.copy(
avatar = bitmap.toByteArray()
)
}
}
}
fun clearAvatar() {
viewModelScope.launch {
_formStateFillProfile.update {
it.copy(
avatar = null
)
}
}
}
var currentPhoto: Uri? = null
fun selectImage(photo: Uri) {
currentPhoto = photo
}
fun submit() {
viewModelScope.launch {
val validation = validateAuthFieldsUseCase.validateFillProfile(
displayName = _formStateFillProfile.value.displayName,
firstName = _formStateFillProfile.value.firstName,
lastName = _formStateFillProfile.value.lastName,
phone = _formStateFillProfile.value.phone
)
if (!validation.isValid) {
_formStateFillProfile.update { it.copy(errors = validation.errors) }
return@launch
}
_profileFillState.emit(UIState.Loading())
val result = updateUserUseCase(
UpdateUserData(
displayName = _formStateFillProfile.value.displayName,
firstName = _formStateFillProfile.value.firstName,
lastName = _formStateFillProfile.value.lastName,
phone = _formStateFillProfile.value.phone
)
)
result.map { it.id }.collectRequest(_profileFillState)
}
}
}
@@ -0,0 +1,206 @@
package com.prodhack.moscow2025.presentation.screens.login
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
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.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
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.TTPasswordField
import com.prodhack.moscow2025.presentation.components.standart.TTTextField
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
import com.prodhack.moscow2025.presentation.utils.UIState
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
import org.koin.androidx.compose.koinViewModel
@Composable
fun ErrorCollectorScope.LoginScreen(
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState,
onRegisterClick: () -> Unit,
onSuccess: () -> Unit,
viewModel: LoginViewModel = koinViewModel()
) {
val showDialog = remember { mutableStateOf(false) }
val testCreds = listOf(
Pair("user1@mail.ru", "qQW!!!.rty3nqc18123"),
Pair("user2@mail.ru", "qQW!!!.rty3nqc18123"),
Pair("user3@mail.ru", "qQW!!!.rty3nqc18123"),
Pair("user4@mail.ru", "qQW!!!.rty3nqc18123"),
Pair("user5@mail.ru", "qQW!!!.rty3nqc18123")
)
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
val formState by viewModel.formState.collectAsState()
var errorText by remember { mutableStateOf("") }
val authState by viewModel.authState.collectAsStateWithCallbacks(
onInputError = {
errorText = it.error
},
onConnectionError = {
errorText = "Нет подключения к сети"
},
onUnexpectedError = {
errorText = it.error
},
onLoading = {
errorText = ""
},
onSuccess = {
errorText = ""
}
)
LaunchedEffect(authState) {
if (authState is UIState.Success) {
onSuccess()
}
}
LaunchedEffect(errorText) {
if (errorText.isNotEmpty()) {
snackbarHostState.showSnackbar(
message = "Ошибка: $errorText",
duration = SnackbarDuration.Short
)
}
}
Box(
modifier = modifier
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
contentAlignment = Alignment.BottomStart
) {
Image(
painter = painterResource(R.drawable.lottie),
contentDescription = null,
modifier = Modifier
.width(130.dp),
contentScale = ContentScale.Crop
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 30.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier
.size(250.dp)
.noRippleClickable {
showDialog.value = true
}
)
Spacer(Modifier.height(10.dp))
Text(
text = "Вход",
style = MaterialTheme.typography.titleLarge,
fontSize = 40.sp
)
Spacer(modifier = Modifier.height(10.dp))
TTTextField(
value = formState.email,
onValueChange = viewModel::onEmailChange,
label = "Ваш email",
error = formState.errors[AuthField.Email]
)
Spacer(Modifier.height(12.dp))
TTPasswordField(
value = formState.password,
onValueChange = viewModel::onPasswordChange,
label = "Пароль",
error = formState.errors[AuthField.Password],
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done
),
onDone = viewModel::submit
)
Spacer(modifier = Modifier.height(40.dp))
BigButton(
onClick = viewModel::submit,
modifier = Modifier.fillMaxWidth(),
buttonText = "Войти",
isLoading = authState is UIState.Loading
)
Spacer(modifier = Modifier.height(20.dp))
TextButton(
onClick = onRegisterClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Зарегистрироваться",
style = typography.labelMedium,
color = colorScheme.onBackground,
fontSize = 24.sp
)
}
Spacer(Modifier.height(80.dp))
}
if (showDialog.value) {
Dialog(
onDismissRequest = {
showDialog.value = false
}
) {
Column {
testCreds.forEach {
Button(onClick = {
viewModel.onEmailChange(it.first)
viewModel.onPasswordChange(it.second)
viewModel.submit()
}) {
Text(it.first)
}
}
}
}
}
}
}
@@ -0,0 +1,64 @@
package com.prodhack.moscow2025.presentation.screens.login
import androidx.lifecycle.viewModelScope
import com.prodhack.moscow2025.domain.models.LoginData
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.domain.usecase.auth.LoginUserUseCase
import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase
import com.prodhack.moscow2025.presentation.utils.UIState
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel
data class LoginFormState(
val email: String = "",
val password: String = "",
val errors: Map<AuthField, String> = emptyMap()
)
@KoinViewModel
class LoginViewModel(
private val loginUserUseCase: LoginUserUseCase,
private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase
) : BaseViewModel() {
private val _formState = MutableStateFlow(LoginFormState())
val formState: StateFlow<LoginFormState> = _formState
private val _authState = MutableUIStateFlow<String>()
val authState: StateFlow<UIState<String>> = _authState
fun onEmailChange(value: String) {
_formState.update { it.copy(email = value, errors = it.errors - AuthField.Email) }
}
fun onPasswordChange(value: String) {
_formState.update { it.copy(password = value, errors = it.errors - AuthField.Password) }
}
fun submit() {
viewModelScope.launch {
val validation = validateAuthFieldsUseCase.validateLogin(
email = _formState.value.email,
password = _formState.value.password
)
if (!validation.isValid) {
_formState.update { it.copy(errors = validation.errors) }
return@launch
}
_authState.emit(UIState.Loading())
val result = loginUserUseCase(
LoginData(
email = _formState.value.email,
password = _formState.value.password
)
)
result.collectRequest(_authState)
}
}
}
@@ -0,0 +1,282 @@
package com.prodhack.moscow2025.presentation.screens.main
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
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
// }
// }
// }
}
@@ -0,0 +1,143 @@
package com.prodhack.moscow2025.presentation.screens.main
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
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
) : 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()
// }
}
@@ -0,0 +1,182 @@
package com.prodhack.moscow2025.presentation.screens.register
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.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.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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.domain.utils.NetworkError
import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.TTPasswordField
import com.prodhack.moscow2025.presentation.components.standart.TTTextField
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
import com.prodhack.moscow2025.presentation.utils.UIState
import org.koin.androidx.compose.koinViewModel
@Composable
fun ErrorCollectorScope.RegisterScreen(
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState,
onLoginClick: () -> Unit,
onSuccess: () -> Unit,
viewModel: RegisterViewModel = koinViewModel()
) {
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
val formState by viewModel.formStateSignUp.collectAsState()
var errorText by remember { mutableStateOf("") }
val registerState by viewModel.registerState.collectAsStateWithCallbacks(
onInputError = {
errorText = it.error
},
onConnectionError = {
errorText = "Нет подключения к сети"
},
onUnexpectedError = {
errorText = it.error
},
onLoading = {
errorText = ""
},
onSuccess = {
errorText = ""
}
)
LaunchedEffect(registerState) {
if (registerState is UIState.Success) {
onSuccess()
}
}
LaunchedEffect(errorText) {
if (errorText.isNotEmpty()) {
snackbarHostState.showSnackbar(
message = "Ошибка: $errorText",
duration = SnackbarDuration.Short
)
}
}
Box(
modifier = modifier
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
contentAlignment = Alignment.BottomStart
) {
Image(
painter = painterResource(R.drawable.lottie),
contentDescription = null,
modifier = Modifier.width(130.dp),
contentScale = ContentScale.Crop
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(start = 30.dp, end = 30.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Давайте\nзнакомиться!",
style = typography.titleLarge,
fontSize = 31.sp
)
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.size(140.dp),
contentScale = ContentScale.Crop
)
}
Spacer(Modifier.height(20.dp))
TTTextField(
value = formState.email,
onValueChange = viewModel::onEmailChange,
label = "Ваш email",
error = formState.errors[AuthField.Email]
)
Spacer(Modifier.height(12.dp))
TTPasswordField(
value = formState.password,
onValueChange = viewModel::onPasswordChange,
label = "Пароль",
error = formState.errors[AuthField.Password]
)
Spacer(Modifier.height(12.dp))
TTPasswordField(
value = formState.confirmPassword,
onValueChange = viewModel::onConfirmPasswordChange,
label = "Повторите пароль",
error = formState.errors[AuthField.ConfirmPassword],
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done
),
onDone = viewModel::submit
)
Spacer(modifier = Modifier.height(20.dp))
BigButton(
onClick = viewModel::submit,
modifier = Modifier.fillMaxWidth(),
buttonText = "Зарегистрироваться",
isLoading = registerState is UIState.Loading
)
Spacer(modifier = Modifier.height(20.dp))
TextButton(
onClick = onLoginClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Уже есть аккаунт?",
style = typography.labelMedium,
color = colorScheme.onBackground,
fontSize = 24.sp
)
}
Spacer(Modifier.height(80.dp))
}
}
}
@@ -0,0 +1,110 @@
package com.prodhack.moscow2025.presentation.screens.register
import androidx.lifecycle.viewModelScope
import com.prodhack.moscow2025.domain.models.RegisterData
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.domain.usecase.auth.RegisterUserUseCase
import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase
import com.prodhack.moscow2025.presentation.utils.UIState
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel
data class RegisterFormState(
val email: String = "",
val password: String = "",
val confirmPassword: String = "",
val errors: Map<AuthField, String> = emptyMap()
)
@KoinViewModel
class RegisterViewModel(
private val registerUserUseCase: RegisterUserUseCase,
private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase
) : BaseViewModel() {
private val _formStateSignUp = MutableStateFlow(RegisterFormState())
val formStateSignUp: StateFlow<RegisterFormState> = _formStateSignUp
private val _registerState = MutableUIStateFlow<String>()
val registerState: StateFlow<UIState<String>> = _registerState
fun onEmailChange(value: String) {
_formStateSignUp.update { it.copy(email = value, errors = it.errors - AuthField.Email) }
}
fun onPasswordChange(value: String) {
_formStateSignUp.update {
it.copy(
password = value,
errors = it.errors - AuthField.Password
)
}
}
fun onConfirmPasswordChange(value: String) {
_formStateSignUp.update {
it.copy(
confirmPassword = value,
errors = it.errors - AuthField.ConfirmPassword
)
}
}
fun submit() {
viewModelScope.launch {
val validation = validateAuthFieldsUseCase.validateSignUp(
email = _formStateSignUp.value.email,
password = _formStateSignUp.value.password,
confirmPassword = _formStateSignUp.value.confirmPassword
)
if (!validation.isValid) {
_formStateSignUp.update { it.copy(errors = validation.errors) }
return@launch
}
_registerState.emit(UIState.Loading())
val result = registerUserUseCase(
RegisterData(
email = _formStateSignUp.value.email,
password = _formStateSignUp.value.password
)
)
result.collectRequest(_registerState)
// val validation = validateAuthFieldsUseCase.validateRegister(
// firstName = _formStateSignUp.value.firstName,
// lastName = _formStateSignUp.value.lastName,
// email = _formStateSignUp.value.email,
// password = _formStateSignUp.value.password,
// confirmPassword = _formStateSignUp.value.confirmPassword,
// phone = _formStateSignUp.value.ph
// )
//
// if (!validation.isValid) {
// _formStateSignUp.update { it.copy(errors = validation.errors) }
// return@launch
// }
//
// _registerState.emit(UIState.Loading())
//
// val result = registerUserUseCase(
// RegisterData(
// firstName = _formStateSignUp.value.firstName,
// secondName = _formStateSignUp.value.lastName,
// email = _formStateSignUp.value.email,
// password = _formStateSignUp.value.password
// )
// )
// result.collectRequest(_registerState)
}
}
}
@@ -0,0 +1,103 @@
package com.prodhack.moscow2025.presentation.theme
import androidx.compose.ui.graphics.Color
val WhitePrimary = Color(0xFF1b6b51)
val WhiteSurfaceTint = Color(0xFF1b6b51)
val WhiteOnPrimary = Color(0xFFFFFFFF)
val WhitePrimaryContainer = Color(0xFFa6f2d1)
val WhiteOnPrimaryContainer = Color(0xFF00513b)
val WhiteSecondary = Color(0xFF4c6359)
val WhiteOnSecondary = Color(0xFFFFFFFF)
val WhiteSecondaryContainer = Color(0xFFcee9db)
val WhiteOnSecondaryContainer = Color(0xFF354b41)
val WhiteTertiary = Color(0xFF3e6374)
val WhiteOnTertiary = Color(0xFFFFFFFF)
val WhiteTertiaryContainer = Color(0xFFc2e8fd)
val WhiteOnTertiaryContainer = Color(0xFF264b5c)
val WhiteError = Color(0xFFba1a1a)
val WhiteOnError = Color(0xFFFFFFFF)
val WhiteErrorContainer = Color(0xFFffdad6)
val WhiteOnErrorContainer = Color(0xFF93000a)
val WhiteBackground = Color(0xFFf5fbf5)
val WhiteOnBackground = Color(0xFF171d1a)
val WhiteSurface = Color(0xFFf5fbf5)
val WhiteOnSurface = Color(0xFF171d1a)
val WhiteSurfaceVariant = Color(0xFFdbe5de)
val WhiteOnSurfaceVariant = Color(0xFF404944)
val WhiteOutline = Color(0xFF707974)
val WhiteOutlineVariant = Color(0xFFbfc9c2)
val WhiteShadow = Color(0xFF000000)
val WhiteScrim = Color(0xFF000000)
val WhiteInverseSurface = Color(0xFF2c322e)
val WhiteInverseOnSurface = Color(0xFFecf2ed)
val WhiteInversePrimary = Color(0xFF8bd6b6)
val WhitePrimaryFixed = Color(0xFFa6f2d1)
val WhiteOnPrimaryFixed = Color(0xFF002116)
val WhitePrimaryFixedDim = Color(0xFF8bd6b6)
val WhiteOnPrimaryFixedVariant = Color(0xFF00513b)
val WhiteSecondaryFixed = Color(0xFFcee9db)
val WhiteOnSecondaryFixed = Color(0xFF092017)
val WhiteSecondaryFixedDim = Color(0xFFb3ccbf)
val WhiteOnSecondaryFixedVariant = Color(0xFF354b41)
val WhiteTertiaryFixed = Color(0xFFc2e8fd)
val WhiteOnTertiaryFixed = Color(0xFF001f2a)
val WhiteTertiaryFixedDim = Color(0xFFa6cce0)
val WhiteOnTertiaryFixedVariant = Color(0xFF264b5c)
val WhiteSurfaceDim = Color(0xFFd6dbd6)
val WhiteSurfaceBright = Color(0xFFf5fbf5)
val WhiteSurfaceContainerLowest = Color(0xFFFFFFFF)
val WhiteSurfaceContainerLow = Color(0xFFeff5f0)
val WhiteSurfaceContainer = Color(0xFFe9efea)
val WhiteSurfaceContainerHigh = Color(0xFFe4eae4)
val WhiteSurfaceContainerHighest = Color(0xFFdee4df)
val DarkPrimary = Color(0xFF8bd6b6)
val DarkSurfaceTint = Color(0xFF8bd6b6)
val DarkOnPrimary = Color(0xFF003828)
val DarkPrimaryContainer = Color(0xFF00513b)
val DarkOnPrimaryContainer = Color(0xFFa6f2d1)
val DarkSecondary = Color(0xFFb3ccbf)
val DarkOnSecondary = Color(0xFF1e352b)
val DarkSecondaryContainer = Color(0xFF354b41)
val DarkOnSecondaryContainer = Color(0xFFcee9db)
val DarkTertiary = Color(0xFFa6cce0)
val DarkOnTertiary = Color(0xFF093544)
val DarkTertiaryContainer = Color(0xFF264b5c)
val DarkOnTertiaryContainer = Color(0xFFc2e8fd)
val DarkError = Color(0xFFffb4ab)
val DarkOnError = Color(0xFF690005)
val DarkErrorContainer = Color(0xFF93000a)
val DarkOnErrorContainer = Color(0xFFffdad6)
val DarkBackground = Color(0xFF0f1512)
val DarkOnBackground = Color(0xFFdee4df)
val DarkSurface = Color(0xFF0f1512)
val DarkOnSurface = Color(0xFFdee4df)
val DarkSurfaceVariant = Color(0xFF404944)
val DarkOnSurfaceVariant = Color(0xFFbfc9c2)
val DarkOutline = Color(0xFF89938d)
val DarkOutlineVariant = Color(0xFF404944)
val DarkShadow = Color(0xFF000000)
val DarkScrim = Color(0xFF000000)
val DarkInverseSurface = Color(0xFFdee4df)
val DarkInverseOnSurface = Color(0xFF2c322e)
val DarkInversePrimary = Color(0xFF1b6b51)
val DarkPrimaryFixed = Color(0xFFa6f2d1)
val DarkOnPrimaryFixed = Color(0xFF002116)
val DarkPrimaryFixedDim = Color(0xFF8bd6b6)
val DarkOnPrimaryFixedVariant = Color(0xFF00513b)
val DarkSecondaryFixed = Color(0xFFcee9db)
val DarkOnSecondaryFixed = Color(0xFF092017)
val DarkSecondaryFixedDim = Color(0xFFb3ccbf)
val DarkOnSecondaryFixedVariant = Color(0xFF354b41)
val DarkTertiaryFixed = Color(0xFFc2e8fd)
val DarkOnTertiaryFixed = Color(0xFF001f2a)
val DarkTertiaryFixedDim = Color(0xFFa6cce0)
val DarkOnTertiaryFixedVariant = Color(0xFF264b5c)
val DarkSurfaceDim = Color(0xFF0f1512)
val DarkSurfaceBright = Color(0xFF343b37)
val DarkSurfaceContainerLowest = Color(0xFF0a0f0d)
val DarkSurfaceContainerLow = Color(0xFF171d1a)
val DarkSurfaceContainer = Color(0xFF1b211e)
val DarkSurfaceContainerHigh = Color(0xFF252b28)
val DarkSurfaceContainerHighest = Color(0xFF303633)
@@ -0,0 +1,12 @@
package com.prodhack.moscow2025.presentation.theme
import androidx.compose.ui.unit.dp
object Paddings {
val verySmall = 4.dp
val small = 8.dp
val medium = 12.dp
val large = 20.dp
}
@@ -0,0 +1,11 @@
package com.prodhack.moscow2025.presentation.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp
object Shapes{
val verySmallRoundedBox = RoundedCornerShape(Paddings.verySmall)
val smallRoundedBox = RoundedCornerShape(10.dp)
}
@@ -0,0 +1,154 @@
package com.prodhack.moscow2025.presentation.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
// Light color scheme
private val LightColorScheme = lightColorScheme(
primary = WhitePrimary,
onPrimary = WhiteOnPrimary,
primaryContainer = WhitePrimaryContainer,
onPrimaryContainer = WhiteOnPrimaryContainer,
inversePrimary = WhiteInversePrimary,
secondary = WhiteSecondary,
onSecondary = WhiteOnSecondary,
secondaryContainer = WhiteSecondaryContainer,
onSecondaryContainer = WhiteOnSecondaryContainer,
tertiary = WhiteTertiary,
onTertiary = WhiteOnTertiary,
tertiaryContainer = WhiteTertiaryContainer,
onTertiaryContainer = WhiteOnTertiaryContainer,
error = WhiteError,
onError = WhiteOnError,
errorContainer = WhiteErrorContainer,
onErrorContainer = WhiteOnErrorContainer,
background = WhiteBackground,
onBackground = WhiteOnBackground,
surface = WhiteSurface,
onSurface = WhiteOnSurface,
surfaceVariant = WhiteSurfaceVariant,
onSurfaceVariant = WhiteOnSurfaceVariant,
inverseSurface = WhiteInverseSurface,
inverseOnSurface = WhiteInverseOnSurface,
outline = WhiteOutline,
outlineVariant = WhiteOutlineVariant,
scrim = WhiteScrim,
surfaceTint = WhiteSurfaceTint,
// Fixed colors
primaryFixed = WhitePrimaryFixed,
onPrimaryFixed = WhiteOnPrimaryFixed,
primaryFixedDim = WhitePrimaryFixedDim,
onPrimaryFixedVariant = WhiteOnPrimaryFixedVariant,
secondaryFixed = WhiteSecondaryFixed,
onSecondaryFixed = WhiteOnSecondaryFixed,
secondaryFixedDim = WhiteSecondaryFixedDim,
onSecondaryFixedVariant = WhiteOnSecondaryFixedVariant,
tertiaryFixed = WhiteTertiaryFixed,
onTertiaryFixed = WhiteOnTertiaryFixed,
tertiaryFixedDim = WhiteTertiaryFixedDim,
onTertiaryFixedVariant = WhiteOnTertiaryFixedVariant,
surfaceDim = WhiteSurfaceDim,
surfaceBright = WhiteSurfaceBright,
surfaceContainerLowest = WhiteSurfaceContainerLowest,
surfaceContainerLow = WhiteSurfaceContainerLow,
surfaceContainer = WhiteSurfaceContainer,
surfaceContainerHigh = WhiteSurfaceContainerHigh,
surfaceContainerHighest = WhiteSurfaceContainerHighest
)
// Dark color scheme
private val DarkColorScheme = darkColorScheme(
primary = DarkPrimary,
onPrimary = DarkOnPrimary,
primaryContainer = DarkPrimaryContainer,
onPrimaryContainer = DarkOnPrimaryContainer,
inversePrimary = DarkInversePrimary,
secondary = DarkSecondary,
onSecondary = DarkOnSecondary,
secondaryContainer = DarkSecondaryContainer,
onSecondaryContainer = DarkOnSecondaryContainer,
tertiary = DarkTertiary,
onTertiary = DarkOnTertiary,
tertiaryContainer = DarkTertiaryContainer,
onTertiaryContainer = DarkOnTertiaryContainer,
error = DarkError,
onError = DarkOnError,
errorContainer = DarkErrorContainer,
onErrorContainer = DarkOnErrorContainer,
background = DarkBackground,
onBackground = DarkOnBackground,
surface = DarkSurface,
onSurface = DarkOnSurface,
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = DarkOnSurfaceVariant,
inverseSurface = DarkInverseSurface,
inverseOnSurface = DarkInverseOnSurface,
outline = DarkOutline,
outlineVariant = DarkOutlineVariant,
scrim = DarkScrim,
surfaceTint = DarkSurfaceTint,
// Fixed colors
primaryFixed = DarkPrimaryFixed,
onPrimaryFixed = DarkOnPrimaryFixed,
primaryFixedDim = DarkPrimaryFixedDim,
onPrimaryFixedVariant = DarkOnPrimaryFixedVariant,
secondaryFixed = DarkSecondaryFixed,
onSecondaryFixed = DarkOnSecondaryFixed,
secondaryFixedDim = DarkSecondaryFixedDim,
onSecondaryFixedVariant = DarkOnSecondaryFixedVariant,
tertiaryFixed = DarkTertiaryFixed,
onTertiaryFixed = DarkOnTertiaryFixed,
tertiaryFixedDim = DarkTertiaryFixedDim,
onTertiaryFixedVariant = DarkOnTertiaryFixedVariant,
surfaceDim = DarkSurfaceDim,
surfaceBright = DarkSurfaceBright,
surfaceContainerLowest = DarkSurfaceContainerLowest,
surfaceContainerLow = DarkSurfaceContainerLow,
surfaceContainer = DarkSurfaceContainer,
surfaceContainerHigh = DarkSurfaceContainerHigh,
surfaceContainerHighest = DarkSurfaceContainerHighest
)
@Composable
fun MoscowHackatonTemplateTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = when {
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,40 @@
package com.prodhack.moscow2025.presentation.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R
val TinkoffSansFamily = FontFamily(
Font(
R.font.tinkoff_sans_bold,
FontWeight.Bold
),
Font(
R.font.tinkoff_sans_regular,
FontWeight.Normal
),
Font(
R.font.tinkoff_sans_medium,
FontWeight.Medium
)
)
val Typography = Typography(
titleLarge = TextStyle(
fontFamily = TinkoffSansFamily,
fontWeight = FontWeight.Bold
),
titleMedium = TextStyle(
fontFamily = TinkoffSansFamily,
fontWeight = FontWeight.Medium
),
labelLarge = TextStyle(
fontFamily = TinkoffSansFamily,
fontWeight = FontWeight.Normal
)
)
@@ -0,0 +1,9 @@
package com.prodhack.moscow2025.presentation.utils
fun <T> MutableSet<T>.toggleItem(item: T) {
if (item in this) {
remove(item)
} else {
add(item)
}
}
@@ -0,0 +1,3 @@
package com.prodhack.moscow2025.presentation.utils
fun String?.notNullOrBlank() = this != null && this.isNotBlank()
@@ -0,0 +1,55 @@
package com.prodhack.moscow2025.presentation.utils
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import java.util.Date
import java.util.Locale
import java.util.TimeZone
fun daysUntilTimestampZoned(targetTimestamp: Long, zoneId: ZoneId = ZoneId.systemDefault()): Int {
val now = Instant.now().atZone(zoneId)
val targetTime = Instant.ofEpochMilli(targetTimestamp).atZone(zoneId)
return ChronoUnit.DAYS.between(now, targetTime).toInt()
}
fun getStartOfDayTimestamp(date: Date): Long {
val localDate = date.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDate()
return localDate.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
}
fun getStartOfTodayTimestamp(): Long {
val today = LocalDate.now()
return today.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
}
fun timestampToDate(timestamp: Long, timeZone: TimeZone = TimeZone.getDefault()): String {
val date = Date(timestamp)
val formatter = SimpleDateFormat("dd.MM", Locale.getDefault())
formatter.timeZone = timeZone
return formatter.format(date)
}
fun timestampToDateWithYear(timestamp: Long, timeZone: TimeZone = TimeZone.getDefault()): String {
val date = Date(timestamp)
val formatter = SimpleDateFormat("dd.MM.YYYY", Locale.getDefault())
formatter.timeZone = timeZone
return formatter.format(date)
}
fun convertGMTToSystemTimezone(gmtTimestamp: Long): Long {
return getStartOfDayTimestamp(Date(gmtTimestamp))
}
fun timestampToIso(timestamp: Long): String {
return Instant.ofEpochMilli(timestamp).toString()
}
@@ -0,0 +1,226 @@
package com.prodhack.moscow2025.presentation.utils
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import com.prodhack.moscow2025.domain.utils.NetworkError
import com.prodhack.moscow2025.domain.utils.convertToNetworkError
import com.prodhack.moscow2025.presentation.utils.ui.placeholders.ErrorPlaceholder
import com.prodhack.moscow2025.presentation.utils.ui.placeholders.LoadingPlaceholder
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
sealed class UIState<T> {
class Idle<T> : UIState<T>()
class Loading<T> : UIState<T>()
class Error<T>(val error: NetworkError) : UIState<T>()
class Success<T>(val data: T) : UIState<T>()
fun <S> map(mapper: (T) -> S): UIState<S> {
return when (this) {
is Idle -> Idle()
is Loading -> Loading()
is Error -> Error(this.error)
is Success -> Success(mapper(this.data))
}
}
fun getOrNull(): T? = if (this is Success) {
data
} else {
null
}
val isSuccess: Boolean
get() = this is Success
}
interface ErrorCallbacks {
fun processConnectionError(networkError: NetworkError.Connection)
fun processUnexpectedError(networkError: NetworkError.Unexpected)
}
open class ErrorCollectorScope(
private val context: Context,
val navController: NavController,
private val errorCallbacks: ErrorCallbacks
) {
companion object {
private const val TAG = "ErrorCollectorScope"
}
@Composable
fun <T> Flow<UIState<T>>.collectAsStateWithCallbacks(
onInputError: ((NetworkError.InputError) -> Unit) = {
Toast.makeText(context, "Something went wrong", Toast.LENGTH_SHORT)
.show()
},
onUnexpectedError: ((NetworkError.Unexpected) -> Unit) = {},
onConnectionError: ((NetworkError.Connection) -> Unit) = {},
onLoading: (() -> Unit) = {},
onSuccess: (T) -> Unit = {}
): State<UIState<T>> = this.onEach {
when (it) {
is UIState.Loading -> {
onLoading()
}
is UIState.Error -> {
Log.e(TAG, "collected error ${it.error}")
when (it.error) {
is NetworkError.Connection -> {
errorCallbacks.processConnectionError(it.error)
onConnectionError.invoke(it.error)
}
is NetworkError.Unexpected -> {
errorCallbacks.processUnexpectedError(it.error)
onUnexpectedError.invoke(it.error)
}
is NetworkError.InputError -> onInputError.invoke(it.error)
}
}
is UIState.Success -> {
onSuccess.invoke(it.data)
}
else -> {}
}
}.collectAsState(UIState.Idle())
@Composable
fun <T> Flow<UIState<T>>.collectAsValueStateWithCallbacks(
onInputError: ((NetworkError.InputError) -> Unit) = {
Toast.makeText(context, "Something went wrong", Toast.LENGTH_SHORT)
.show()
},
onLoading: (() -> Unit) = {},
onSuccess: (T) -> Unit = {}
): State<T?> = this.map {
when (it) {
is UIState.Loading -> {
onLoading()
null
}
is UIState.Error -> {
Log.e(TAG, "collected error ${it.error}")
when (it.error) {
is NetworkError.Connection -> errorCallbacks.processConnectionError(it.error)
is NetworkError.Unexpected -> errorCallbacks.processUnexpectedError(it.error)
is NetworkError.InputError -> onInputError.invoke(it.error)
}
null
}
is UIState.Success -> {
onSuccess.invoke(it.data)
it.data
}
else -> {
null
}
}
}.collectAsState(null)
@Composable
fun <T> Flow<UIState<T>>.FoldUIStateWithGlobalCallbacks(
modifier: Modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp),
onIdle: @Composable () -> Unit = {},
onError: @Composable (NetworkError) -> Unit = { ErrorPlaceholder(modifier = modifier) { navController?.popBackStack() } },
onLoading: @Composable () -> Unit = { LoadingPlaceholder(modifier = modifier) },
onSuccess: @Composable (T) -> Unit
) {
val state = this.onEach {
if (it is UIState.Error) {
Log.e(TAG, "collected error ${it.error}")
when (it.error) {
is NetworkError.Connection -> errorCallbacks.processConnectionError(it.error)
is NetworkError.Unexpected -> errorCallbacks.processUnexpectedError(it.error)
else -> {}
}
}
}.collectAsState(initial = UIState.Idle()).value
when (state) {
is UIState.Idle -> {
onIdle()
}
is UIState.Error -> {
onError(state.error.convertToNetworkError())
}
is UIState.Loading -> {
onLoading()
}
is UIState.Success -> {
onSuccess(state.data)
}
}
}
@Composable
fun <T> UIState<T>.FoldUIStateWithGlobalCallbacks(
modifier: Modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp),
onIdle: @Composable () -> Unit = {},
onError: @Composable (NetworkError) -> Unit = { ErrorPlaceholder(modifier = modifier) { navController?.popBackStack() } },
onLoading: @Composable () -> Unit = { LoadingPlaceholder(modifier = modifier) },
onSuccess: @Composable (T) -> Unit
) {
if (this is UIState.Error) {
Log.e(TAG, "collected error ${this.error}")
when (error) {
is NetworkError.Connection -> errorCallbacks.processConnectionError(error)
is NetworkError.Unexpected -> errorCallbacks.processUnexpectedError(error)
else -> {}
}
}
when (this) {
is UIState.Idle -> {
onIdle()
}
is UIState.Error -> {
onError(error.convertToNetworkError())
}
is UIState.Loading -> {
onLoading()
}
is UIState.Success -> {
onSuccess.invoke(data)
}
}
}
}
@Composable
fun ErrorCollectorScope(
context: Context,
navController: NavController? = null,
errorCallbacks: ErrorCallbacks,
content: @Composable ErrorCollectorScope.() -> Unit
) {
ErrorCollectorScope(context, navController ?: rememberNavController(), errorCallbacks).content()
}
@@ -0,0 +1,78 @@
package com.prodhack.moscow2025.presentation.utils.base
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import com.prodhack.moscow2025.presentation.utils.UIState
import com.prodhack.moscow2025.domain.utils.convertToNetworkError
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
/**
* Base class for all [ViewModel]s
*/
abstract class BaseViewModel : ViewModel() {
/**
* Creates [MutableStateFlow] with [UIState] and the given initial value [UIState.Idle]
*/
@Suppress("FunctionName")
protected fun <T> MutableUIStateFlow(defaultValue: T? = null) =
MutableStateFlow<UIState<T>>(defaultValue?.let { UIState.Success(it) } ?: UIState.Idle())
/**
* Reset [MutableUIStateFlow] to [UIState.Idle]
*/
protected fun <T> MutableStateFlow<UIState<T>>.reset() {
value = UIState.Idle()
}
/**
* Collect network request
*
* @return [UIState] depending request result
*/
protected fun <T> Flow<Result<T>>.collectRequest(
state: MutableStateFlow<UIState<T>>,
) {
viewModelScope.launch {
state.value = UIState.Loading()
this@collectRequest.collect {
state.value = if (it.isSuccess) {
UIState.Success(it.getOrNull()!!)
} else {
UIState.Error(it.exceptionOrNull()!!.convertToNetworkError())
}
}
}
}
/**
* Collect network request
*
* @return [UIState] depending request result
*/
protected fun <T> Result<T>.collectRequest(
state: MutableStateFlow<UIState<T>>
) {
state.value = UIState.Loading()
state.value = if (isSuccess) {
UIState.Success(getOrNull()!!)
} else {
UIState.Error(exceptionOrNull()!!.convertToNetworkError())
}
}
/**
* Collect paging request
*/
protected fun <T : Any, S : Any> Flow<PagingData<T>>.collectPagingRequest(
mappedData: suspend (T) -> S
) = map { it.map { data -> mappedData(data) } }.cachedIn(viewModelScope)
}
@@ -0,0 +1,14 @@
package com.prodhack.moscow2025.presentation.utils
import android.graphics.Bitmap
import java.io.ByteArrayOutputStream
fun Bitmap.toByteArray(): ByteArray {
val stream = ByteArrayOutputStream() // Create a ByteArrayOutputStream
compress(
Bitmap.CompressFormat.JPEG,
100,
stream
) // Compress Bitmap to PNG with 100% quality
return stream.toByteArray() // Convert stream to byte array
}
@@ -0,0 +1,88 @@
package com.prodhack.moscow2025.presentation.utils.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DateRangePicker
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberDateRangePickerState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun DateRangePickerModal(
onDateRangeSelected: (Pair<Long?, Long?>) -> Unit,
onDismiss: () -> Unit
) {
val dateRangePickerState = rememberDateRangePickerState()
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
onDateRangeSelected(
Pair(
dateRangePickerState.selectedStartDateMillis,
dateRangePickerState.selectedEndDateMillis
)
)
onDismiss()
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
) {
DateRangePicker(
state = dateRangePickerState,
title = {
Text(
text = "Select date range"
)
},
showModeToggle = false,
modifier = Modifier
.fillMaxWidth()
.height(500.dp)
.padding(16.dp)
)
}
}
@Composable
fun DatePickerModal(
onDateSelected: (Long?) -> Unit,
onDismiss: () -> Unit
) {
val datePickerState = rememberDatePickerState()
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
onDateSelected(datePickerState.selectedDateMillis)
onDismiss()
}) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
) {
DatePicker(state = datePickerState)
}
}
@@ -0,0 +1,42 @@
package com.prodhack.moscow2025.presentation.utils.ui
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.debugInspectorInfo
fun Modifier.clickable(
rippleColor: Color? = null,
onClick: () -> Unit
) = composed(
inspectorInfo = debugInspectorInfo {
name = "clickable"
properties["rippleColor"] = rippleColor
properties["onClick"] = onClick
}
) {
this.clickable(
onClick = onClick,
indication = rippleColor?.let {
ripple(
color = it
)
} ?: LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }
)
}
@Composable
fun Modifier.noRippleClickable(
onClick: () -> Unit
) = this.clickable(
onClick = onClick,
interactionSource = remember { MutableInteractionSource() },
indication = null
)
@@ -0,0 +1,41 @@
package com.prodhack.moscow2025.presentation.utils.ui.placeholders
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.prodhack.moscow2025.presentation.theme.MoscowHackatonTemplateTheme
data class ErrorTexts(
val title: String = "Error",
val mainText: String = "Oh mio dio! \n" +
"Sembra che qualcosa non va",
val description: String = "Lavoreremo per sistemare le cose, ti chiediamo tornare più tardi."
)
@Composable
fun ErrorPlaceholder(
modifier: Modifier = Modifier,
showTop: Boolean = false,
small: Boolean = false,
showButton: Boolean = true,
errorTexts: ErrorTexts = ErrorTexts(),
actionText: String = "Ok",
onAction: () -> Unit
) {
Text("Error")
}
@Preview
@Composable
fun ErrorPlaceHolderPreview() {
MoscowHackatonTemplateTheme {
Scaffold {
ErrorPlaceholder(modifier = Modifier.padding(it), showTop = true) { }
}
}
}
@@ -0,0 +1,32 @@
package com.prodhack.moscow2025.presentation.utils.ui.placeholders
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.prodhack.moscow2025.presentation.theme.MoscowHackatonTemplateTheme
@Composable
fun LoadingPlaceholder(
modifier: Modifier = Modifier,
text: String = "Già quasi scaricato, per favore aspetta un po"
) {
Text(modifier = modifier, text = text)
}
@Preview
@Composable
private fun LoadingPlaceholderPreview() {
Scaffold { paddingValues ->
MoscowHackatonTemplateTheme() {
LoadingPlaceholder(
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues)
)
}
}
}
@@ -0,0 +1,13 @@
<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="M12.75,9C12.75,8.801 12.671,8.61 12.53,8.47C12.39,8.329 12.199,8.25 12,8.25C11.801,8.25 11.61,8.329 11.47,8.47C11.329,8.61 11.25,8.801 11.25,9V11.25H9C8.801,11.25 8.61,11.329 8.47,11.47C8.329,11.61 8.25,11.801 8.25,12C8.25,12.199 8.329,12.39 8.47,12.53C8.61,12.671 8.801,12.75 9,12.75H11.25V15C11.25,15.199 11.329,15.39 11.47,15.53C11.61,15.671 11.801,15.75 12,15.75C12.199,15.75 12.39,15.671 12.53,15.53C12.671,15.39 12.75,15.199 12.75,15V12.75H15C15.199,12.75 15.39,12.671 15.53,12.53C15.671,12.39 15.75,12.199 15.75,12C15.75,11.801 15.671,11.61 15.53,11.47C15.39,11.329 15.199,11.25 15,11.25H12.75V9Z"
android:fillColor="#BDEAF3"/>
<path
android:pathData="M12.057,1.25H11.943C9.634,1.25 7.825,1.25 6.413,1.44C4.969,1.634 3.829,2.04 2.934,2.934C2.039,3.829 1.634,4.969 1.44,6.414C1.25,7.825 1.25,9.634 1.25,11.943V12.057C1.25,14.366 1.25,16.175 1.44,17.587C1.634,19.031 2.04,20.171 2.934,21.066C3.829,21.961 4.969,22.366 6.414,22.56C7.825,22.75 9.634,22.75 11.943,22.75H12.057C14.366,22.75 16.175,22.75 17.587,22.56C19.031,22.366 20.171,21.96 21.066,21.066C21.961,20.171 22.366,19.031 22.56,17.586C22.75,16.175 22.75,14.366 22.75,12.057V11.943C22.75,9.634 22.75,7.825 22.56,6.413C22.366,4.969 21.96,3.829 21.066,2.934C20.171,2.039 19.031,1.634 17.586,1.44C16.175,1.25 14.366,1.25 12.057,1.25ZM3.995,3.995C4.565,3.425 5.335,3.098 6.614,2.926C7.914,2.752 9.622,2.75 12,2.75C14.378,2.75 16.086,2.752 17.386,2.926C18.665,3.098 19.436,3.426 20.006,3.995C20.575,4.565 20.902,5.335 21.074,6.614C21.248,7.914 21.25,9.622 21.25,12C21.25,14.378 21.248,16.086 21.074,17.386C20.902,18.665 20.574,19.436 20.005,20.006C19.435,20.575 18.665,20.902 17.386,21.074C16.086,21.248 14.378,21.25 12,21.25C9.622,21.25 7.914,21.248 6.614,21.074C5.335,20.902 4.564,20.574 3.994,20.005C3.425,19.435 3.098,18.665 2.926,17.386C2.752,16.086 2.75,14.378 2.75,12C2.75,9.622 2.752,7.914 2.926,6.614C3.098,5.335 3.426,4.565 3.995,3.995Z"
android:fillColor="#BDEAF3"
android:fillType="evenOdd"/>
</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="M4.43,8.512C4.494,8.437 4.572,8.376 4.66,8.331C4.748,8.287 4.844,8.26 4.942,8.252C5.04,8.245 5.139,8.257 5.233,8.287C5.326,8.318 5.413,8.367 5.488,8.431L12,14.012L18.512,8.431C18.664,8.309 18.857,8.251 19.051,8.269C19.245,8.287 19.424,8.38 19.551,8.528C19.677,8.675 19.742,8.867 19.73,9.061C19.718,9.255 19.632,9.438 19.488,9.569L12.488,15.569C12.352,15.686 12.179,15.75 12,15.75C11.821,15.75 11.648,15.686 11.512,15.569L4.512,9.569C4.361,9.44 4.268,9.255 4.253,9.057C4.237,8.859 4.302,8.663 4.431,8.512"
android:fillColor="#003828"
android:fillType="evenOdd"/>
</vector>
+13
View File
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="18"
android:viewportHeight="18">
<path
android:pathData="M12.75,10.5C12.949,10.5 13.14,10.421 13.28,10.28C13.421,10.14 13.5,9.949 13.5,9.75C13.5,9.551 13.421,9.36 13.28,9.22C13.14,9.079 12.949,9 12.75,9C12.551,9 12.36,9.079 12.22,9.22C12.079,9.36 12,9.551 12,9.75C12,9.949 12.079,10.14 12.22,10.28C12.36,10.421 12.551,10.5 12.75,10.5ZM12.75,13.5C12.949,13.5 13.14,13.421 13.28,13.28C13.421,13.14 13.5,12.949 13.5,12.75C13.5,12.551 13.421,12.36 13.28,12.22C13.14,12.079 12.949,12 12.75,12C12.551,12 12.36,12.079 12.22,12.22C12.079,12.36 12,12.551 12,12.75C12,12.949 12.079,13.14 12.22,13.28C12.36,13.421 12.551,13.5 12.75,13.5ZM9.75,9.75C9.75,9.949 9.671,10.14 9.53,10.28C9.39,10.421 9.199,10.5 9,10.5C8.801,10.5 8.61,10.421 8.47,10.28C8.329,10.14 8.25,9.949 8.25,9.75C8.25,9.551 8.329,9.36 8.47,9.22C8.61,9.079 8.801,9 9,9C9.199,9 9.39,9.079 9.53,9.22C9.671,9.36 9.75,9.551 9.75,9.75ZM9.75,12.75C9.75,12.949 9.671,13.14 9.53,13.28C9.39,13.421 9.199,13.5 9,13.5C8.801,13.5 8.61,13.421 8.47,13.28C8.329,13.14 8.25,12.949 8.25,12.75C8.25,12.551 8.329,12.36 8.47,12.22C8.61,12.079 8.801,12 9,12C9.199,12 9.39,12.079 9.53,12.22C9.671,12.36 9.75,12.551 9.75,12.75ZM5.25,10.5C5.449,10.5 5.64,10.421 5.78,10.28C5.921,10.14 6,9.949 6,9.75C6,9.551 5.921,9.36 5.78,9.22C5.64,9.079 5.449,9 5.25,9C5.051,9 4.86,9.079 4.72,9.22C4.579,9.36 4.5,9.551 4.5,9.75C4.5,9.949 4.579,10.14 4.72,10.28C4.86,10.421 5.051,10.5 5.25,10.5ZM5.25,13.5C5.449,13.5 5.64,13.421 5.78,13.28C5.921,13.14 6,12.949 6,12.75C6,12.551 5.921,12.36 5.78,12.22C5.64,12.079 5.449,12 5.25,12C5.051,12 4.86,12.079 4.72,12.22C4.579,12.36 4.5,12.551 4.5,12.75C4.5,12.949 4.579,13.14 4.72,13.28C4.86,13.421 5.051,13.5 5.25,13.5Z"
android:fillColor="#C1C9BE"/>
<path
android:pathData="M5.25,1.313C5.399,1.313 5.542,1.372 5.648,1.477C5.753,1.583 5.812,1.726 5.812,1.875V2.447C6.309,2.438 6.856,2.438 7.457,2.438H10.542C11.144,2.438 11.691,2.438 12.188,2.447V1.875C12.188,1.726 12.247,1.583 12.352,1.477C12.458,1.372 12.601,1.313 12.75,1.313C12.899,1.313 13.042,1.372 13.148,1.477C13.253,1.583 13.313,1.726 13.313,1.875V2.495C13.507,2.51 13.692,2.529 13.867,2.552C14.746,2.671 15.458,2.92 16.019,3.481C16.58,4.043 16.829,4.754 16.948,5.633C17.063,6.488 17.063,7.58 17.063,8.958V10.542C17.063,11.92 17.063,13.012 16.948,13.867C16.829,14.746 16.58,15.458 16.019,16.019C15.458,16.58 14.746,16.829 13.867,16.948C13.012,17.063 11.92,17.063 10.542,17.063H7.459C6.08,17.063 4.988,17.063 4.134,16.948C3.255,16.829 2.543,16.58 1.981,16.019C1.42,15.458 1.171,14.746 1.053,13.867C0.938,13.012 0.938,11.92 0.938,10.542V8.958C0.938,7.58 0.938,6.488 1.053,5.633C1.171,4.754 1.42,4.043 1.981,3.481C2.543,2.92 3.255,2.671 4.134,2.552C4.309,2.529 4.494,2.51 4.688,2.495V1.875C4.688,1.726 4.747,1.583 4.853,1.478C4.958,1.372 5.101,1.313 5.25,1.313ZM4.282,3.668C3.529,3.769 3.094,3.959 2.776,4.277C2.459,4.594 2.269,5.029 2.167,5.782C2.15,5.91 2.136,6.045 2.124,6.187H15.876C15.864,6.045 15.849,5.91 15.833,5.782C15.731,5.028 15.541,4.593 15.224,4.276C14.906,3.958 14.471,3.768 13.717,3.667C12.946,3.563 11.93,3.562 10.5,3.562H7.5C6.07,3.562 5.054,3.564 4.282,3.668ZM2.062,9C2.062,8.359 2.062,7.802 2.072,7.313H15.928C15.938,7.802 15.938,8.359 15.938,9V10.5C15.938,11.93 15.936,12.946 15.833,13.717C15.731,14.471 15.541,14.906 15.224,15.224C14.906,15.541 14.471,15.731 13.717,15.833C12.946,15.936 11.93,15.938 10.5,15.938H7.5C6.07,15.938 5.054,15.936 4.282,15.833C3.529,15.731 3.094,15.541 2.776,15.224C2.459,14.906 2.269,14.471 2.167,13.717C2.064,12.946 2.062,11.93 2.062,10.5V9Z"
android:fillColor="#C1C9BE"
android:fillType="evenOdd"/>
</vector>
+10
View File
@@ -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="M12.05,1.25H11.95C11.286,1.25 10.713,1.25 10.254,1.312C9.763,1.378 9.291,1.527 8.909,1.909C8.527,2.291 8.378,2.763 8.312,3.254C8.25,3.713 8.25,4.286 8.25,4.951V7.378C8.009,7.294 7.755,7.251 7.5,7.25H4.5C4.205,7.25 3.912,7.308 3.639,7.421C3.366,7.534 3.118,7.7 2.909,7.909C2.7,8.118 2.534,8.366 2.421,8.639C2.308,8.912 2.25,9.205 2.25,9.5V21.25H2C1.801,21.25 1.61,21.329 1.47,21.47C1.329,21.61 1.25,21.801 1.25,22C1.25,22.199 1.329,22.39 1.47,22.53C1.61,22.671 1.801,22.75 2,22.75H22C22.199,22.75 22.39,22.671 22.53,22.53C22.671,22.39 22.75,22.199 22.75,22C22.75,21.801 22.671,21.61 22.53,21.47C22.39,21.329 22.199,21.25 22,21.25H21.75V14.5C21.75,13.903 21.513,13.331 21.091,12.909C20.669,12.487 20.097,12.25 19.5,12.25H16.5C16.236,12.251 15.986,12.294 15.75,12.378V4.951C15.75,4.286 15.75,3.713 15.688,3.254C15.622,2.763 15.473,2.291 15.091,1.909C14.709,1.527 14.238,1.378 13.746,1.312C13.287,1.25 12.714,1.25 12.049,1.25M20.249,21.25V14.5C20.249,14.301 20.17,14.11 20.029,13.97C19.889,13.829 19.698,13.75 19.499,13.75H16.499C16.3,13.75 16.109,13.829 15.969,13.97C15.828,14.11 15.749,14.301 15.749,14.5V21.25H20.249ZM14.249,21.25V5C14.249,4.272 14.247,3.8 14.201,3.454C14.157,3.129 14.086,3.027 14.029,2.97C13.972,2.913 13.87,2.842 13.545,2.798C13.198,2.752 12.727,2.75 11.999,2.75C11.271,2.75 10.799,2.752 10.453,2.798C10.128,2.842 10.026,2.913 9.969,2.97C9.912,3.027 9.841,3.129 9.797,3.454C9.751,3.801 9.749,4.272 9.749,5V21.25H14.249ZM8.249,21.25V9.5C8.249,9.301 8.17,9.11 8.029,8.97C7.889,8.829 7.698,8.75 7.499,8.75H4.499C4.3,8.75 4.109,8.829 3.969,8.97C3.828,9.11 3.749,9.301 3.749,9.5V21.25H8.249Z"
android:fillColor="#B7F1B9"
android:fillType="evenOdd"/>
</vector>
@@ -0,0 +1,13 @@
<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="M20,7L10,17L5,12"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M9.121,1.042H10.879C12.018,1.042 12.938,1.042 13.66,1.139C14.41,1.239 15.042,1.456 15.543,1.957C15.792,2.206 15.97,2.487 16.099,2.798C16.876,2.895 17.528,3.108 18.043,3.623C18.545,4.125 18.76,4.757 18.862,5.507C18.958,6.229 18.958,7.148 18.958,8.288V11.712C18.958,12.852 18.958,13.771 18.862,14.493C18.76,15.243 18.545,15.875 18.043,16.377C17.528,16.892 16.877,17.105 16.099,17.202C15.97,17.513 15.792,17.794 15.543,18.043C15.042,18.545 14.41,18.76 13.66,18.862C12.938,18.958 12.018,18.958 10.879,18.958H9.121C7.982,18.958 7.062,18.958 6.34,18.862C5.59,18.76 4.958,18.545 4.457,18.043C4.218,17.802 4.029,17.517 3.901,17.202C3.124,17.105 2.472,16.892 1.957,16.377C1.455,15.875 1.24,15.243 1.139,14.493C1.042,13.771 1.042,12.852 1.042,11.712V8.288C1.042,7.148 1.042,6.229 1.139,5.507C1.239,4.757 1.456,4.125 1.957,3.623C2.472,3.108 3.123,2.895 3.901,2.798C4.029,2.484 4.218,2.198 4.457,1.957C4.958,1.455 5.59,1.24 6.34,1.139C7.062,1.042 7.982,1.042 9.121,1.042ZM3.607,4.117C3.242,4.205 3.015,4.333 2.841,4.508C2.61,4.738 2.46,5.062 2.377,5.673C2.293,6.303 2.292,7.138 2.292,8.333V11.667C2.292,12.863 2.293,13.698 2.377,14.327C2.46,14.938 2.611,15.262 2.841,15.493C3.015,15.667 3.242,15.795 3.607,15.883C3.542,15.207 3.542,14.378 3.542,13.379V6.621C3.542,5.623 3.542,4.793 3.607,4.117ZM16.393,15.883C16.757,15.795 16.985,15.667 17.159,15.493C17.39,15.262 17.54,14.938 17.622,14.326C17.707,13.698 17.708,12.863 17.708,11.667V8.334C17.708,7.138 17.707,6.303 17.622,5.673C17.54,5.062 17.389,4.738 17.159,4.508C16.985,4.333 16.757,4.205 16.392,4.117C16.458,4.793 16.458,5.623 16.458,6.621V13.379C16.458,14.377 16.458,15.207 16.393,15.883ZM6.507,2.378C5.895,2.46 5.572,2.611 5.341,2.841C5.11,3.072 4.96,3.395 4.877,4.008C4.793,4.635 4.792,5.47 4.792,6.667V13.333C4.792,14.529 4.793,15.363 4.877,15.993C4.96,16.605 5.111,16.928 5.341,17.159C5.572,17.39 5.895,17.54 6.507,17.622C7.136,17.707 7.971,17.708 9.167,17.708H10.833C12.029,17.708 12.864,17.707 13.493,17.622C14.105,17.54 14.428,17.389 14.659,17.159C14.89,16.928 15.04,16.605 15.123,15.993C15.207,15.363 15.208,14.529 15.208,13.333V6.667C15.208,5.471 15.207,4.636 15.123,4.007C15.04,3.395 14.889,3.072 14.659,2.841C14.428,2.61 14.105,2.46 13.493,2.378C12.864,2.293 12.029,2.292 10.833,2.292H9.167C7.971,2.292 7.136,2.293 6.507,2.378ZM6.875,7.5C6.875,7.334 6.941,7.175 7.058,7.058C7.175,6.941 7.334,6.875 7.5,6.875H12.5C12.666,6.875 12.825,6.941 12.942,7.058C13.059,7.175 13.125,7.334 13.125,7.5C13.125,7.666 13.059,7.825 12.942,7.942C12.825,8.059 12.666,8.125 12.5,8.125H7.5C7.334,8.125 7.175,8.059 7.058,7.942C6.941,7.825 6.875,7.666 6.875,7.5ZM6.875,10.833C6.875,10.668 6.941,10.509 7.058,10.391C7.175,10.274 7.334,10.208 7.5,10.208H12.5C12.666,10.208 12.825,10.274 12.942,10.391C13.059,10.509 13.125,10.668 13.125,10.833C13.125,10.999 13.059,11.158 12.942,11.275C12.825,11.392 12.666,11.458 12.5,11.458H7.5C7.334,11.458 7.175,11.392 7.058,11.275C6.941,11.158 6.875,10.999 6.875,10.833ZM6.875,14.167C6.875,14.001 6.941,13.842 7.058,13.725C7.175,13.608 7.334,13.542 7.5,13.542H10C10.166,13.542 10.325,13.608 10.442,13.725C10.559,13.842 10.625,14.001 10.625,14.167C10.625,14.332 10.559,14.491 10.442,14.609C10.325,14.726 10.166,14.792 10,14.792H7.5C7.334,14.792 7.175,14.726 7.058,14.609C6.941,14.491 6.875,14.332 6.875,14.167Z"
android:fillColor="#B7F1B9"
android:fillType="evenOdd"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="33dp"
android:height="33dp"
android:viewportWidth="33"
android:viewportHeight="33">
<path
android:pathData="M7.906,1.375C8.18,1.375 8.442,1.484 8.635,1.677C8.829,1.87 8.938,2.133 8.938,2.406V4.95L11.302,4.477C13.572,4.025 15.925,4.241 18.074,5.098L18.355,5.21C20.501,6.069 22.863,6.229 25.106,5.669C25.349,5.608 25.603,5.604 25.848,5.656C26.094,5.708 26.324,5.815 26.522,5.969C26.719,6.124 26.879,6.321 26.989,6.546C27.099,6.771 27.156,7.019 27.156,7.27V17.399C27.156,18.285 26.553,19.058 25.693,19.272L25.399,19.345C22.966,19.953 20.403,19.779 18.074,18.848C15.925,17.991 13.573,17.775 11.304,18.227L8.938,18.7V29.906C8.938,30.18 8.829,30.442 8.635,30.635C8.442,30.829 8.18,30.938 7.906,30.938C7.633,30.938 7.37,30.829 7.177,30.635C6.984,30.442 6.875,30.18 6.875,29.906V2.406C6.875,2.133 6.984,1.87 7.177,1.677C7.37,1.484 7.633,1.375 7.906,1.375Z"
android:fillColor="#B7F1B9"/>
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="33dp"
android:height="33dp"
android:viewportWidth="33"
android:viewportHeight="33">
<path
android:pathData="M6.875,30.25V19.25M6.875,19.25V5.5M6.875,19.25L10.271,18.571C12.541,18.118 14.894,18.334 17.043,19.192C19.372,20.123 21.935,20.297 24.368,19.689L24.662,19.616C25.08,19.511 25.451,19.271 25.716,18.931C25.981,18.592 26.125,18.174 26.125,17.743V7.613C26.125,7.363 26.068,7.116 25.958,6.89C25.848,6.665 25.688,6.468 25.491,6.314C25.293,6.159 25.063,6.052 24.818,6C24.573,5.948 24.319,5.952 24.076,6.013C21.833,6.573 19.47,6.413 17.324,5.554L17.043,5.442C14.894,4.585 12.542,4.369 10.273,4.821L6.875,5.5M6.875,5.5V2.75"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#B7F1B9"
android:strokeLineCap="round"/>
</vector>
+20
View File
@@ -0,0 +1,20 @@
<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="M12,1.25C10.74,1.25 9.532,1.75 8.641,2.641C7.75,3.532 7.25,4.74 7.25,6C7.25,7.26 7.75,8.468 8.641,9.359C9.532,10.25 10.74,10.75 12,10.75C13.26,10.75 14.468,10.25 15.359,9.359C16.25,8.468 16.75,7.26 16.75,6C16.75,4.74 16.25,3.532 15.359,2.641C14.468,1.75 13.26,1.25 12,1.25ZM8.75,6C8.75,5.138 9.092,4.311 9.702,3.702C10.311,3.092 11.138,2.75 12,2.75C12.862,2.75 13.689,3.092 14.298,3.702C14.908,4.311 15.25,5.138 15.25,6C15.25,6.862 14.908,7.689 14.298,8.298C13.689,8.908 12.862,9.25 12,9.25C11.138,9.25 10.311,8.908 9.702,8.298C9.092,7.689 8.75,6.862 8.75,6Z"
android:fillColor="#B7F1B9"
android:fillType="evenOdd"/>
<path
android:pathData="M18,3.25C17.801,3.25 17.61,3.329 17.47,3.47C17.329,3.61 17.25,3.801 17.25,4C17.25,4.199 17.329,4.39 17.47,4.53C17.61,4.671 17.801,4.75 18,4.75C19.377,4.75 20.25,5.656 20.25,6.5C20.25,7.344 19.377,8.25 18,8.25C17.801,8.25 17.61,8.329 17.47,8.47C17.329,8.61 17.25,8.801 17.25,9C17.25,9.199 17.329,9.39 17.47,9.53C17.61,9.671 17.801,9.75 18,9.75C19.937,9.75 21.75,8.417 21.75,6.5C21.75,4.583 19.937,3.25 18,3.25ZM6.75,4C6.75,3.801 6.671,3.61 6.53,3.47C6.39,3.329 6.199,3.25 6,3.25C4.063,3.25 2.25,4.583 2.25,6.5C2.25,8.417 4.063,9.75 6,9.75C6.199,9.75 6.39,9.671 6.53,9.53C6.671,9.39 6.75,9.199 6.75,9C6.75,8.801 6.671,8.61 6.53,8.47C6.39,8.329 6.199,8.25 6,8.25C4.624,8.25 3.75,7.344 3.75,6.5C3.75,5.656 4.624,4.75 6,4.75C6.199,4.75 6.39,4.671 6.53,4.53C6.671,4.39 6.75,4.199 6.75,4Z"
android:fillColor="#B7F1B9"/>
<path
android:pathData="M12,12.25C10.216,12.25 8.566,12.73 7.341,13.547C6.121,14.361 5.25,15.567 5.25,17C5.25,18.433 6.121,19.64 7.341,20.453C8.566,21.269 10.216,21.75 12,21.75C13.784,21.75 15.434,21.27 16.659,20.453C17.879,19.639 18.75,18.433 18.75,17C18.75,15.567 17.878,14.36 16.659,13.547C15.434,12.731 13.784,12.25 12,12.25ZM6.75,17C6.75,16.224 7.222,15.43 8.173,14.796C9.12,14.165 10.471,13.75 12,13.75C13.53,13.75 14.88,14.165 15.827,14.796C16.778,15.43 17.25,16.224 17.25,17C17.25,17.776 16.778,18.57 15.827,19.204C14.88,19.835 13.529,20.25 12,20.25C10.47,20.25 9.12,19.835 8.173,19.204C7.222,18.57 6.75,17.776 6.75,17Z"
android:fillColor="#B7F1B9"
android:fillType="evenOdd"/>
<path
android:pathData="M19.267,13.84C19.288,13.744 19.328,13.652 19.384,13.571C19.44,13.49 19.512,13.421 19.595,13.368C19.678,13.315 19.771,13.278 19.868,13.261C19.965,13.244 20.065,13.246 20.161,13.267C21.122,13.478 21.989,13.859 22.633,14.386C23.276,14.912 23.75,15.636 23.75,16.5C23.75,17.365 23.276,18.088 22.633,18.614C21.989,19.141 21.123,19.522 20.161,19.733C20.065,19.754 19.965,19.756 19.868,19.739C19.771,19.722 19.679,19.685 19.596,19.632C19.513,19.579 19.441,19.51 19.384,19.429C19.328,19.348 19.288,19.257 19.267,19.161C19.246,19.065 19.244,18.965 19.261,18.868C19.278,18.771 19.315,18.678 19.368,18.596C19.421,18.513 19.49,18.441 19.571,18.384C19.652,18.328 19.743,18.288 19.839,18.267C20.632,18.094 21.265,17.795 21.683,17.453C22.101,17.111 22.25,16.776 22.25,16.5C22.25,16.224 22.101,15.89 21.683,15.547C21.265,15.204 20.632,14.907 19.839,14.733C19.743,14.712 19.652,14.672 19.571,14.616C19.49,14.559 19.421,14.488 19.368,14.405C19.315,14.321 19.278,14.229 19.261,14.132C19.244,14.035 19.246,13.935 19.267,13.839M3.84,13.267C4.034,13.225 4.238,13.261 4.405,13.369C4.573,13.476 4.691,13.646 4.733,13.84C4.775,14.034 4.739,14.238 4.631,14.405C4.524,14.573 4.354,14.691 4.16,14.733C3.368,14.906 2.735,15.205 2.317,15.547C1.899,15.889 1.75,16.224 1.75,16.5C1.75,16.776 1.899,17.11 2.317,17.453C2.735,17.796 3.368,18.093 4.161,18.267C4.355,18.31 4.525,18.428 4.632,18.596C4.739,18.763 4.776,18.967 4.733,19.161C4.69,19.355 4.572,19.525 4.404,19.632C4.237,19.739 4.033,19.776 3.839,19.733C2.877,19.522 2.011,19.141 1.367,18.614C0.724,18.088 0.25,17.364 0.25,16.5C0.25,15.635 0.724,14.912 1.367,14.386C2.011,13.859 2.878,13.478 3.84,13.267Z"
android:fillColor="#B7F1B9"/>
</vector>
+13
View File
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="30"
android:viewportHeight="30">
<path
android:pathData="M14.063,22.5C14.063,22.749 14.161,22.987 14.337,23.163C14.513,23.339 14.751,23.438 15,23.438C15.249,23.438 15.487,23.339 15.663,23.163C15.839,22.987 15.938,22.749 15.938,22.5V18.75C15.938,18.501 15.839,18.263 15.663,18.087C15.487,17.911 15.249,17.813 15,17.813C14.751,17.813 14.513,17.911 14.337,18.087C14.161,18.263 14.063,18.501 14.063,18.75V22.5Z"
android:fillColor="#E0E4DB"/>
<path
android:pathData="M15,1.563C14.115,1.563 13.311,1.816 12.438,2.24C11.595,2.65 10.62,3.255 9.404,4.01L6.82,5.614C5.67,6.329 4.746,6.901 4.036,7.445C3.3,8.007 2.735,8.583 2.326,9.329C1.919,10.073 1.736,10.865 1.648,11.801C1.563,12.708 1.563,13.818 1.563,15.209V17.225C1.563,19.605 1.563,21.484 1.754,22.952C1.949,24.459 2.361,25.675 3.291,26.636C4.225,27.603 5.412,28.035 6.885,28.239C8.31,28.438 10.132,28.438 12.427,28.438H17.573C19.868,28.438 21.69,28.438 23.115,28.239C24.586,28.035 25.775,27.603 26.71,26.636C27.639,25.675 28.051,24.459 28.247,22.952C28.438,21.484 28.438,19.605 28.438,17.225V15.209C28.438,13.818 28.438,12.709 28.353,11.801C28.265,10.864 28.081,10.073 27.674,9.329C27.265,8.583 26.699,8.009 25.964,7.445C25.254,6.9 24.331,6.329 23.18,5.614L20.596,4.01C19.38,3.255 18.405,2.65 17.561,2.24C16.69,1.815 15.886,1.563 15,1.563ZM10.35,5.63C11.619,4.843 12.512,4.29 13.259,3.926C13.985,3.572 14.5,3.438 15,3.438C15.5,3.438 16.015,3.572 16.741,3.926C17.489,4.289 18.381,4.843 19.65,5.63L22.15,7.181C23.351,7.927 24.195,8.451 24.825,8.934C25.436,9.403 25.788,9.789 26.029,10.229C26.27,10.67 26.411,11.186 26.485,11.976C26.561,12.786 26.563,13.807 26.563,15.255V17.156C26.563,19.619 26.56,21.376 26.388,22.71C26.218,24.02 25.896,24.78 25.362,25.334C24.831,25.882 24.109,26.209 22.858,26.382C21.575,26.56 19.884,26.563 17.5,26.563H12.5C10.115,26.563 8.425,26.56 7.142,26.382C5.891,26.208 5.169,25.882 4.639,25.333C4.104,24.78 3.783,24.02 3.614,22.71C3.439,21.376 3.438,19.62 3.438,17.156V15.255C3.438,13.807 3.438,12.786 3.515,11.976C3.589,11.186 3.73,10.67 3.971,10.229C4.213,9.789 4.564,9.403 5.176,8.934C5.805,8.451 6.649,7.927 7.85,7.181L10.35,5.63Z"
android:fillColor="#E0E4DB"
android:fillType="evenOdd"/>
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="25dp"
android:height="25dp"
android:viewportWidth="25"
android:viewportHeight="25">
<path
android:pathData="M11.825,2.828C10.644,2.828 9.474,3.06 8.382,3.513C7.291,3.965 6.299,4.628 5.463,5.463C4.628,6.299 3.965,7.29 3.513,8.382C3.061,9.474 2.828,10.644 2.828,11.825C2.828,13.007 3.061,14.177 3.513,15.269C3.965,16.36 4.628,17.352 5.463,18.188C6.299,19.023 7.291,19.686 8.382,20.138C9.474,20.59 10.644,20.823 11.825,20.823C14.212,20.823 16.5,19.875 18.188,18.188C19.875,16.5 20.823,14.212 20.823,11.825C20.823,9.439 19.875,7.15 18.188,5.463C16.5,3.776 14.212,2.828 11.825,2.828ZM1.285,11.825C1.285,6.005 6.005,1.285 11.825,1.285C17.646,1.285 22.365,6.005 22.365,11.825C22.365,14.458 21.4,16.866 19.804,18.713L23.167,22.077C23.243,22.148 23.304,22.233 23.346,22.328C23.388,22.422 23.411,22.524 23.413,22.628C23.414,22.732 23.396,22.834 23.357,22.93C23.318,23.026 23.26,23.114 23.187,23.187C23.114,23.26 23.027,23.318 22.931,23.357C22.834,23.395 22.732,23.414 22.628,23.413C22.524,23.411 22.422,23.388 22.328,23.346C22.233,23.304 22.148,23.243 22.077,23.167L18.713,19.804C16.801,21.46 14.355,22.37 11.825,22.365C6.005,22.365 1.285,17.645 1.285,11.825Z"
android:fillColor="#003828"
android:fillType="evenOdd"/>
</vector>
+13
View File
@@ -0,0 +1,13 @@
<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="M15,12.75C15.199,12.75 15.39,12.671 15.53,12.53C15.671,12.39 15.75,12.199 15.75,12C15.75,11.801 15.671,11.61 15.53,11.47C15.39,11.329 15.199,11.25 15,11.25H9C8.801,11.25 8.61,11.329 8.47,11.47C8.329,11.61 8.25,11.801 8.25,12C8.25,12.199 8.329,12.39 8.47,12.53C8.61,12.671 8.801,12.75 9,12.75H15Z"
android:fillColor="#DEE4DF"/>
<path
android:pathData="M12.057,1.25H11.943C9.634,1.25 7.825,1.25 6.413,1.44C4.969,1.634 3.829,2.04 2.934,2.934C2.039,3.829 1.634,4.969 1.44,6.414C1.25,7.825 1.25,9.634 1.25,11.943V12.057C1.25,14.366 1.25,16.175 1.44,17.587C1.634,19.031 2.04,20.171 2.934,21.066C3.829,21.961 4.969,22.366 6.414,22.56C7.825,22.75 9.634,22.75 11.943,22.75H12.057C14.366,22.75 16.175,22.75 17.587,22.56C19.031,22.366 20.171,21.96 21.066,21.066C21.961,20.171 22.366,19.031 22.56,17.586C22.75,16.175 22.75,14.366 22.75,12.057V11.943C22.75,9.634 22.75,7.825 22.56,6.413C22.366,4.969 21.96,3.829 21.066,2.934C20.171,2.039 19.031,1.634 17.586,1.44C16.175,1.25 14.366,1.25 12.057,1.25ZM3.995,3.995C4.565,3.425 5.335,3.098 6.614,2.926C7.914,2.752 9.622,2.75 12,2.75C14.378,2.75 16.086,2.752 17.386,2.926C18.665,3.098 19.436,3.426 20.006,3.995C20.575,4.565 20.902,5.335 21.074,6.614C21.248,7.914 21.25,9.622 21.25,12C21.25,14.378 21.248,16.086 21.074,17.386C20.902,18.665 20.574,19.436 20.005,20.006C19.435,20.575 18.665,20.902 17.386,21.074C16.086,21.248 14.378,21.25 12,21.25C9.622,21.25 7.914,21.248 6.614,21.074C5.335,20.902 4.564,20.574 3.994,20.005C3.425,19.435 3.098,18.665 2.926,17.386C2.752,16.086 2.75,14.378 2.75,12C2.75,9.622 2.752,7.914 2.926,6.614C3.098,5.335 3.426,4.565 3.995,3.995Z"
android:fillColor="#DEE4DF"
android:fillType="evenOdd"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<vector android:height="24dp" android:viewportWidth="24"
android:viewportHeight="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM19,17v-6c0,-3.07 -1.64,-5.64 -4.5,-6.32L14.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v6l-2,2v1h16v-1l-2,-2z"/>
</vector>
+13
View File
@@ -0,0 +1,13 @@
<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="M12.75,9C12.75,8.801 12.671,8.61 12.53,8.47C12.39,8.329 12.199,8.25 12,8.25C11.801,8.25 11.61,8.329 11.47,8.47C11.329,8.61 11.25,8.801 11.25,9V11.25H9C8.801,11.25 8.61,11.329 8.47,11.47C8.329,11.61 8.25,11.801 8.25,12C8.25,12.199 8.329,12.39 8.47,12.53C8.61,12.671 8.801,12.75 9,12.75H11.25V15C11.25,15.199 11.329,15.39 11.47,15.53C11.61,15.671 11.801,15.75 12,15.75C12.199,15.75 12.39,15.671 12.53,15.53C12.671,15.39 12.75,15.199 12.75,15V12.75H15C15.199,12.75 15.39,12.671 15.53,12.53C15.671,12.39 15.75,12.199 15.75,12C15.75,11.801 15.671,11.61 15.53,11.47C15.39,11.329 15.199,11.25 15,11.25H12.75V9Z"
android:fillColor="#DEE4DF"/>
<path
android:pathData="M12.057,1.25H11.943C9.634,1.25 7.825,1.25 6.413,1.44C4.969,1.634 3.829,2.04 2.934,2.934C2.039,3.829 1.634,4.969 1.44,6.414C1.25,7.825 1.25,9.634 1.25,11.943V12.057C1.25,14.366 1.25,16.175 1.44,17.587C1.634,19.031 2.04,20.171 2.934,21.066C3.829,21.961 4.969,22.366 6.414,22.56C7.825,22.75 9.634,22.75 11.943,22.75H12.057C14.366,22.75 16.175,22.75 17.587,22.56C19.031,22.366 20.171,21.96 21.066,21.066C21.961,20.171 22.366,19.031 22.56,17.586C22.75,16.175 22.75,14.366 22.75,12.057V11.943C22.75,9.634 22.75,7.825 22.56,6.413C22.366,4.969 21.96,3.829 21.066,2.934C20.171,2.039 19.031,1.634 17.586,1.44C16.175,1.25 14.366,1.25 12.057,1.25ZM3.995,3.995C4.565,3.425 5.335,3.098 6.614,2.926C7.914,2.752 9.622,2.75 12,2.75C14.378,2.75 16.086,2.752 17.386,2.926C18.665,3.098 19.436,3.426 20.006,3.995C20.575,4.565 20.902,5.335 21.074,6.614C21.248,7.914 21.25,9.622 21.25,12C21.25,14.378 21.248,16.086 21.074,17.386C20.902,18.665 20.574,19.436 20.005,20.006C19.435,20.575 18.665,20.902 17.386,21.074C16.086,21.248 14.378,21.25 12,21.25C9.622,21.25 7.914,21.248 6.614,21.074C5.335,20.902 4.564,20.574 3.994,20.005C3.425,19.435 3.098,18.665 2.926,17.386C2.752,16.086 2.75,14.378 2.75,12C2.75,9.622 2.752,7.914 2.926,6.614C3.098,5.335 3.426,4.565 3.995,3.995Z"
android:fillColor="#DEE4DF"
android:fillType="evenOdd"/>
</vector>
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="30"
android:viewportHeight="30">
<path
android:pathData="M15,1.563C13.425,1.563 11.915,2.188 10.802,3.302C9.688,4.415 9.063,5.925 9.063,7.5C9.063,9.075 9.688,10.585 10.802,11.698C11.915,12.812 13.425,13.438 15,13.438C16.575,13.438 18.085,12.812 19.198,11.698C20.312,10.585 20.938,9.075 20.938,7.5C20.938,5.925 20.312,4.415 19.198,3.302C18.085,2.188 16.575,1.563 15,1.563ZM10.938,7.5C10.938,6.423 11.366,5.389 12.127,4.627C12.889,3.866 13.923,3.438 15,3.438C16.077,3.438 17.111,3.866 17.873,4.627C18.635,5.389 19.063,6.423 19.063,7.5C19.063,8.577 18.635,9.611 17.873,10.373C17.111,11.134 16.077,11.563 15,11.563C13.923,11.563 12.889,11.134 12.127,10.373C11.366,9.611 10.938,8.577 10.938,7.5ZM15,15.313C12.109,15.313 9.444,15.97 7.47,17.08C5.525,18.175 4.063,19.833 4.063,21.875V22.003C4.061,23.455 4.06,25.278 5.659,26.58C6.445,27.22 7.546,27.676 9.034,27.976C10.524,28.279 12.468,28.438 15,28.438C17.533,28.438 19.475,28.279 20.968,27.976C22.455,27.676 23.555,27.22 24.343,26.58C25.941,25.278 25.939,23.455 25.938,22.003V21.875C25.938,19.833 24.475,18.175 22.531,17.08C20.556,15.97 17.893,15.313 15,15.313ZM5.938,21.875C5.938,20.811 6.715,19.656 8.389,18.715C10.034,17.79 12.369,17.188 15.001,17.188C17.631,17.188 19.966,17.79 21.611,18.715C23.286,19.656 24.063,20.811 24.063,21.875C24.063,23.51 24.013,24.43 23.158,25.125C22.695,25.503 21.92,25.871 20.595,26.139C19.274,26.406 17.468,26.563 15,26.563C12.533,26.563 10.725,26.406 9.405,26.139C8.08,25.871 7.305,25.503 6.843,25.126C5.988,24.43 5.938,23.51 5.938,21.875Z"
android:fillColor="#C1C9BE"
android:fillType="evenOdd"/>
</vector>
+13
View File
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="30"
android:viewportHeight="30">
<path
android:pathData="M14.935,1.563H15.065C16.189,1.563 17.125,1.563 17.868,1.663C18.653,1.768 19.361,2 19.93,2.569C20.5,3.139 20.733,3.848 20.837,4.631C20.913,5.181 20.931,5.836 20.936,6.594C21.746,6.62 22.469,6.669 23.111,6.754C24.576,6.951 25.763,7.366 26.699,8.301C27.634,9.238 28.049,10.424 28.246,11.889C28.438,13.314 28.438,15.132 28.438,17.43V17.57C28.438,19.868 28.438,21.688 28.246,23.111C28.049,24.576 27.634,25.763 26.699,26.699C25.763,27.634 24.576,28.049 23.111,28.246C21.686,28.438 19.868,28.438 17.57,28.438H12.43C10.132,28.438 8.313,28.438 6.889,28.246C5.424,28.049 4.238,27.634 3.301,26.699C2.366,25.763 1.951,24.576 1.754,23.111C1.563,21.686 1.563,19.868 1.563,17.57V17.43C1.563,15.132 1.563,13.313 1.754,11.889C1.951,10.424 2.366,9.238 3.301,8.301C4.238,7.366 5.424,6.951 6.889,6.754C7.611,6.665 8.337,6.612 9.064,6.594C9.069,5.836 9.089,5.181 9.163,4.631C9.267,3.848 9.5,3.139 10.069,2.569C10.639,2 11.347,1.769 12.131,1.663C12.875,1.563 13.813,1.563 14.935,1.563ZM10.94,6.565C11.413,6.563 11.909,6.562 12.43,6.563H17.57C18.091,6.563 18.587,6.563 19.06,6.565C19.055,5.852 19.038,5.315 18.98,4.881C18.901,4.305 18.767,4.057 18.605,3.895C18.442,3.733 18.195,3.599 17.618,3.52C17.015,3.44 16.205,3.438 15,3.438C13.795,3.438 12.985,3.44 12.381,3.521C11.805,3.599 11.557,3.733 11.395,3.896C11.233,4.06 11.099,4.305 11.02,4.881C10.962,5.314 10.945,5.851 10.94,6.565ZM7.137,8.613C5.88,8.781 5.155,9.099 4.625,9.627C4.097,10.156 3.78,10.881 3.611,12.139C3.439,13.422 3.436,15.116 3.436,17.5C3.436,19.884 3.439,21.577 3.611,22.862C3.78,24.119 4.097,24.844 4.626,25.372C5.155,25.901 5.88,26.219 7.137,26.388C8.422,26.56 10.115,26.563 12.499,26.563H17.499C19.882,26.563 21.576,26.56 22.861,26.388C24.118,26.219 24.843,25.901 25.371,25.372C25.9,24.844 26.218,24.119 26.386,22.861C26.559,21.577 26.561,19.884 26.561,17.5C26.561,15.116 26.559,13.424 26.386,12.137C26.218,10.881 25.9,10.156 25.371,9.627C24.843,9.099 24.118,8.781 22.86,8.613C21.576,8.44 19.882,8.438 17.499,8.438H12.499C10.115,8.438 8.424,8.44 7.137,8.613Z"
android:fillColor="#C1C9BE"
android:fillType="evenOdd"/>
<path
android:pathData="M21.25,11.25C21.25,11.582 21.118,11.899 20.884,12.134C20.649,12.368 20.331,12.5 20,12.5C19.669,12.5 19.351,12.368 19.116,12.134C18.882,11.899 18.75,11.582 18.75,11.25C18.75,10.918 18.882,10.601 19.116,10.366C19.351,10.132 19.669,10 20,10C20.331,10 20.649,10.132 20.884,10.366C21.118,10.601 21.25,10.918 21.25,11.25ZM11.25,11.25C11.25,11.582 11.118,11.899 10.884,12.134C10.649,12.368 10.332,12.5 10,12.5C9.668,12.5 9.351,12.368 9.116,12.134C8.882,11.899 8.75,11.582 8.75,11.25C8.75,10.918 8.882,10.601 9.116,10.366C9.351,10.132 9.668,10 10,10C10.332,10 10.649,10.132 10.884,10.366C11.118,10.601 11.25,10.918 11.25,11.25Z"
android:fillColor="#C1C9BE"/>
</vector>
+12
View File
@@ -0,0 +1,12 @@
<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="M12,3.25C12.199,3.25 12.39,3.329 12.53,3.47C12.671,3.61 12.75,3.801 12.75,4C12.75,4.199 12.671,4.39 12.53,4.53C12.39,4.671 12.199,4.75 12,4.75C10.077,4.75 8.233,5.514 6.873,6.873C5.514,8.233 4.75,10.077 4.75,12C4.75,13.923 5.514,15.767 6.873,17.126C8.233,18.486 10.077,19.25 12,19.25C12.199,19.25 12.39,19.329 12.53,19.47C12.671,19.61 12.75,19.801 12.75,20C12.75,20.199 12.671,20.39 12.53,20.53C12.39,20.671 12.199,20.75 12,20.75C9.679,20.75 7.454,19.828 5.813,18.187C4.172,16.546 3.25,14.321 3.25,12C3.25,9.679 4.172,7.454 5.813,5.813C7.454,4.172 9.679,3.25 12,3.25Z"
android:fillColor="#FFDAD6"/>
<path
android:pathData="M16.47,9.53C16.337,9.388 16.265,9.2 16.269,9.005C16.272,8.811 16.351,8.626 16.488,8.488C16.626,8.351 16.811,8.272 17.006,8.269C17.2,8.265 17.388,8.337 17.53,8.47L20.53,11.47C20.67,11.611 20.749,11.801 20.749,12C20.749,12.199 20.67,12.389 20.53,12.53L17.53,15.53C17.461,15.604 17.378,15.663 17.287,15.704C17.194,15.745 17.095,15.767 16.994,15.769C16.894,15.77 16.794,15.752 16.7,15.714C16.607,15.676 16.522,15.62 16.451,15.549C16.38,15.478 16.324,15.393 16.286,15.3C16.248,15.206 16.23,15.106 16.231,15.005C16.233,14.905 16.255,14.805 16.296,14.713C16.337,14.621 16.396,14.539 16.47,14.47L18.19,12.75H10C9.801,12.75 9.61,12.671 9.47,12.53C9.329,12.39 9.25,12.199 9.25,12C9.25,11.801 9.329,11.61 9.47,11.47C9.61,11.329 9.801,11.25 10,11.25H18.19L16.47,9.53Z"
android:fillColor="#FFDAD6"/>
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Some files were not shown because too many files have changed in this diff Show More