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
+15
View File
@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Android Vitals">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="com.startup.suprisa" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="SEVEN_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
+61
View File
@@ -0,0 +1,61 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
+1
View File
@@ -0,0 +1 @@
/build
+201
View File
@@ -0,0 +1,201 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.googleKsp)
alias(libs.plugins.room)
alias(libs.plugins.serialization)
alias(libs.plugins.secrets.gradle.plugin)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.google.services.gmc)
alias(libs.plugins.firebase.crashlytics)
alias(libs.plugins.kotzilla)
}
android {
namespace = "com.prodhack.moscow2025"
compileSdk {
version = release(36)
}
defaultConfig {
applicationId = "com.prodhack.moscow2025"
minSdk = 29
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
room {
schemaDirectory("$projectDir/schemas")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildFeatures {
compose = true
buildConfig = true
viewBinding = true
}
ksp {
arg("KOIN_CONFIG_CHECK", "true")
arg("KOIN_DEFAULT_MODULE", "false")
arg("KOIN_USE_COMPOSE_VIEWMODEL", "true")
}
composeOptions.kotlinCompilerExtensionVersion = "1.5.6"
packagingOptions.resources.excludes.add("/META-INF/{AL2.0,LGPL2.1}")
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode")
}
}
}
dependencies {
implementation(libs.kotzilla.sdk)
// Base libraries ------------------------------------------------------------------------------
implementation(libs.core.ktx)
implementation(platform(libs.kotlin.bom))
implementation(libs.lifecycle.runtime.ktx)
implementation(libs.activity.compose)
implementation(platform(libs.compose.bom))
implementation(libs.ui)
implementation(libs.ui.graphics)
implementation(libs.ui.tooling.preview)
implementation(libs.compose.animation.graphics)
implementation(libs.material.icons.extended)
implementation(libs.androidx.foundation)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.ui.tooling)
debugImplementation(libs.ui.test.manifest)
// Material
implementation(libs.material3)
// Navigation
implementation(libs.navigation.compose)
// Data store
implementation(libs.androidx.datastore.preferences)
// TESTING -------------------------------------------------------------------------------------
testImplementation(libs.junit)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
// Koin testing
testImplementation(libs.koin.test)
// IMAGES --------------------------------------------------------------------------------------
// Coil
implementation(libs.coil.compose)
implementation(libs.coil.svg)
implementation(libs.coil.gif)
// VIEW ELEMENTS -------------------------------------------------------------------------------
// Lottie animation for start
implementation(libs.lottie.compose)
// Fonts
implementation(libs.ui.text.google.fonts)
// Constraint layout
implementation(libs.constraintlayout.compose)
// NETWORK -------------------------------------------------------------------------------------
// gson
implementation(libs.gson)
// Ktor
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.core)
implementation(libs.io.ktor.ktor.client.cio)
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.client.negotiation)
implementation(libs.ktor.client.auth)
// Paging
implementation(libs.androidx.paging.runtime.ktx)
implementation(libs.androidx.paging.compose)
// GOOGLE AND FACEBOOK SERVICES ----------------------------------------------------------------
// Cloud messaging
implementation(libs.play.services.gcm)
implementation(libs.google.firebase.analytics)
implementation(libs.firebase.messaging)
// Import the BoM for the Firebase platform
implementation(platform(libs.firebase.bom))
// Guava
implementation(libs.guava)
// OTHER SERVICES ------------------------------------------------------------------------------
// CameraX
implementation(libs.camera.core)
implementation(libs.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.video)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.extensions)
// Koin
implementation(libs.koin.core)
implementation(libs.koin.android)
implementation(libs.koin.annotations)
implementation(libs.koin.androidx.compose)
implementation(libs.koin.androidx.compose.navigation)
ksp(libs.koin.ksp.compiler)
ksp(libs.koin.ksp.bom)
// Viewmodel
implementation(libs.androidx.savedstate.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.service)
implementation(libs.lifecycle.runtime.ktx)
// Room
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.room.paging)
// Permission manager
implementation(libs.accompanist.permissions)
// System UI controller
implementation(libs.accompanist.systemuicontroller)
implementation(libs.androidx.core.splashscreen)
// END -------------------------------------------------------------------------------------
}
+29
View File
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "846499996834",
"project_id": "prodmoscow2025",
"storage_bucket": "prodmoscow2025.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:846499996834:android:3f1b450bd2c804dff523fb",
"android_client_info": {
"package_name": "com.prodhack.moscow2025"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDFkGaR9ME9drbAw2pAeSDd2QX8vY5H8d8"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+11
View File
@@ -0,0 +1,11 @@
{
"sdkVersion": "1.3.1",
"keys": [
{
"appId": "603ad697-3a85-4f69-89db-d0322cbb6059",
"applicationPackageName": "com.prodhack.moscow2025",
"keyId": "019aa5e2-2c8b-76f5-aa95-104934100116",
"apiKey": "ktz-sdk-rwsABf6YmCK4P70l7drtjOaWEj8s9WRu17VulWyrfcM"
}
]
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,62 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "bf664fe902e116c42af432814d63d6a7",
"entities": [
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `first_name` TEXT, `last_name` TEXT, `display_name` TEXT, `avatar_url` TEXT, `phone` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "email",
"columnName": "email",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "first_name",
"affinity": "TEXT"
},
{
"fieldPath": "lastName",
"columnName": "last_name",
"affinity": "TEXT"
},
{
"fieldPath": "displayName",
"columnName": "display_name",
"affinity": "TEXT"
},
{
"fieldPath": "avatarUrl",
"columnName": "avatar_url",
"affinity": "TEXT"
},
{
"fieldPath": "phone",
"columnName": "phone",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bf664fe902e116c42af432814d63d6a7')"
]
}
}
@@ -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>

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