commit d710525123097a47c1de88a30526d30e081a818c Author: MaximOksiuta <63787095+MaximOksiuta@users.noreply.github.com> Date: Fri Nov 21 13:19:14 2025 +0300 Initial with template diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..a0d2490 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,40 @@ + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7061a0d --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..91bc919 --- /dev/null +++ b/app/build.gradle.kts @@ -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 ------------------------------------------------------------------------------------- +} \ No newline at end of file diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000..182ebf8 --- /dev/null +++ b/app/google-services.json @@ -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" +} \ No newline at end of file diff --git a/app/kotzilla.json b/app/kotzilla.json new file mode 100644 index 0000000..7273d9b --- /dev/null +++ b/app/kotzilla.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json b/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json new file mode 100644 index 0000000..4877ef0 --- /dev/null +++ b/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/prodhack/moscow2025/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/prodhack/moscow2025/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..f5450d7 --- /dev/null +++ b/app/src/androidTest/java/com/prodhack/moscow2025/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b9f1966 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/kotzilla.key b/app/src/main/assets/kotzilla.key new file mode 100644 index 0000000..53027ae --- /dev/null +++ b/app/src/main/assets/kotzilla.key @@ -0,0 +1 @@ +Omt0ei1zZGstcndzQUJmNlltQ0s0UDcwbDdkcnRqT2FXRWo4czlXUnUxN1Z1bFd5cmZjTQ== \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/FirebaseMessagingService.kt b/app/src/main/java/com/prodhack/moscow2025/FirebaseMessagingService.kt new file mode 100644 index 0000000..d3672b0 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/FirebaseMessagingService.kt @@ -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()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/common/App.kt b/app/src/main/java/com/prodhack/moscow2025/common/App.kt new file mode 100644 index 0000000..b8c58b6 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/common/App.kt @@ -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 + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/common/Constants.kt b/app/src/main/java/com/prodhack/moscow2025/common/Constants.kt new file mode 100644 index 0000000..d66fabc --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/common/Constants.kt @@ -0,0 +1,5 @@ +package com.prodhack.moscow2025.common + +object Constants { + const val BASE_API_URL = "https://hackaton.paas.itqdev.xyz/" +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/common/di/ScanModules.kt b/app/src/main/java/com/prodhack/moscow2025/common/di/ScanModules.kt new file mode 100644 index 0000000..2c55559 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/common/di/ScanModules.kt @@ -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 diff --git a/app/src/main/java/com/prodhack/moscow2025/data/base/BaseEntity.kt b/app/src/main/java/com/prodhack/moscow2025/data/base/BaseEntity.kt new file mode 100644 index 0000000..17dcbd9 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/base/BaseEntity.kt @@ -0,0 +1,6 @@ +package com.prodhack.moscow2025.data.base + + +interface BaseEntity { + val id: Number +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/base/BasePaginationDAO.kt b/app/src/main/java/com/prodhack/moscow2025/data/base/BasePaginationDAO.kt new file mode 100644 index 0000000..30ab752 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/base/BasePaginationDAO.kt @@ -0,0 +1,10 @@ +package com.prodhack.moscow2025.data.base + +import androidx.paging.PagingSource + +interface BasePaginationDAO { + suspend fun clearAll() + suspend fun upsertAll(data: List) + + fun getPaginatedData(): PagingSource +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/base/BaseRemoteMediator.kt b/app/src/main/java/com/prodhack/moscow2025/data/base/BaseRemoteMediator.kt new file mode 100644 index 0000000..ddf62da --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/base/BaseRemoteMediator.kt @@ -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( + private val db: RoomDatabase, + private val dao: BasePaginationDAO, + private val makeRequest: suspend (page: Long, pageCount: Int) -> Result> +) : RemoteMediator() { + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/base/BaseRepository.kt b/app/src/main/java/com/prodhack/moscow2025/data/base/BaseRepository.kt new file mode 100644 index 0000000..21f0771 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/base/BaseRepository.kt @@ -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>() + + private data class CacheEntry( + val value: T, + val expirationTime: Long + ) + + fun putCache(cacheConfiguration: Pair, value: T) { + internalCacheStorage[cacheConfiguration.first] = + CacheEntry(value, cacheConfiguration.second.inWholeSeconds) + } + + @Suppress("UNCHECKED_CAST") + fun 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 networkRequest( + ktorClient: HttpClient? = this.defaultKtorClient, + cacheConfiguration: Pair? = null, + block: HttpRequestBuilder.() -> Unit + ): Result { + 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() + } + } 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 internalCachedRequest( + ktorClient: HttpClient? = this.defaultKtorClient, + cacheConfiguration: Pair, + block: HttpRequestBuilder.() -> Unit + ): Result { + val cachedResult = getCache(cacheConfiguration.first) + + return if (cachedResult != null) { + Result.success(cachedResult) + } else { + networkRequest(ktorClient, cacheConfiguration, block) + } + } + + @OptIn(ExperimentalPagingApi::class) + protected fun 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, + makeRequest: suspend (page: Long, pageSize: Int) -> Result> + ): Flow> { + assertDBSpecify() + + return Pager( + config = PagingConfig( + pageSize, + prefetchDistance, + enablePlaceholders, + initialLoadSize, + maxSize, + jumpThreshold + ), + remoteMediator = BaseRemoteMediator( + db = db!!, + dao = dbDao, + makeRequest = makeRequest + ), + pagingSourceFactory = { + dbDao.getPaginatedData() + } + ).flow + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/data/base/DBMappableDTO.kt b/app/src/main/java/com/prodhack/moscow2025/data/base/DBMappableDTO.kt new file mode 100644 index 0000000..60c8262 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/base/DBMappableDTO.kt @@ -0,0 +1,5 @@ +package com.prodhack.moscow2025.data.base + +interface DBMappableDTO { + fun mapToDB(): T +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/base/DomainMappableDTO.kt b/app/src/main/java/com/prodhack/moscow2025/data/base/DomainMappableDTO.kt new file mode 100644 index 0000000..204b7d3 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/base/DomainMappableDTO.kt @@ -0,0 +1,5 @@ +package com.prodhack.moscow2025.data.base + +interface DomainMappableDTO { + fun mapToDomain(): T +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/GalleryPagingSource.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/GalleryPagingSource.kt new file mode 100644 index 0000000..bcbbb4d --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/GalleryPagingSource.kt @@ -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() { + + override suspend fun load(params: LoadParams): LoadResult { + 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() + 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? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/api/ApiKtorClient.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/api/ApiKtorClient.kt new file mode 100644 index 0000000..3071978 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/api/ApiKtorClient.kt @@ -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) + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/api/utils/TimestampFormatter.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/api/utils/TimestampFormatter.kt new file mode 100644 index 0000000..b2aa9d3 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/api/utils/TimestampFormatter.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/localInfo/AuthorizationDataStore.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/localInfo/AuthorizationDataStore.kt new file mode 100644 index 0000000..0994d90 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/localInfo/AuthorizationDataStore.kt @@ -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] ?: "" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt new file mode 100644 index 0000000..db2fb77 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt @@ -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 +} diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/DatabaseProvider.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/DatabaseProvider.kt new file mode 100644 index 0000000..6b1976b --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/DatabaseProvider.kt @@ -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() +} diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/CleanUpDao.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/CleanUpDao.kt new file mode 100644 index 0000000..efa0e44 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/CleanUpDao.kt @@ -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() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/UserDao.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/UserDao.kt new file mode 100644 index 0000000..8c2c6b0 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/UserDao.kt @@ -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 + + @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() +} diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/UserEntity.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/UserEntity.kt new file mode 100644 index 0000000..3e149cd --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/UserEntity.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/dto/AuthDtos.kt b/app/src/main/java/com/prodhack/moscow2025/data/dto/AuthDtos.kt new file mode 100644 index 0000000..1870dce --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/dto/AuthDtos.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/AuthRepositoryImpl.kt b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/AuthRepositoryImpl.kt new file mode 100644 index 0000000..a3cabe7 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/AuthRepositoryImpl.kt @@ -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 = + authorizationDataStore.token.map { it.isNotBlank() } + + override suspend fun signUpRequest(request: RegisterData): Result = + networkRequest { + 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 = + networkRequest { + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/GalleryRepositoryImpl.kt b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/GalleryRepositoryImpl.kt new file mode 100644 index 0000000..27c7df8 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/GalleryRepositoryImpl.kt @@ -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> = Pager( + config = PagingConfig( + pageSize = 50, + enablePlaceholders = false + ), + pagingSourceFactory = { + GalleryPagingSource(application.contentResolver) + } + ).flow +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/UserRepositoryImpl.kt b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/UserRepositoryImpl.kt new file mode 100644 index 0000000..f1d274f --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/UserRepositoryImpl.kt @@ -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 { + 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 = networkRequest { + url { + method = HttpMethod.Get + url("/profile") + } + }.map { + it.mapToDomain().also { + writeProfileToDB(it) + } + } + + override suspend fun updateProfile(request: UpdateUserData): Result { + return networkRequest { + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/AuthRepository.kt b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/AuthRepository.kt new file mode 100644 index 0000000..6f377a2 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/AuthRepository.kt @@ -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 + + suspend fun signUpRequest(request: RegisterData): Result + + suspend fun signInRequest(request: LoginData): Result + + suspend fun clearLoginData() +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/GalleryRepository.kt b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/GalleryRepository.kt new file mode 100644 index 0000000..fa17126 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/GalleryRepository.kt @@ -0,0 +1,8 @@ +package com.prodhack.moscow2025.domain.interfaces + +import androidx.paging.PagingData +import kotlinx.coroutines.flow.Flow + +interface GalleryRepository { + fun getImagesIds(): Flow> +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/UserRepository.kt b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/UserRepository.kt new file mode 100644 index 0000000..a01ff12 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/UserRepository.kt @@ -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 + + suspend fun fetchProfile(): Result + + suspend fun updateProfile(request: UpdateUserData): Result + + suspend fun clearLocalUserData() +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/models/Auth.kt b/app/src/main/java/com/prodhack/moscow2025/domain/models/Auth.kt new file mode 100644 index 0000000..0eef4f8 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/models/Auth.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/models/User.kt b/app/src/main/java/com/prodhack/moscow2025/domain/models/User.kt new file mode 100644 index 0000000..7a95845 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/models/User.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/CheckSessionUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/CheckSessionUseCase.kt new file mode 100644 index 0000000..371becd --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/CheckSessionUseCase.kt @@ -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 + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/GetUserUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/GetUserUseCase.kt new file mode 100644 index 0000000..f5d98ee --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/GetUserUseCase.kt @@ -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 = userRepository.fetchProfile() +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/LogOutUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/LogOutUseCase.kt new file mode 100644 index 0000000..4371f82 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/LogOutUseCase.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/LoginUserUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/LoginUserUseCase.kt new file mode 100644 index 0000000..34a97df --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/LoginUserUseCase.kt @@ -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 { + return authRepository.signInRequest(data) + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/RegisterUserUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/RegisterUserUseCase.kt new file mode 100644 index 0000000..3a0e301 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/RegisterUserUseCase.kt @@ -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 { + return authRepository.signUpRequest(data) + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/UpdateUserUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/UpdateUserUseCase.kt new file mode 100644 index 0000000..a1b9d1c --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/UpdateUserUseCase.kt @@ -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 { + return userRepository.updateProfile(data) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateAuthFieldsUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateAuthFieldsUseCase.kt new file mode 100644 index 0000000..d176516 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateAuthFieldsUseCase.kt @@ -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 = 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() +} diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/utils/NetworkError.kt b/app/src/main/java/com/prodhack/moscow2025/domain/utils/NetworkError.kt new file mode 100644 index 0000000..7bf5034 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/utils/NetworkError.kt @@ -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() + } \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/utils/TypeAliases.kt b/app/src/main/java/com/prodhack/moscow2025/domain/utils/TypeAliases.kt new file mode 100644 index 0000000..a7da9b0 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/utils/TypeAliases.kt @@ -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 = Flow> + +/** + * Simple wrapper for convenience of network paging requests in repositories + * + * @see Flow + * @see PagingData + */ +internal typealias RemotePagingWrapper = Flow> \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/MainActivity.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/MainActivity.kt new file mode 100644 index 0000000..65a1e1b --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/MainActivity.kt @@ -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(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, + grantResults: IntArray, + deviceId: Int + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 123) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + getFCMToken() + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/BottomNavigation.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/BottomNavigation.kt new file mode 100644 index 0000000..6c4a7b7 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/BottomNavigation.kt @@ -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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTBigButton.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTBigButton.kt new file mode 100644 index 0000000..d2283c5 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTBigButton.kt @@ -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, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTCheckBox.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTCheckBox.kt new file mode 100644 index 0000000..4804db0 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTCheckBox.kt @@ -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" + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTFloatingActionButton.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTFloatingActionButton.kt new file mode 100644 index 0000000..1c26fa0 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTFloatingActionButton.kt @@ -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) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTNamedTextField.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTNamedTextField.kt new file mode 100644 index 0000000..4cf3bdd --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTNamedTextField.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTPasswordField.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTPasswordField.kt new file mode 100644 index 0000000..49100f4 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTPasswordField.kt @@ -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 + ) + } + } + ) + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTextField.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTextField.kt new file mode 100644 index 0000000..fbacc62 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTextField.kt @@ -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 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 = 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 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 = 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 + } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTopLogo.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTopLogo.kt new file mode 100644 index 0000000..d1c6b29 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTopLogo.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt new file mode 100644 index 0000000..cd12537 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt @@ -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") + +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksApp.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksApp.kt new file mode 100644 index 0000000..1bf1391 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksApp.kt @@ -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(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 + ) + } + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksAppState.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksAppState.kt new file mode 100644 index 0000000..2e62727 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksAppState.kt @@ -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 + ) +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt new file mode 100644 index 0000000..9f75f4a --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt new file mode 100644 index 0000000..fee1d41 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt @@ -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 :)") +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileViewModel.kt new file mode 100644 index 0000000..df97251 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileViewModel.kt @@ -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 = 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 = _formStateFillProfile + + + private val _profileFillState = MutableUIStateFlow() + val profileFillState: StateFlow> = _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) + } + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginScreen.kt new file mode 100644 index 0000000..e7f1dc1 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginScreen.kt @@ -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) + } + } + } + } + } + } + +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginViewModel.kt new file mode 100644 index 0000000..a75cd2c --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginViewModel.kt @@ -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 = emptyMap() +) + +@KoinViewModel +class LoginViewModel( + private val loginUserUseCase: LoginUserUseCase, + private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase +) : BaseViewModel() { + + private val _formState = MutableStateFlow(LoginFormState()) + val formState: StateFlow = _formState + + private val _authState = MutableUIStateFlow() + val authState: StateFlow> = _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) + } + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt new file mode 100644 index 0000000..e7f84d3 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt @@ -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(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 +// } +// } +// } +} + + diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreenViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreenViewModel.kt new file mode 100644 index 0000000..adbc27e --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreenViewModel.kt @@ -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 , 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) { +// 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(null) +// +// val topicList = MutableUIStateFlow>() +// +// 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() +// +// 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() +// } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt new file mode 100644 index 0000000..1bf160a --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterViewModel.kt new file mode 100644 index 0000000..6f78027 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterViewModel.kt @@ -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 = emptyMap() +) + +@KoinViewModel +class RegisterViewModel( + private val registerUserUseCase: RegisterUserUseCase, + private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase +) : BaseViewModel() { + + private val _formStateSignUp = MutableStateFlow(RegisterFormState()) + val formStateSignUp: StateFlow = _formStateSignUp + + + private val _registerState = MutableUIStateFlow() + val registerState: StateFlow> = _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) + } + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Color.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Color.kt new file mode 100644 index 0000000..4dee16c --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Color.kt @@ -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) diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Dim.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Dim.kt new file mode 100644 index 0000000..dfebcc3 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Dim.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Shapes.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Shapes.kt new file mode 100644 index 0000000..6563358 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Shapes.kt @@ -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) + +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Theme.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Theme.kt new file mode 100644 index 0000000..09fb7de --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Type.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Type.kt new file mode 100644 index 0000000..719ffca --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/theme/Type.kt @@ -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 + ) + +) \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/SetUtils.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/SetUtils.kt new file mode 100644 index 0000000..f4d6e9a --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/SetUtils.kt @@ -0,0 +1,9 @@ +package com.prodhack.moscow2025.presentation.utils + +fun MutableSet.toggleItem(item: T) { + if (item in this) { + remove(item) + } else { + add(item) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/StringUtils.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/StringUtils.kt new file mode 100644 index 0000000..741b9de --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/StringUtils.kt @@ -0,0 +1,3 @@ +package com.prodhack.moscow2025.presentation.utils + +fun String?.notNullOrBlank() = this != null && this.isNotBlank() \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/TimeUtils.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/TimeUtils.kt new file mode 100644 index 0000000..1e6806a --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/TimeUtils.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/UIState.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/UIState.kt new file mode 100644 index 0000000..0f56b58 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/UIState.kt @@ -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 { + class Idle : UIState() + class Loading : UIState() + class Error(val error: NetworkError) : UIState() + class Success(val data: T) : UIState() + + fun map(mapper: (T) -> S): UIState { + 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 Flow>.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> = 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 Flow>.collectAsValueStateWithCallbacks( + onInputError: ((NetworkError.InputError) -> Unit) = { + Toast.makeText(context, "Something went wrong", Toast.LENGTH_SHORT) + .show() + }, + onLoading: (() -> Unit) = {}, + onSuccess: (T) -> Unit = {} + ): State = 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 Flow>.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 UIState.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() +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/base/BaseViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/base/BaseViewModel.kt new file mode 100644 index 0000000..fdbf6d9 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/base/BaseViewModel.kt @@ -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 MutableUIStateFlow(defaultValue: T? = null) = + MutableStateFlow>(defaultValue?.let { UIState.Success(it) } ?: UIState.Idle()) + + /** + * Reset [MutableUIStateFlow] to [UIState.Idle] + */ + protected fun MutableStateFlow>.reset() { + value = UIState.Idle() + } + + /** + * Collect network request + * + * @return [UIState] depending request result + */ + protected fun Flow>.collectRequest( + state: MutableStateFlow>, + ) { + 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 Result.collectRequest( + state: MutableStateFlow> + ) { + state.value = UIState.Loading() + state.value = if (isSuccess) { + UIState.Success(getOrNull()!!) + } else { + UIState.Error(exceptionOrNull()!!.convertToNetworkError()) + } + } + + + /** + * Collect paging request + */ + protected fun Flow>.collectPagingRequest( + mappedData: suspend (T) -> S + ) = map { it.map { data -> mappedData(data) } }.cachedIn(viewModelScope) + +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/imageToByteArray.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/imageToByteArray.kt new file mode 100644 index 0000000..8652d62 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/imageToByteArray.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/CalendarModal.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/CalendarModal.kt new file mode 100644 index 0000000..d76e76d --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/CalendarModal.kt @@ -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) -> 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/ColoredClickable.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/ColoredClickable.kt new file mode 100644 index 0000000..ef25098 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/ColoredClickable.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/placeholders/ErrorPlaceHolder.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/placeholders/ErrorPlaceHolder.kt new file mode 100644 index 0000000..e3a7555 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/placeholders/ErrorPlaceHolder.kt @@ -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) { } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/placeholders/LoadingPlaceholder.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/placeholders/LoadingPlaceholder.kt new file mode 100644 index 0000000..a253804 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/ui/placeholders/LoadingPlaceholder.kt @@ -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) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/add_square_outline.xml b/app/src/main/res/drawable/add_square_outline.xml new file mode 100644 index 0000000..0604309 --- /dev/null +++ b/app/src/main/res/drawable/add_square_outline.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_arr_dropdown.xml b/app/src/main/res/drawable/ic_arr_dropdown.xml new file mode 100644 index 0000000..0c01396 --- /dev/null +++ b/app/src/main/res/drawable/ic_arr_dropdown.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_calendar.xml b/app/src/main/res/drawable/ic_calendar.xml new file mode 100644 index 0000000..ec87b7a --- /dev/null +++ b/app/src/main/res/drawable/ic_calendar.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_chart.xml b/app/src/main/res/drawable/ic_chart.xml new file mode 100644 index 0000000..330aeae --- /dev/null +++ b/app/src/main/res/drawable/ic_chart.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_checkmark.xml b/app/src/main/res/drawable/ic_checkmark.xml new file mode 100644 index 0000000..e583e14 --- /dev/null +++ b/app/src/main/res/drawable/ic_checkmark.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_documents.xml b/app/src/main/res/drawable/ic_documents.xml new file mode 100644 index 0000000..a40950b --- /dev/null +++ b/app/src/main/res/drawable/ic_documents.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_flag_filled.xml b/app/src/main/res/drawable/ic_flag_filled.xml new file mode 100644 index 0000000..2489881 --- /dev/null +++ b/app/src/main/res/drawable/ic_flag_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_flag_unfilled.xml b/app/src/main/res/drawable/ic_flag_unfilled.xml new file mode 100644 index 0000000..daa4f77 --- /dev/null +++ b/app/src/main/res/drawable/ic_flag_unfilled.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_group.xml b/app/src/main/res/drawable/ic_group.xml new file mode 100644 index 0000000..bb9d073 --- /dev/null +++ b/app/src/main/res/drawable/ic_group.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml new file mode 100644 index 0000000..b8ae911 --- /dev/null +++ b/app/src/main/res/drawable/ic_home.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_magnifer.xml b/app/src/main/res/drawable/ic_magnifer.xml new file mode 100644 index 0000000..38a28a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_magnifer.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_minus.xml b/app/src/main/res/drawable/ic_minus.xml new file mode 100644 index 0000000..c88b884 --- /dev/null +++ b/app/src/main/res/drawable/ic_minus.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..a71432d --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_plus.xml b/app/src/main/res/drawable/ic_plus.xml new file mode 100644 index 0000000..800e533 --- /dev/null +++ b/app/src/main/res/drawable/ic_plus.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_profile.xml b/app/src/main/res/drawable/ic_profile.xml new file mode 100644 index 0000000..45abdfd --- /dev/null +++ b/app/src/main/res/drawable/ic_profile.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_trips.xml b/app/src/main/res/drawable/ic_trips.xml new file mode 100644 index 0000000..cadeea0 --- /dev/null +++ b/app/src/main/res/drawable/ic_trips.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/logout_icon.xml b/app/src/main/res/drawable/logout_icon.xml new file mode 100644 index 0000000..46590a0 --- /dev/null +++ b/app/src/main/res/drawable/logout_icon.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/lottie.png b/app/src/main/res/drawable/lottie.png new file mode 100644 index 0000000..ba1559f Binary files /dev/null and b/app/src/main/res/drawable/lottie.png differ diff --git a/app/src/main/res/font/tinkoff_sans_bold.ttf b/app/src/main/res/font/tinkoff_sans_bold.ttf new file mode 100644 index 0000000..e8eb528 Binary files /dev/null and b/app/src/main/res/font/tinkoff_sans_bold.ttf differ diff --git a/app/src/main/res/font/tinkoff_sans_medium.ttf b/app/src/main/res/font/tinkoff_sans_medium.ttf new file mode 100644 index 0000000..d62120c Binary files /dev/null and b/app/src/main/res/font/tinkoff_sans_medium.ttf differ diff --git a/app/src/main/res/font/tinkoff_sans_regular.ttf b/app/src/main/res/font/tinkoff_sans_regular.ttf new file mode 100644 index 0000000..13963b9 Binary files /dev/null and b/app/src/main/res/font/tinkoff_sans_regular.ttf differ diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..74d295c --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + MoscowHackatonTemplate + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..bf97c8c --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +