You've already forked RekomenciMobile
Initial with template
This commit is contained in:
+15
@@ -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
|
||||
Generated
+3
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
Generated
+6
@@ -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>
|
||||
Generated
+40
@@ -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>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+10
@@ -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>
|
||||
Generated
+19
@@ -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
@@ -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>
|
||||
Generated
+10
@@ -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>
|
||||
Generated
+9
@@ -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>
|
||||
Generated
+17
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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 -------------------------------------------------------------------------------------
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+21
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+12
@@ -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
|
||||
}
|
||||
+55
@@ -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
|
||||
}
|
||||
+19
@@ -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()
|
||||
}
|
||||
+17
@@ -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()
|
||||
}
|
||||
+44
@@ -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
|
||||
)
|
||||
}
|
||||
+60
@@ -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()
|
||||
}
|
||||
}
|
||||
+23
@@ -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
|
||||
}
|
||||
+75
@@ -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)
|
||||
}
|
||||
}
|
||||
+102
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+138
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+84
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+127
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+402
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -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 :)")
|
||||
}
|
||||
+185
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+64
@@ -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
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
+143
@@ -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()
|
||||
// }
|
||||
}
|
||||
+182
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -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
|
||||
)
|
||||
+41
@@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user