You've already forked RekomenciMobile
Initial with template
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
package com.prodhack.moscow2025
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import com.prodhack.moscow2025.presentation.MainActivity
|
||||
|
||||
class FirebaseMessagingService : FirebaseMessagingService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
|
||||
val title = message.data["title"]
|
||||
val text = message.data["body"]
|
||||
Log.e(
|
||||
"fcm",
|
||||
"title=$title" +
|
||||
"\ntext=$text"
|
||||
)
|
||||
|
||||
title?.let { it1 -> text?.let { it2 -> sendNotification(it1, it2) } }
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
super.onNewToken(token)
|
||||
Log.e("fcm", "token=$token")
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannel(
|
||||
"default_channel_id",
|
||||
"MainAppNotifications",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Channel for main motifications"
|
||||
enableLights(true)
|
||||
lightColor = Color.Red.toArgb()
|
||||
enableVibration(true)
|
||||
vibrationPattern = longArrayOf(0, 500, 200, 500)
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
}
|
||||
|
||||
val notificationManager =
|
||||
getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun sendNotification(title: String?, messageBody: String?) {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, intent,
|
||||
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notificationBuilder = NotificationCompat.Builder(this, "default_channel_id")
|
||||
.setSmallIcon(R.drawable.ic_launcher_background) // замените на свою иконку
|
||||
.setContentTitle(title ?: "Уведомление")
|
||||
.setContentText(messageBody)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
|
||||
val notificationManager =
|
||||
getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.notify(0, notificationBuilder.build())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.prodhack.moscow2025.common
|
||||
|
||||
import android.app.Application
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.prodhack.moscow2025.common.di.AppModule
|
||||
import com.prodhack.moscow2025.common.di.DataModule
|
||||
import com.prodhack.moscow2025.common.di.DomainModule
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.DatabaseProvider
|
||||
import io.kotzilla.sdk.analytics.koin.analytics
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.ksp.generated.module
|
||||
|
||||
|
||||
class App : Application() {
|
||||
|
||||
companion object {
|
||||
lateinit var instance: Application
|
||||
lateinit var version: String
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
version = getAppVersion()
|
||||
startKoin {
|
||||
androidContext(this@App)
|
||||
analytics()
|
||||
modules(
|
||||
listOf(
|
||||
AppModule().module,
|
||||
DataModule().module,
|
||||
DomainModule().module,
|
||||
DatabaseProvider().module
|
||||
)
|
||||
)
|
||||
}
|
||||
FirebaseApp.initializeApp(this@App)
|
||||
}
|
||||
|
||||
private fun getAppVersion(): String {
|
||||
var pInfo: PackageInfo? = null
|
||||
try {
|
||||
val pm = packageManager
|
||||
if (pm != null) {
|
||||
pInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
|
||||
} else {
|
||||
pm.getPackageInfo(packageName, 0)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d("App", "method: getAppVersion - error: $e")
|
||||
}
|
||||
if (pInfo == null) {
|
||||
pInfo = PackageInfo()
|
||||
pInfo.versionName = "0.0.0"
|
||||
pInfo.longVersionCode = 0
|
||||
}
|
||||
var version = pInfo.versionName + "."
|
||||
version += pInfo.longVersionCode
|
||||
return version
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.prodhack.moscow2025.common
|
||||
|
||||
object Constants {
|
||||
const val BASE_API_URL = "https://hackaton.paas.itqdev.xyz/"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.prodhack.moscow2025.common.di
|
||||
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Module
|
||||
|
||||
@Module
|
||||
@ComponentScan("com.prodhack.moscow2025.presentation")
|
||||
class AppModule
|
||||
|
||||
@Module
|
||||
@ComponentScan("com.prodhack.moscow2025.domain")
|
||||
class DomainModule
|
||||
|
||||
@Module
|
||||
@ComponentScan("com.prodhack.moscow2025.data")
|
||||
class DataModule
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.prodhack.moscow2025.data.base
|
||||
|
||||
|
||||
interface BaseEntity {
|
||||
val id: Number
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.prodhack.moscow2025.data.base
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
|
||||
interface BasePaginationDAO<T : Any> {
|
||||
suspend fun clearAll()
|
||||
suspend fun upsertAll(data: List<T>)
|
||||
|
||||
fun getPaginatedData(): PagingSource<Int, T>
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.prodhack.moscow2025.data.base
|
||||
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.LoadType
|
||||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.withTransaction
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class BaseRemoteMediator<DBEntity : BaseEntity>(
|
||||
private val db: RoomDatabase,
|
||||
private val dao: BasePaginationDAO<DBEntity>,
|
||||
private val makeRequest: suspend (page: Long, pageCount: Int) -> Result<List<DBEntity>>
|
||||
) : RemoteMediator<Int, DBEntity>() {
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<Int, DBEntity>
|
||||
): MediatorResult {
|
||||
return try {
|
||||
val loadKey = when (loadType) {
|
||||
LoadType.REFRESH -> 1
|
||||
LoadType.PREPEND -> return MediatorResult.Success(
|
||||
endOfPaginationReached = true
|
||||
)
|
||||
|
||||
LoadType.APPEND -> {
|
||||
val lastItem = state.lastItemOrNull()
|
||||
if (lastItem == null) {
|
||||
1
|
||||
} else {
|
||||
(lastItem.id.toLong() / state.config.pageSize) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val result = makeRequest(
|
||||
loadKey,
|
||||
state.config.pageSize
|
||||
)
|
||||
|
||||
if (result.isSuccess) {
|
||||
val data = result.getOrNull()!!
|
||||
db.withTransaction {
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
dao.clearAll()
|
||||
}
|
||||
val beerEntities = data
|
||||
dao.upsertAll(beerEntities)
|
||||
}
|
||||
MediatorResult.Success(
|
||||
endOfPaginationReached = data.size < state.config.pageSize
|
||||
)
|
||||
} else {
|
||||
MediatorResult.Error(result.exceptionOrNull()!!)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
MediatorResult.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.prodhack.moscow2025.data.base
|
||||
|
||||
import android.util.Log
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.room.RoomDatabase
|
||||
import com.prodhack.moscow2025.data.dto.ErrorNetworkDTO
|
||||
import com.prodhack.moscow2025.domain.utils.NetworkError
|
||||
import com.prodhack.moscow2025.domain.utils.convertToNetworkError
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.request
|
||||
import io.ktor.http.isSuccess
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlin.time.Duration
|
||||
|
||||
abstract class BaseRepository {
|
||||
|
||||
// Caching module ==============================================================================
|
||||
private val internalCacheStorage = mutableMapOf<String, CacheEntry<*>>()
|
||||
|
||||
private data class CacheEntry<T>(
|
||||
val value: T,
|
||||
val expirationTime: Long
|
||||
)
|
||||
|
||||
fun <T> putCache(cacheConfiguration: Pair<String, Duration>, value: T) {
|
||||
internalCacheStorage[cacheConfiguration.first] =
|
||||
CacheEntry(value, cacheConfiguration.second.inWholeSeconds)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> getCache(key: String): T? {
|
||||
val entry = internalCacheStorage[key] ?: return null
|
||||
if (entry.expirationTime < System.currentTimeMillis()) {
|
||||
internalCacheStorage.remove(key)
|
||||
return null
|
||||
}
|
||||
return entry.value as T
|
||||
}
|
||||
|
||||
// Base data sources ===========================================================================
|
||||
|
||||
protected open val defaultKtorClient: HttpClient? = null
|
||||
protected open val db: RoomDatabase? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BaseRepository"
|
||||
}
|
||||
|
||||
// Internal methods ============================================================================
|
||||
|
||||
private fun assertKtorClientSpecify() {
|
||||
if (defaultKtorClient == null) {
|
||||
Log.e(TAG, "You must specify ktor client for make network requests")
|
||||
throw IllegalStateException("You must specify ktor client for make network requests")
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertDBSpecify() {
|
||||
if (db == null) {
|
||||
throw IllegalStateException("You must specify db for use pagination/cashing")
|
||||
}
|
||||
}
|
||||
|
||||
// And methods for use :) ======================================================================
|
||||
|
||||
/**
|
||||
* Makes a network request using the provided Ktor client and request builder block.
|
||||
*
|
||||
* This function handles the common boilerplate for making a network request,
|
||||
* including error handling and converting exceptions to a domain-specific `NetworkError`.
|
||||
*
|
||||
* @param T The expected successful response type. This type must be deserializable by Ktor.
|
||||
* @param ktorClient The [HttpClient] to use for the request. Defaults to `this.defaultKtorClient`.
|
||||
* An [IllegalStateException] will be thrown if no client is provided and `defaultKtorClient` is null.
|
||||
* @param block A lambda function that configures the [HttpRequestBuilder] for the request.
|
||||
* @return A [Result] object containing either the successful response of type [T] or a [NetworkError] if the request fails.
|
||||
* @throws IllegalStateException if `ktorClient` is null and `defaultKtorClient` is also null.
|
||||
*/
|
||||
internal suspend inline fun <reified T> networkRequest(
|
||||
ktorClient: HttpClient? = this.defaultKtorClient,
|
||||
cacheConfiguration: Pair<String, Duration>? = null,
|
||||
block: HttpRequestBuilder.() -> Unit
|
||||
): Result<T> {
|
||||
Log.d(TAG, "Network request! Asserting ktor client specify")
|
||||
assertKtorClientSpecify()
|
||||
Log.d(TAG, "ktor client is specified - continue network request")
|
||||
return try {
|
||||
Log.d(TAG, "Start request!")
|
||||
val response = ktorClient!!.request(block = block)
|
||||
Log.d(TAG, "Request was made without exceptions")
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
Result.success(
|
||||
value = response
|
||||
).map {
|
||||
it.body<T>()
|
||||
}
|
||||
} else {
|
||||
val firstCodeNum = response.status.value / 100
|
||||
val detail = (response.body() as? ErrorNetworkDTO)?.detail ?: "Unknown"
|
||||
Result.failure(
|
||||
when (firstCodeNum) {
|
||||
4 -> NetworkError.InputError(detail)
|
||||
else -> NetworkError.Unexpected(detail)
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception in request process! $e")
|
||||
Result.failure(
|
||||
exception = e.convertToNetworkError()
|
||||
)
|
||||
}.onSuccess {
|
||||
Log.v(TAG, "Network request was successful")
|
||||
if (cacheConfiguration != null) {
|
||||
putCache(cacheConfiguration, it)
|
||||
}
|
||||
}.onFailure {
|
||||
Log.e(TAG, "Network request has error! $it")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
internal suspend inline fun <reified T> internalCachedRequest(
|
||||
ktorClient: HttpClient? = this.defaultKtorClient,
|
||||
cacheConfiguration: Pair<String, Duration>,
|
||||
block: HttpRequestBuilder.() -> Unit
|
||||
): Result<T> {
|
||||
val cachedResult = getCache<T>(cacheConfiguration.first)
|
||||
|
||||
return if (cachedResult != null) {
|
||||
Result.success(cachedResult)
|
||||
} else {
|
||||
networkRequest<T>(ktorClient, cacheConfiguration, block)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
protected fun <Value : BaseEntity> paginatedRequest(
|
||||
pageSize: Int = 10,
|
||||
prefetchDistance: Int = pageSize,
|
||||
enablePlaceholders: Boolean = true,
|
||||
initialLoadSize: Int = pageSize * 3,
|
||||
maxSize: Int = Int.MAX_VALUE,
|
||||
jumpThreshold: Int = Int.MIN_VALUE,
|
||||
dbDao: BasePaginationDAO<Value>,
|
||||
makeRequest: suspend (page: Long, pageSize: Int) -> Result<List<Value>>
|
||||
): Flow<PagingData<Value>> {
|
||||
assertDBSpecify()
|
||||
|
||||
return Pager(
|
||||
config = PagingConfig(
|
||||
pageSize,
|
||||
prefetchDistance,
|
||||
enablePlaceholders,
|
||||
initialLoadSize,
|
||||
maxSize,
|
||||
jumpThreshold
|
||||
),
|
||||
remoteMediator = BaseRemoteMediator(
|
||||
db = db!!,
|
||||
dao = dbDao,
|
||||
makeRequest = makeRequest
|
||||
),
|
||||
pagingSourceFactory = {
|
||||
dbDao.getPaginatedData()
|
||||
}
|
||||
).flow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.prodhack.moscow2025.data.base
|
||||
|
||||
interface DBMappableDTO <T> {
|
||||
fun mapToDB(): T
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.prodhack.moscow2025.data.base
|
||||
|
||||
interface DomainMappableDTO <T> {
|
||||
fun mapToDomain(): T
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.prodhack.moscow2025.data.data_providers
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.provider.MediaStore
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
|
||||
class GalleryPagingSource(
|
||||
private val contentResolver: ContentResolver
|
||||
) : PagingSource<Int, Long>() {
|
||||
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Long> {
|
||||
return try {
|
||||
val page = params.key ?: 0
|
||||
val pageSize = params.loadSize
|
||||
|
||||
val projection = arrayOf(
|
||||
MediaStore.Images.Media._ID,
|
||||
MediaStore.Images.Media.DATE_ADDED
|
||||
)
|
||||
|
||||
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"
|
||||
|
||||
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
val cursor = contentResolver.query(
|
||||
uri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
"$sortOrder LIMIT $pageSize OFFSET ${page * pageSize}"
|
||||
)
|
||||
|
||||
val images = mutableListOf<Long>()
|
||||
cursor?.use {
|
||||
val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
|
||||
while (it.moveToNext()) {
|
||||
images.add(it.getLong(idColumn))
|
||||
}
|
||||
}
|
||||
|
||||
val nextKey = if (images.size < pageSize) null else page + 1
|
||||
|
||||
LoadResult.Page(
|
||||
data = images,
|
||||
prevKey = if (page == 0) null else page - 1,
|
||||
nextKey = nextKey
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
LoadResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Int, Long>): Int? {
|
||||
return state.anchorPosition?.let { anchorPosition ->
|
||||
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
|
||||
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.prodhack.moscow2025.data.data_providers.api
|
||||
|
||||
import com.prodhack.moscow2025.common.Constants
|
||||
import com.prodhack.moscow2025.data.data_providers.localInfo.AuthorizationDataStore
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.HttpRequestRetry
|
||||
import io.ktor.client.plugins.auth.Auth
|
||||
import io.ktor.client.plugins.auth.providers.BearerTokens
|
||||
import io.ktor.client.plugins.auth.providers.bearer
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.plugins.logging.ANDROID
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
|
||||
// Configuration Ktor client for request to API
|
||||
@Single
|
||||
class ApiKtorClient(authorizationDataStore: AuthorizationDataStore) {
|
||||
|
||||
val client = HttpClient(OkHttp) {
|
||||
install(Logging) {
|
||||
logger = Logger.ANDROID
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
install(HttpRequestRetry) {
|
||||
retryOnServerErrors(maxRetries = 3)
|
||||
exponentialDelay()
|
||||
}
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
defaultRequest {
|
||||
url(Constants.BASE_API_URL)
|
||||
}
|
||||
install(Auth) {
|
||||
bearer {
|
||||
sendWithoutRequest { request ->
|
||||
val segments = request.url.pathSegments
|
||||
|
||||
val endpointsWithoutAuth = listOf(
|
||||
"sign_in",
|
||||
"sign_up"
|
||||
)
|
||||
|
||||
endpointsWithoutAuth.any { segments.contains(it) }.not()
|
||||
}
|
||||
loadTokens {
|
||||
return@loadTokens authorizationDataStore.token.first()
|
||||
.toBearerTokens()
|
||||
}
|
||||
refreshTokens {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
authorizationDataStore.clearToken()
|
||||
}
|
||||
|
||||
return@refreshTokens null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toBearerTokens(): BearerTokens {
|
||||
return BearerTokens(this, null)
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.prodhack.moscow2025.data.data_providers.api.utils
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
internal fun String.parseToTimestamp(): Long? {
|
||||
val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
|
||||
dateFormatter.timeZone = TimeZone.getTimeZone("UTC")
|
||||
|
||||
return dateFormatter.parse(this)?.time
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.prodhack.moscow2025.data.data_providers.localInfo
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.io.IOException
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
|
||||
@Single
|
||||
class AuthorizationDataStore(
|
||||
context: Context
|
||||
) {
|
||||
private val Context.dataStore by preferencesDataStore(
|
||||
name = "authTokens"
|
||||
)
|
||||
|
||||
private val dataStore = context.dataStore
|
||||
|
||||
private companion object {
|
||||
const val TAG = "AuthorizationDataStore"
|
||||
val ACCESS_TOKEN = stringPreferencesKey("accessToken")
|
||||
}
|
||||
|
||||
suspend fun saveToken(accessToken: String) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[ACCESS_TOKEN] = accessToken
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearToken() {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[ACCESS_TOKEN] = ""
|
||||
}
|
||||
}
|
||||
|
||||
val token
|
||||
get() = dataStore.data
|
||||
.catch {
|
||||
Log.e(TAG, "Error reading preferences.", it)
|
||||
if (it is IOException) {
|
||||
Log.e(TAG, "return empty prefs")
|
||||
emit(emptyPreferences())
|
||||
} else {
|
||||
throw it
|
||||
}
|
||||
}.map { preferences ->
|
||||
preferences[ACCESS_TOKEN] ?: ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.prodhack.moscow2025.data.data_providers.local_db
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.dao.CleanUpDao
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.dao.UserDao
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity
|
||||
|
||||
@Database(
|
||||
entities = [UserEntity::class],
|
||||
version = 1,
|
||||
exportSchema = true
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun userDao(): UserDao
|
||||
|
||||
abstract fun cleanUpDao(): CleanUpDao
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.prodhack.moscow2025.data.data_providers.local_db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Module
|
||||
class DatabaseProvider {
|
||||
|
||||
@Single
|
||||
fun provideDatabase(context: Context): AppDatabase =
|
||||
Room.databaseBuilder(
|
||||
context,
|
||||
AppDatabase::class.java,
|
||||
"t_tasks.db"
|
||||
).fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.prodhack.moscow2025.data.data_providers.local_db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
|
||||
@Dao
|
||||
interface CleanUpDao {
|
||||
@Query("DELETE FROM users")
|
||||
suspend fun cleanUpUsers()
|
||||
|
||||
@Transaction
|
||||
suspend fun cleanUp() {
|
||||
cleanUpUsers()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.prodhack.moscow2025.data.data_providers.local_db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface UserDao {
|
||||
|
||||
@Query("SELECT * FROM users LIMIT 1")
|
||||
fun observeUser(): Flow<UserEntity?>
|
||||
|
||||
@Query("SELECT * FROM users LIMIT 1")
|
||||
suspend fun getUser(): UserEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(user: UserEntity)
|
||||
|
||||
@Query("DELETE FROM users")
|
||||
suspend fun clear()
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.prodhack.moscow2025.data.data_providers.local_db.entities
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.prodhack.moscow2025.domain.models.User
|
||||
|
||||
@Entity(tableName = "users")
|
||||
data class UserEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
val id: String,
|
||||
val email: String,
|
||||
@ColumnInfo(name = "first_name")
|
||||
val firstName: String?,
|
||||
@ColumnInfo(name = "last_name")
|
||||
val lastName: String?,
|
||||
@ColumnInfo(name = "display_name")
|
||||
val displayName: String?,
|
||||
@ColumnInfo(name = "avatar_url")
|
||||
val avatarUrl: String?,
|
||||
val phone: String?
|
||||
) {
|
||||
fun mapToDomain(): User {
|
||||
return User(
|
||||
id = id,
|
||||
firstName = firstName,
|
||||
lastName = lastName,
|
||||
displayName = displayName,
|
||||
email = email,
|
||||
avatarUrl = avatarUrl,
|
||||
phone = phone
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun User.mapToDB(): UserEntity = UserEntity(
|
||||
id = id,
|
||||
firstName = firstName,
|
||||
lastName = lastName,
|
||||
displayName = displayName,
|
||||
phone = phone,
|
||||
email = email,
|
||||
avatarUrl = avatarUrl
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.prodhack.moscow2025.data.dto
|
||||
|
||||
import com.prodhack.moscow2025.domain.models.LoginData
|
||||
import com.prodhack.moscow2025.domain.models.RegisterData
|
||||
import com.prodhack.moscow2025.domain.models.UpdateUserData
|
||||
import com.prodhack.moscow2025.domain.models.User
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ErrorNetworkDTO(
|
||||
val detail: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserPatchRequest(
|
||||
val email: String?,
|
||||
@SerialName("display_name")
|
||||
val displayName: String? = null,
|
||||
@SerialName("first_name")
|
||||
val firstName: String? = null,
|
||||
@SerialName("last_name")
|
||||
val lastName: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
val phone: String? = null,
|
||||
)
|
||||
|
||||
fun UpdateUserData.mapToData(): UserPatchRequest = UserPatchRequest(
|
||||
email = email,
|
||||
displayName = displayName,
|
||||
firstName = firstName,
|
||||
lastName = lastName,
|
||||
avatarUrl = avatarUrl,
|
||||
phone = phone
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserLoginRequest(
|
||||
val email: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
fun LoginData.mapToData(): UserLoginRequest = UserLoginRequest(email, password)
|
||||
|
||||
|
||||
@Serializable
|
||||
data class UserRegisterRequest(
|
||||
val email: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
fun RegisterData.mapToData(): UserRegisterRequest = UserRegisterRequest(email, password)
|
||||
|
||||
|
||||
@Serializable
|
||||
data class TokenResponse(
|
||||
@SerialName("access_token")
|
||||
val token: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserResponse(
|
||||
val id: String,
|
||||
val email: String,
|
||||
@SerialName("display_name")
|
||||
val displayName: String? = null,
|
||||
@SerialName("first_name")
|
||||
val firstName: String? = null,
|
||||
@SerialName("last_name")
|
||||
val lastName: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
val phone: String? = null,
|
||||
) {
|
||||
fun mapToDomain(): User = User(
|
||||
id = id,
|
||||
email = email,
|
||||
displayName = displayName,
|
||||
firstName = firstName,
|
||||
lastName = lastName,
|
||||
avatarUrl = avatarUrl,
|
||||
phone = phone
|
||||
)
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.prodhack.moscow2025.data.repImplementations
|
||||
|
||||
import com.prodhack.moscow2025.data.base.BaseRepository
|
||||
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
|
||||
import com.prodhack.moscow2025.data.data_providers.localInfo.AuthorizationDataStore
|
||||
import com.prodhack.moscow2025.data.dto.TokenResponse
|
||||
import com.prodhack.moscow2025.data.dto.mapToData
|
||||
import com.prodhack.moscow2025.domain.interfaces.AuthRepository
|
||||
import com.prodhack.moscow2025.domain.models.LoginData
|
||||
import com.prodhack.moscow2025.domain.models.RegisterData
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class AuthRepositoryImpl(
|
||||
ktorClient: ApiKtorClient,
|
||||
private val authorizationDataStore: AuthorizationDataStore
|
||||
) : AuthRepository, BaseRepository() {
|
||||
|
||||
override val defaultKtorClient = ktorClient.client
|
||||
|
||||
override fun fetchLoginState(): Flow<Boolean> =
|
||||
authorizationDataStore.token.map { it.isNotBlank() }
|
||||
|
||||
override suspend fun signUpRequest(request: RegisterData): Result<String> =
|
||||
networkRequest<TokenResponse> {
|
||||
url {
|
||||
method = HttpMethod.Post
|
||||
url("/auth/sign_up/email")
|
||||
setBody(request.mapToData())
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
}.map {
|
||||
authorizationDataStore.saveToken(it.token)
|
||||
"Success"
|
||||
}
|
||||
|
||||
override suspend fun signInRequest(request: LoginData): Result<String> =
|
||||
networkRequest<TokenResponse> {
|
||||
url {
|
||||
method = HttpMethod.Post
|
||||
url("/auth/sign_up/email")
|
||||
setBody(request.mapToData())
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
}.map {
|
||||
authorizationDataStore.saveToken(it.token)
|
||||
"Success"
|
||||
}
|
||||
|
||||
override suspend fun clearLoginData() {
|
||||
authorizationDataStore.clearToken()
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.prodhack.moscow2025.data.repImplementations
|
||||
|
||||
import android.app.Application
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import com.prodhack.moscow2025.data.data_providers.GalleryPagingSource
|
||||
import com.prodhack.moscow2025.domain.interfaces.GalleryRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class GalleryRepositoryImpl(private val application: Application) : GalleryRepository {
|
||||
override fun getImagesIds(): Flow<PagingData<Long>> = Pager(
|
||||
config = PagingConfig(
|
||||
pageSize = 50,
|
||||
enablePlaceholders = false
|
||||
),
|
||||
pagingSourceFactory = {
|
||||
GalleryPagingSource(application.contentResolver)
|
||||
}
|
||||
).flow
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.prodhack.moscow2025.data.repImplementations
|
||||
|
||||
import com.prodhack.moscow2025.data.base.BaseRepository
|
||||
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.entities.mapToDB
|
||||
import com.prodhack.moscow2025.data.dto.UserResponse
|
||||
import com.prodhack.moscow2025.data.dto.mapToData
|
||||
import com.prodhack.moscow2025.domain.interfaces.UserRepository
|
||||
import com.prodhack.moscow2025.domain.models.UpdateUserData
|
||||
import com.prodhack.moscow2025.domain.models.User
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class UserRepositoryImpl(
|
||||
ktorClient: ApiKtorClient,
|
||||
override val db: AppDatabase
|
||||
) : UserRepository, BaseRepository() {
|
||||
|
||||
override val defaultKtorClient = ktorClient.client
|
||||
private val userDao = db.userDao()
|
||||
|
||||
override fun observeUser(): Flow<User?> {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
fetchProfile()
|
||||
}
|
||||
return userDao.observeUser().map {
|
||||
it?.mapToDomain()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun writeProfileToDB(data: User) {
|
||||
userDao.upsert(data.mapToDB())
|
||||
}
|
||||
|
||||
override suspend fun fetchProfile(): Result<User> = networkRequest<UserResponse> {
|
||||
url {
|
||||
method = HttpMethod.Get
|
||||
url("/profile")
|
||||
}
|
||||
}.map {
|
||||
it.mapToDomain().also {
|
||||
writeProfileToDB(it)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateProfile(request: UpdateUserData): Result<User> {
|
||||
return networkRequest<UserResponse> {
|
||||
url {
|
||||
method = HttpMethod.Patch
|
||||
url("/profile")
|
||||
setBody(request.mapToData())
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
}.map {
|
||||
it.mapToDomain().also {
|
||||
writeProfileToDB(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearLocalUserData() {
|
||||
userDao.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.prodhack.moscow2025.domain.interfaces
|
||||
|
||||
import com.prodhack.moscow2025.domain.models.LoginData
|
||||
import com.prodhack.moscow2025.domain.models.RegisterData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AuthRepository {
|
||||
fun fetchLoginState(): Flow<Boolean>
|
||||
|
||||
suspend fun signUpRequest(request: RegisterData): Result<String>
|
||||
|
||||
suspend fun signInRequest(request: LoginData): Result<String>
|
||||
|
||||
suspend fun clearLoginData()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.prodhack.moscow2025.domain.interfaces
|
||||
|
||||
import androidx.paging.PagingData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface GalleryRepository {
|
||||
fun getImagesIds(): Flow<PagingData<Long>>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.prodhack.moscow2025.domain.interfaces
|
||||
|
||||
import com.prodhack.moscow2025.domain.models.UpdateUserData
|
||||
import com.prodhack.moscow2025.domain.models.User
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface UserRepository {
|
||||
fun observeUser(): Flow<User?>
|
||||
|
||||
suspend fun fetchProfile(): Result<User>
|
||||
|
||||
suspend fun updateProfile(request: UpdateUserData): Result<User>
|
||||
|
||||
suspend fun clearLocalUserData()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.prodhack.moscow2025.domain.models
|
||||
|
||||
data class RegisterData(
|
||||
val email: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
data class LoginData(
|
||||
val email: String,
|
||||
val password: String
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.prodhack.moscow2025.domain.models
|
||||
|
||||
data class User(
|
||||
val id: String,
|
||||
val email: String,
|
||||
val displayName: String?,
|
||||
val firstName: String?,
|
||||
val lastName: String?,
|
||||
val avatarUrl: String?,
|
||||
val phone: String?
|
||||
)
|
||||
|
||||
data class UpdateUserData(
|
||||
val email: String? = null,
|
||||
val displayName: String? = null,
|
||||
val firstName: String? = null,
|
||||
val lastName: String? = null,
|
||||
val avatarUrl: String? = null,
|
||||
val phone: String? = null
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.prodhack.moscow2025.domain.usecase.auth
|
||||
|
||||
import com.prodhack.moscow2025.domain.interfaces.AuthRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class CheckSessionUseCase(
|
||||
private val authRepository: AuthRepository
|
||||
) {
|
||||
operator suspend fun invoke(): Boolean {
|
||||
return authRepository.fetchLoginState().firstOrNull() == true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.prodhack.moscow2025.domain.usecase.auth
|
||||
|
||||
import com.prodhack.moscow2025.domain.models.User
|
||||
import com.prodhack.moscow2025.domain.interfaces.UserRepository
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class GetUserUseCase(
|
||||
private val userRepository: UserRepository
|
||||
) {
|
||||
suspend operator fun invoke(): Result<User> = userRepository.fetchProfile()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.prodhack.moscow2025.domain.usecase.auth
|
||||
|
||||
import com.prodhack.moscow2025.domain.interfaces.AuthRepository
|
||||
import com.prodhack.moscow2025.domain.interfaces.UserRepository
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class LogOutUseCase(
|
||||
private val authRepository: AuthRepository,
|
||||
private val userRepository: UserRepository
|
||||
) {
|
||||
suspend operator fun invoke() {
|
||||
authRepository.clearLoginData()
|
||||
userRepository.clearLocalUserData()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.prodhack.moscow2025.domain.usecase.auth
|
||||
|
||||
import com.prodhack.moscow2025.domain.models.LoginData
|
||||
import com.prodhack.moscow2025.domain.interfaces.AuthRepository
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class LoginUserUseCase(
|
||||
private val authRepository: AuthRepository
|
||||
) {
|
||||
suspend operator fun invoke(data: LoginData): Result<String> {
|
||||
return authRepository.signInRequest(data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.prodhack.moscow2025.domain.usecase.auth
|
||||
|
||||
import com.prodhack.moscow2025.domain.models.RegisterData
|
||||
import com.prodhack.moscow2025.domain.interfaces.AuthRepository
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class RegisterUserUseCase(
|
||||
private val authRepository: AuthRepository
|
||||
) {
|
||||
suspend operator fun invoke(data: RegisterData): Result<String> {
|
||||
return authRepository.signUpRequest(data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.prodhack.moscow2025.domain.usecase.auth
|
||||
|
||||
import com.prodhack.moscow2025.domain.models.UpdateUserData
|
||||
import com.prodhack.moscow2025.domain.models.User
|
||||
import com.prodhack.moscow2025.domain.interfaces.UserRepository
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class UpdateUserUseCase(
|
||||
private val userRepository: UserRepository
|
||||
) {
|
||||
suspend operator fun invoke(data: UpdateUserData): Result<User> {
|
||||
return userRepository.updateProfile(data)
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package com.prodhack.moscow2025.domain.usecase.auth
|
||||
|
||||
import android.util.Patterns
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
enum class AuthField {
|
||||
FirstName,
|
||||
SecondName,
|
||||
Email,
|
||||
Password,
|
||||
ConfirmPassword,
|
||||
Phone
|
||||
}
|
||||
|
||||
|
||||
data class ValidationResult(
|
||||
val errors: Map<AuthField, String> = emptyMap()
|
||||
) {
|
||||
val isValid: Boolean
|
||||
get() = errors.isEmpty()
|
||||
}
|
||||
|
||||
@Single
|
||||
class ValidateAuthFieldsUseCase {
|
||||
|
||||
fun validateFillProfile(
|
||||
displayName: String,
|
||||
firstName: String,
|
||||
lastName: String,
|
||||
phone: String
|
||||
): ValidationResult {
|
||||
val errors = buildMap {
|
||||
if (displayName.isBlank()) put(AuthField.FirstName, "Введите никнейм")
|
||||
if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя")
|
||||
if (lastName.isBlank()) put(AuthField.SecondName, "Введите фамилию")
|
||||
if (!isPhoneValid(phone)) put(AuthField.Phone, "Некорректный номер телефона")
|
||||
}
|
||||
return ValidationResult(errors)
|
||||
}
|
||||
|
||||
fun validateSignUp(
|
||||
email: String,
|
||||
password: String,
|
||||
confirmPassword: String
|
||||
): ValidationResult {
|
||||
val errors = buildMap {
|
||||
if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email")
|
||||
validatePassword(password)?.let { put(AuthField.Password, it) }
|
||||
if (confirmPassword.isBlank()) put(AuthField.ConfirmPassword, "Повторите пароль")
|
||||
|
||||
if (password != confirmPassword) {
|
||||
put(AuthField.ConfirmPassword, "Пароли не совпадают")
|
||||
}
|
||||
}
|
||||
return ValidationResult(errors)
|
||||
}
|
||||
|
||||
fun validatePassword(password: String): String? {
|
||||
if (password.length < 8) {
|
||||
return "Пароль должен быть не менее 8 символов"
|
||||
}
|
||||
if (!password.any { it.isUpperCase() }) {
|
||||
return "Пароль должен содержать хотя бы одну заглавную букву"
|
||||
}
|
||||
if (!password.any { it.isDigit() }) {
|
||||
return "Пароль должен содержать хотя бы одну цифру"
|
||||
}
|
||||
if (!password.any { !it.isLetterOrDigit() }) {
|
||||
return "Пароль должен содержать хотя бы один специальный символ"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
fun validateLogin(
|
||||
email: String,
|
||||
password: String
|
||||
): ValidationResult {
|
||||
val errors = buildMap {
|
||||
if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email")
|
||||
validatePassword(password)?.let { put(AuthField.Password, it) }
|
||||
}
|
||||
return ValidationResult(errors)
|
||||
}
|
||||
|
||||
fun validateProfile(
|
||||
firstName: String,
|
||||
secondName: String,
|
||||
): ValidationResult {
|
||||
val errors = buildMap {
|
||||
if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя")
|
||||
if (secondName.isBlank()) put(AuthField.SecondName, "Введите фамилию")
|
||||
}
|
||||
return ValidationResult(errors)
|
||||
}
|
||||
|
||||
private fun isEmailValid(email: String): Boolean =
|
||||
email.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(email).matches()
|
||||
|
||||
private fun isPhoneValid(phone: String): Boolean =
|
||||
phone.isNotBlank() && Patterns.PHONE.matcher(phone).matches()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.prodhack.moscow2025.domain.utils
|
||||
|
||||
import com.prodhack.moscow2025.domain.utils.NetworkError.Connection
|
||||
import com.prodhack.moscow2025.domain.utils.NetworkError.InputError
|
||||
import com.prodhack.moscow2025.domain.utils.NetworkError.Unexpected
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import io.ktor.client.plugins.RedirectResponseException
|
||||
import io.ktor.client.plugins.ServerResponseException
|
||||
|
||||
/**
|
||||
* Network error wrapper class
|
||||
*/
|
||||
sealed class NetworkError : Throwable() {
|
||||
|
||||
/**
|
||||
* Network connection error
|
||||
*/
|
||||
class Connection() : NetworkError()
|
||||
|
||||
/**
|
||||
* Unexpected error for example HTTP code - 500 or exception when mapping data
|
||||
*/
|
||||
class Unexpected(val error: String) : NetworkError()
|
||||
|
||||
/**
|
||||
* User input error - 400 codes
|
||||
*/
|
||||
class InputError(val error: String) : NetworkError()
|
||||
}
|
||||
|
||||
fun Throwable.convertToNetworkError() =
|
||||
when (this) {
|
||||
is NetworkError -> this
|
||||
is RedirectResponseException -> Unexpected(error = message)
|
||||
is ClientRequestException -> InputError(error = message)
|
||||
is ServerResponseException -> Unexpected(error = message)
|
||||
else -> Connection()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.prodhack.moscow2025.domain.utils
|
||||
|
||||
import androidx.paging.PagingData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Simple wrapper for convenience of network requests in repositories
|
||||
*
|
||||
* @see Flow
|
||||
* @see Result
|
||||
* @see NetworkError
|
||||
*/
|
||||
internal typealias RemoteWrapper<T> = Flow<Result<T>>
|
||||
|
||||
/**
|
||||
* Simple wrapper for convenience of network paging requests in repositories
|
||||
*
|
||||
* @see Flow
|
||||
* @see PagingData
|
||||
*/
|
||||
internal typealias RemotePagingWrapper<T> = Flow<PagingData<T>>
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.prodhack.moscow2025.presentation
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.prodhack.moscow2025.domain.usecase.auth.CheckSessionUseCase
|
||||
import com.prodhack.moscow2025.presentation.navigation.AppDestination
|
||||
import com.prodhack.moscow2025.presentation.navigation.TTasksApp
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.android.ext.android.inject
|
||||
import kotlin.getValue
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val checkSessionUseCase: CheckSessionUseCase by inject()
|
||||
|
||||
private val sessionDestinationState = MutableStateFlow<AppDestination?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val splashScreen = installSplashScreen()
|
||||
var stateLoaded = false
|
||||
splashScreen.setKeepOnScreenCondition {
|
||||
stateLoaded.not()
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
runBlocking {
|
||||
val isAuthorized = try {
|
||||
checkSessionUseCase()
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
sessionDestinationState.value =
|
||||
if (isAuthorized) AppDestination.Main else AppDestination.Login
|
||||
|
||||
stateLoaded = true
|
||||
}
|
||||
|
||||
setContent {
|
||||
val sessionDestination by sessionDestinationState.collectAsState()
|
||||
TTasksApp(sessionDestination = sessionDestination, context = this)
|
||||
LaunchedEffect(Unit) {
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.ACCESS_NOTIFICATION_POLICY), 123
|
||||
)
|
||||
FirebaseMessaging.getInstance().token
|
||||
.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
val token = task.result
|
||||
Log.d("TOKEN", token)
|
||||
}
|
||||
}
|
||||
|
||||
checkAndRequestNotificationPermission()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkAndRequestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
when {
|
||||
ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED -> {
|
||||
// Разрешение уже есть, получаем токен
|
||||
getFCMToken()
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Запрашиваем разрешение
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
123
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Для версий ниже Android 13 разрешение не требуется
|
||||
getFCMToken()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFCMToken() {
|
||||
FirebaseMessaging.getInstance().token
|
||||
.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
val token = task.result
|
||||
Log.d("TOKEN", token)
|
||||
} else {
|
||||
Log.e("TOKEN", "Failed to get token", task.exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray,
|
||||
deviceId: Int
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == 123) {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
getFCMToken()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package com.prodhack.moscow2025.presentation.components
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.prodhack.moscow2025.R
|
||||
import com.prodhack.moscow2025.presentation.theme.MoscowHackatonTemplateTheme
|
||||
import com.prodhack.moscow2025.presentation.theme.Paddings
|
||||
import com.prodhack.moscow2025.presentation.theme.Shapes
|
||||
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
|
||||
|
||||
@Composable
|
||||
fun TBottomNavigation(modifier: Modifier = Modifier, selectedPage: Int, onSelect: (Int) -> Unit) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(vertical = Paddings.small),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
|
||||
val firstIconPos = remember { mutableFloatStateOf(0f) }
|
||||
val secondIconPos = remember { mutableFloatStateOf(0f) }
|
||||
val thirdIconPos = remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val indicatorOffset =
|
||||
with(LocalDensity.current) {
|
||||
when (selectedPage) {
|
||||
0 -> firstIconPos.floatValue - secondIconPos.floatValue
|
||||
1 -> 0f
|
||||
2 -> thirdIconPos.floatValue - secondIconPos.floatValue
|
||||
else -> null
|
||||
}?.toDp()
|
||||
}
|
||||
AnimatedVisibility(indicatorOffset != null) {
|
||||
indicatorOffset?.let {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(85.dp, 45.dp)
|
||||
.offset(x = animateDpAsState(it).value)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
shape = Shapes.smallRoundedBox
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.onGloballyPositioned {
|
||||
it.parentCoordinates?.positionInParent()?.let {
|
||||
firstIconPos.floatValue = it.x
|
||||
}
|
||||
}
|
||||
.noRippleClickable {
|
||||
onSelect(0)
|
||||
},
|
||||
painter = painterResource(R.drawable.ic_trips),
|
||||
tint = animateColorAsState(if (selectedPage == 0) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant).value,
|
||||
contentDescription = "open trips list screen"
|
||||
)
|
||||
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.onGloballyPositioned {
|
||||
it.parentCoordinates?.positionInParent()?.let {
|
||||
secondIconPos.floatValue = it.x
|
||||
}
|
||||
}
|
||||
.noRippleClickable {
|
||||
onSelect(1)
|
||||
},
|
||||
painter = painterResource(R.drawable.ic_home),
|
||||
tint = animateColorAsState(if (selectedPage == 1) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant).value,
|
||||
contentDescription = "open tasks screen"
|
||||
)
|
||||
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.onGloballyPositioned {
|
||||
it.parentCoordinates?.positionInParent()?.let {
|
||||
thirdIconPos.floatValue = it.x
|
||||
}
|
||||
}
|
||||
.noRippleClickable {
|
||||
onSelect(2)
|
||||
},
|
||||
painter = painterResource(R.drawable.ic_profile),
|
||||
tint = animateColorAsState(if (selectedPage == 2) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant).value,
|
||||
contentDescription = "open tasks screen"
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun TBottomNavigationPreview() {
|
||||
MoscowHackatonTemplateTheme {
|
||||
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom) {
|
||||
val page = remember { mutableIntStateOf(0) }
|
||||
TBottomNavigation(selectedPage = page.intValue) {
|
||||
Log.d("click", it.toString())
|
||||
page.intValue = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.prodhack.moscow2025.presentation.components.standart
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonColors
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.prodhack.moscow2025.presentation.theme.Shapes
|
||||
|
||||
@Composable
|
||||
fun BigButton(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
buttonText: String,
|
||||
isLoading: Boolean
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val typography = MaterialTheme.typography
|
||||
Button(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp),
|
||||
shape = Shapes.smallRoundedBox,
|
||||
onClick = onClick,
|
||||
enabled = !isLoading,
|
||||
colors = ButtonColors(
|
||||
containerColor = colorScheme.onPrimary,
|
||||
contentColor = colorScheme.primary,
|
||||
disabledContainerColor = colorScheme.onPrimary,
|
||||
disabledContentColor = colorScheme.primary
|
||||
)
|
||||
){
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
Text(
|
||||
text = buttonText,
|
||||
style = typography.labelMedium,
|
||||
fontSize = 24.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MediumButton(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
buttonText: String,
|
||||
isLoading: Boolean
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val typography = MaterialTheme.typography
|
||||
Button(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp),
|
||||
shape = Shapes.smallRoundedBox,
|
||||
onClick = onClick,
|
||||
enabled = !isLoading,
|
||||
colors = ButtonColors(
|
||||
containerColor = colorScheme.primary,
|
||||
contentColor = colorScheme.onPrimary,
|
||||
disabledContainerColor = colorScheme.primary,
|
||||
disabledContentColor = colorScheme.onPrimary
|
||||
)
|
||||
){
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
Text(
|
||||
text = buttonText,
|
||||
style = typography.labelMedium,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.prodhack.moscow2025.presentation.components.standart
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.prodhack.moscow2025.R
|
||||
import com.prodhack.moscow2025.presentation.theme.Shapes
|
||||
|
||||
@Composable
|
||||
fun TCheckBox(modifier: Modifier = Modifier, checked: Boolean, color: Color) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(Color.Transparent)
|
||||
.border(width = 1.dp, color = color, shape = Shapes.verySmallRoundedBox)
|
||||
) {
|
||||
AnimatedContent(checked) {
|
||||
if (it) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(2.dp),
|
||||
painter = painterResource(R.drawable.ic_checkmark),
|
||||
tint = color,
|
||||
contentDescription = "checkmark"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.prodhack.moscow2025.presentation.components.standart
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.prodhack.moscow2025.R
|
||||
|
||||
@Composable
|
||||
fun TTFloatingActionButton(
|
||||
modifier: Modifier,
|
||||
onClick: () -> Unit,
|
||||
text: String
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val typography = MaterialTheme.typography
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
onClick()
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
containerColor = colorScheme.tertiaryContainer,
|
||||
contentColor = colorScheme.onTertiaryContainer,
|
||||
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 5.dp)
|
||||
) {
|
||||
Row {
|
||||
Text(
|
||||
text = text,
|
||||
style = typography.titleMedium,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.add_square_outline),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.prodhack.moscow2025.presentation.components.standart
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme.typography
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun TTNamedTextField(
|
||||
name: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
error: String? = null,
|
||||
singleLine: Boolean = true,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
onDone: (() -> Unit)? = null
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = name,
|
||||
style = typography.labelLarge,
|
||||
fontSize = 14.sp,
|
||||
color = Color.White
|
||||
)
|
||||
Spacer(Modifier.height(5.dp))
|
||||
TTTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
error = error,
|
||||
singleLine = singleLine,
|
||||
keyboardOptions = keyboardOptions,
|
||||
onDone = onDone
|
||||
)
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.prodhack.moscow2025.presentation.components.standart
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun TTPasswordField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
error: String? = null,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
onDone: (() -> Unit)? = null
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val typography = MaterialTheme.typography
|
||||
var isVisible by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
Modifier.height(70.dp)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth().height(56.dp)
|
||||
.offset(x = 5.dp)
|
||||
.background(
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(15.dp)
|
||||
)
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth().offset(y = 5.dp),
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
textStyle = typography.labelLarge,
|
||||
placeholder = {
|
||||
Text(
|
||||
label,
|
||||
style = typography.labelLarge,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
},
|
||||
isError = error != null,
|
||||
supportingText = {
|
||||
if (error != null) {
|
||||
Text(
|
||||
text = error,
|
||||
style = typography.labelLarge,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
onDone?.invoke()
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = colorScheme.primary,
|
||||
unfocusedContainerColor = colorScheme.primary,
|
||||
errorContainerColor = colorScheme.error,
|
||||
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
errorBorderColor = Color.Transparent,
|
||||
|
||||
focusedPlaceholderColor = colorScheme.onPrimary,
|
||||
unfocusedPlaceholderColor = colorScheme.onPrimary,
|
||||
errorPlaceholderColor = colorScheme.onError,
|
||||
|
||||
focusedTextColor = colorScheme.onPrimary,
|
||||
unfocusedTextColor = colorScheme.onPrimary,
|
||||
errorTextColor = colorScheme.onError,
|
||||
|
||||
cursorColor = colorScheme.onPrimary,
|
||||
errorCursorColor = colorScheme.onError
|
||||
),
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
visualTransformation = if (isVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
val icon = if (isVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility
|
||||
IconButton(onClick = { isVisible = !isVisible }) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
package com.prodhack.moscow2025.presentation.components.standart
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.prodhack.moscow2025.R
|
||||
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TTTextField(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
readOnly: Boolean = false,
|
||||
label: String = "",
|
||||
error: String? = null,
|
||||
singleLine: Boolean = true,
|
||||
maxLines: Int = 1,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
onDone: (() -> Unit)? = null,
|
||||
trailingIcon: @Composable () -> Unit = {}
|
||||
) {
|
||||
val typography = MaterialTheme.typography
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Box(
|
||||
Modifier.height(70.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.offset(x = 5.dp)
|
||||
.background(
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(15.dp)
|
||||
)
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.offset(y = 5.dp),
|
||||
value = value,
|
||||
readOnly = readOnly,
|
||||
onValueChange = onValueChange,
|
||||
textStyle = typography.labelLarge,
|
||||
placeholder = {
|
||||
Text(
|
||||
label,
|
||||
style = typography.labelLarge,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
},
|
||||
isError = error != null,
|
||||
supportingText = {
|
||||
if (error != null) {
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = typography.labelLarge,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
onDone?.invoke()
|
||||
}
|
||||
),
|
||||
singleLine = singleLine,
|
||||
maxLines = maxLines,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = colorScheme.primary,
|
||||
unfocusedContainerColor = colorScheme.primary,
|
||||
errorContainerColor = colorScheme.error,
|
||||
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
errorBorderColor = Color.Transparent,
|
||||
|
||||
focusedPlaceholderColor = colorScheme.onPrimary,
|
||||
unfocusedPlaceholderColor = colorScheme.onPrimary,
|
||||
errorPlaceholderColor = colorScheme.onError,
|
||||
|
||||
focusedTextColor = colorScheme.onPrimary,
|
||||
unfocusedTextColor = colorScheme.onPrimary,
|
||||
errorTextColor = colorScheme.onError,
|
||||
|
||||
cursorColor = colorScheme.onPrimary,
|
||||
errorCursorColor = colorScheme.onError
|
||||
),
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
trailingIcon = trailingIcon
|
||||
)
|
||||
|
||||
if (readOnly && onClick != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.noRippleClickable(onClick)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun <T> TTTextFieldWithDropdown(
|
||||
modifier: Modifier = Modifier,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit = {},
|
||||
readOnly: Boolean = true,
|
||||
label: String,
|
||||
error: String? = null,
|
||||
singleLine: Boolean = true,
|
||||
maxLines: Int = 1,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
dropdownItems: List<T> = emptyList(),
|
||||
onDropdownItemSelected: (T) -> Unit = {},
|
||||
dropDownItem: @Composable (T) -> Unit,
|
||||
trailingIcon: @Composable (Boolean) -> Unit = {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.rotate(animateFloatAsState(if (it) 180f else 0f).value),
|
||||
painter = painterResource(R.drawable.ic_arr_dropdown),
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
) {
|
||||
val typography = MaterialTheme.typography
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier.height(70.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.offset(x = 5.dp)
|
||||
.background(
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(15.dp)
|
||||
)
|
||||
)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded },
|
||||
modifier = Modifier.offset(y = 5.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
value = value,
|
||||
readOnly = readOnly,
|
||||
onValueChange = onValueChange,
|
||||
textStyle = typography.labelLarge,
|
||||
placeholder = {
|
||||
Text(
|
||||
label,
|
||||
style = typography.labelLarge,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
},
|
||||
isError = error != null,
|
||||
supportingText = {
|
||||
if (error != null) {
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = typography.labelLarge,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = keyboardOptions,
|
||||
singleLine = singleLine,
|
||||
maxLines = maxLines,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = colorScheme.primary,
|
||||
unfocusedContainerColor = colorScheme.primary,
|
||||
errorContainerColor = colorScheme.error,
|
||||
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
errorBorderColor = Color.Transparent,
|
||||
|
||||
focusedPlaceholderColor = colorScheme.onPrimary,
|
||||
unfocusedPlaceholderColor = colorScheme.onPrimary,
|
||||
errorPlaceholderColor = colorScheme.onError,
|
||||
|
||||
focusedTextColor = colorScheme.onPrimary,
|
||||
unfocusedTextColor = colorScheme.onPrimary,
|
||||
errorTextColor = colorScheme.onError,
|
||||
|
||||
cursorColor = colorScheme.onPrimary,
|
||||
errorCursorColor = colorScheme.onError
|
||||
),
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
trailingIcon = {
|
||||
trailingIcon(expanded)
|
||||
}
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.exposedDropdownSize()
|
||||
) {
|
||||
if (dropdownItems.isEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text("Здесь пока ничего нет", style = typography.titleMedium)
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
dropdownItems.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
dropDownItem(item)
|
||||
},
|
||||
onClick = {
|
||||
onDropdownItemSelected(item)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun <T> TTTextFieldWithSearch(
|
||||
modifier: Modifier = Modifier,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit = {},
|
||||
readOnly: Boolean = true,
|
||||
label: String,
|
||||
error: String? = null,
|
||||
singleLine: Boolean = true,
|
||||
maxLines: Int = 1,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
dropdownItems: List<T> = emptyList(),
|
||||
onDropdownItemSelected: (T) -> Unit = {},
|
||||
dropDownItem: @Composable (T) -> Unit,
|
||||
trailingIcon: @Composable (Boolean) -> Unit = {}
|
||||
) {
|
||||
val typography = MaterialTheme.typography
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier.height(70.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.offset(x = 5.dp)
|
||||
.background(
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(15.dp)
|
||||
)
|
||||
)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded },
|
||||
modifier = Modifier.offset(y = 5.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable),
|
||||
value = value,
|
||||
readOnly = readOnly,
|
||||
onValueChange = onValueChange,
|
||||
textStyle = typography.labelLarge,
|
||||
placeholder = {
|
||||
Text(
|
||||
label,
|
||||
style = typography.labelLarge,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
},
|
||||
isError = error != null,
|
||||
supportingText = {
|
||||
if (error != null) {
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = typography.labelLarge,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = keyboardOptions,
|
||||
singleLine = singleLine,
|
||||
maxLines = maxLines,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = colorScheme.primary,
|
||||
unfocusedContainerColor = colorScheme.primary,
|
||||
errorContainerColor = colorScheme.error,
|
||||
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
errorBorderColor = Color.Transparent,
|
||||
|
||||
focusedPlaceholderColor = colorScheme.onPrimary,
|
||||
unfocusedPlaceholderColor = colorScheme.onPrimary,
|
||||
errorPlaceholderColor = colorScheme.onError,
|
||||
|
||||
focusedTextColor = colorScheme.onPrimary,
|
||||
unfocusedTextColor = colorScheme.onPrimary,
|
||||
errorTextColor = colorScheme.onError,
|
||||
|
||||
cursorColor = colorScheme.onPrimary,
|
||||
errorCursorColor = colorScheme.onError
|
||||
),
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
trailingIcon = {
|
||||
trailingIcon(expanded)
|
||||
}
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.exposedDropdownSize()
|
||||
) {
|
||||
dropdownItems.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
dropDownItem(item)
|
||||
},
|
||||
onClick = {
|
||||
onDropdownItemSelected(item)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.prodhack.moscow2025.presentation.components.standart
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.prodhack.moscow2025.R
|
||||
import com.prodhack.moscow2025.presentation.theme.Paddings
|
||||
|
||||
@Composable
|
||||
fun TopLogo(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier.size(100.dp),
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = "App logo"
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(Paddings.medium))
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontSize = 48.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.prodhack.moscow2025.presentation.navigation
|
||||
|
||||
/**
|
||||
* Centralized list of application destinations.
|
||||
*
|
||||
* Keeping the routes in one place helps to avoid
|
||||
* string duplication and makes refactoring safer.
|
||||
*/
|
||||
sealed class AppDestination(val route: String) {
|
||||
data object Login : AppDestination("app/login")
|
||||
data object Register : AppDestination("app/register")
|
||||
|
||||
data object Main : AppDestination("app/main")
|
||||
|
||||
|
||||
data object Profile : AppDestination("app/profile")
|
||||
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.prodhack.moscow2025.presentation.navigation
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import com.prodhack.moscow2025.presentation.components.TBottomNavigation
|
||||
import com.prodhack.moscow2025.presentation.theme.MoscowHackatonTemplateTheme
|
||||
|
||||
@Composable
|
||||
fun TTasksApp(
|
||||
appState: TTasksAppState = rememberTTasksAppState(),
|
||||
context: Context,
|
||||
sessionDestination: AppDestination? = null
|
||||
) {
|
||||
MoscowHackatonTemplateTheme() {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val bottomBarState = remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
when (appState.navController.currentBackStackEntryAsState().value?.destination?.route) {
|
||||
AppDestination.Login.route -> {
|
||||
bottomBarState.value = null
|
||||
}
|
||||
|
||||
AppDestination.Register.route -> {
|
||||
bottomBarState.value = null
|
||||
}
|
||||
|
||||
AppDestination.Main.route -> {
|
||||
bottomBarState.value = 1
|
||||
}
|
||||
|
||||
AppDestination.Profile.route -> {
|
||||
bottomBarState.value = 2
|
||||
}
|
||||
}
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
snackbarHost = {
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
snackbar = { data ->
|
||||
Snackbar(
|
||||
snackbarData = data,
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
shape = MaterialTheme.shapes.medium
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
bottomBarState.value?.let { bbState ->
|
||||
TBottomNavigation(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.windowInsetsPadding(WindowInsets.navigationBars),
|
||||
selectedPage = bbState
|
||||
) { newPage ->
|
||||
when (newPage) {
|
||||
0 -> {
|
||||
TODO()
|
||||
}
|
||||
|
||||
1 -> {
|
||||
appState.navController.navigate(AppDestination.Main.route)
|
||||
}
|
||||
|
||||
2 -> {
|
||||
appState.navController.navigate(AppDestination.Profile.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
TTasksNavHost(
|
||||
navController = appState.navController,
|
||||
modifier = Modifier.padding(padding),
|
||||
sessionDestination = sessionDestination,
|
||||
snackbarHostState = snackbarHostState,
|
||||
context = context
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.prodhack.moscow2025.presentation.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavOptionsBuilder
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@Stable
|
||||
class TTasksAppState(
|
||||
val navController: NavHostController,
|
||||
val coroutineScope: CoroutineScope
|
||||
) {
|
||||
val currentDestination: NavDestination?
|
||||
get() = navController.currentDestination
|
||||
|
||||
fun navigateTo(
|
||||
destination: AppDestination,
|
||||
builder: NavOptionsBuilder.() -> Unit = {}
|
||||
) {
|
||||
navController.navigate(destination.route, builder)
|
||||
}
|
||||
|
||||
fun navigateBack(): Boolean = navController.popBackStack()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberTTasksAppState(
|
||||
navController: NavHostController = rememberNavController(),
|
||||
coroutineScope: CoroutineScope = rememberCoroutineScope()
|
||||
): TTasksAppState = remember(navController, coroutineScope) {
|
||||
TTasksAppState(
|
||||
navController = navController,
|
||||
coroutineScope = coroutineScope
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.prodhack.moscow2025.presentation.navigation
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import com.prodhack.moscow2025.presentation.screens.main.MainScreen
|
||||
import com.prodhack.moscow2025.domain.utils.NetworkError
|
||||
import com.prodhack.moscow2025.presentation.screens.login.LoginScreen
|
||||
import com.prodhack.moscow2025.presentation.screens.register.RegisterScreen
|
||||
import com.prodhack.moscow2025.presentation.utils.ErrorCallbacks
|
||||
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
|
||||
import org.koin.compose.viewmodel.koinActivityViewModel
|
||||
|
||||
@Composable
|
||||
fun TTasksNavHost(
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier,
|
||||
sessionDestination: AppDestination? = null,
|
||||
context: Context,
|
||||
snackbarHostState: SnackbarHostState
|
||||
) {
|
||||
val startDestination = sessionDestination?.route ?: AppDestination.Login.route
|
||||
|
||||
ErrorCollectorScope(context, navController, object : ErrorCallbacks {
|
||||
override fun processConnectionError(networkError: NetworkError.Connection) {
|
||||
|
||||
}
|
||||
|
||||
override fun processUnexpectedError(networkError: NetworkError.Unexpected) {
|
||||
|
||||
}
|
||||
|
||||
}) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = modifier
|
||||
) {
|
||||
composable(AppDestination.Login.route) {
|
||||
LoginScreen(
|
||||
snackbarHostState = snackbarHostState,
|
||||
onRegisterClick = {
|
||||
navController.navigate(AppDestination.Register.route)
|
||||
},
|
||||
onSuccess = {
|
||||
navController.navigate(AppDestination.Main.route) {
|
||||
popUpTo(AppDestination.Login.route) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(AppDestination.Register.route) {
|
||||
RegisterScreen(
|
||||
snackbarHostState = snackbarHostState,
|
||||
onLoginClick = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onSuccess = {
|
||||
navController.navigate(AppDestination.Main.route) {
|
||||
popUpTo(AppDestination.Register.route) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(AppDestination.Main.route) {
|
||||
MainScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.prodhack.moscow2025.presentation.screens.fillProfile
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun FillProfileScreen() {
|
||||
Text("Fill profile will be here soon :)")
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
package com.prodhack.moscow2025.presentation.screens.fillProfile
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.map
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import com.prodhack.moscow2025.domain.interfaces.GalleryRepository
|
||||
import com.prodhack.moscow2025.domain.models.UpdateUserData
|
||||
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
|
||||
import com.prodhack.moscow2025.domain.usecase.auth.UpdateUserUseCase
|
||||
import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase
|
||||
import com.prodhack.moscow2025.presentation.utils.UIState
|
||||
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
|
||||
import com.prodhack.moscow2025.presentation.utils.toByteArray
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class FillProfileFormState(
|
||||
val displayName: String = "",
|
||||
val firstName: String = "",
|
||||
val lastName: String = "",
|
||||
val phone: String = "",
|
||||
val avatar: ByteArray? = null,
|
||||
val errors: Map<AuthField, String> = emptyMap()
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as FillProfileFormState
|
||||
|
||||
if (displayName != other.displayName) return false
|
||||
if (firstName != other.firstName) return false
|
||||
if (lastName != other.lastName) return false
|
||||
if (phone != other.phone) return false
|
||||
if (!avatar.contentEquals(other.avatar)) return false
|
||||
if (errors != other.errors) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = displayName.hashCode()
|
||||
result = 31 * result + firstName.hashCode()
|
||||
result = 31 * result + lastName.hashCode()
|
||||
result = 31 * result + phone.hashCode()
|
||||
result = 31 * result + (avatar?.contentHashCode() ?: 0)
|
||||
result = 31 * result + errors.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class FillProfileViewModel(
|
||||
private val updateUserUseCase: UpdateUserUseCase,
|
||||
private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase,
|
||||
private val galleryRepository: GalleryRepository
|
||||
) : BaseViewModel() {
|
||||
private val _formStateFillProfile = MutableStateFlow(FillProfileFormState())
|
||||
val formStateSignUp: StateFlow<FillProfileFormState> = _formStateFillProfile
|
||||
|
||||
|
||||
private val _profileFillState = MutableUIStateFlow<String>()
|
||||
val profileFillState: StateFlow<UIState<String>> = _profileFillState
|
||||
|
||||
|
||||
fun onDisplayNameChange(value: String) {
|
||||
_formStateFillProfile.update {
|
||||
it.copy(
|
||||
displayName = value,
|
||||
errors = it.errors - AuthField.Email
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onFirstNameChange(value: String) {
|
||||
_formStateFillProfile.update {
|
||||
it.copy(
|
||||
firstName = value,
|
||||
errors = it.errors - AuthField.Email
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onLastNameChange(value: String) {
|
||||
_formStateFillProfile.update {
|
||||
it.copy(
|
||||
lastName = value,
|
||||
errors = it.errors - AuthField.Email
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onPhoneChange(value: String) {
|
||||
_formStateFillProfile.update {
|
||||
it.copy(
|
||||
phone = value,
|
||||
errors = it.errors - AuthField.Email
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val galleryItems = galleryRepository.getImagesIds().map {
|
||||
it.map { id ->
|
||||
ContentUris.withAppendedId(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun post(context: Context) {
|
||||
viewModelScope.launch {
|
||||
post(
|
||||
(ImageLoader(context).execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(currentPhoto).build()
|
||||
).drawable as BitmapDrawable).bitmap
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun post(bitmap: Bitmap) {
|
||||
viewModelScope.launch {
|
||||
_formStateFillProfile.update {
|
||||
it.copy(
|
||||
avatar = bitmap.toByteArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAvatar() {
|
||||
viewModelScope.launch {
|
||||
_formStateFillProfile.update {
|
||||
it.copy(
|
||||
avatar = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var currentPhoto: Uri? = null
|
||||
|
||||
fun selectImage(photo: Uri) {
|
||||
currentPhoto = photo
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
viewModelScope.launch {
|
||||
val validation = validateAuthFieldsUseCase.validateFillProfile(
|
||||
displayName = _formStateFillProfile.value.displayName,
|
||||
firstName = _formStateFillProfile.value.firstName,
|
||||
lastName = _formStateFillProfile.value.lastName,
|
||||
phone = _formStateFillProfile.value.phone
|
||||
)
|
||||
|
||||
if (!validation.isValid) {
|
||||
_formStateFillProfile.update { it.copy(errors = validation.errors) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
_profileFillState.emit(UIState.Loading())
|
||||
|
||||
val result = updateUserUseCase(
|
||||
UpdateUserData(
|
||||
displayName = _formStateFillProfile.value.displayName,
|
||||
firstName = _formStateFillProfile.value.firstName,
|
||||
lastName = _formStateFillProfile.value.lastName,
|
||||
phone = _formStateFillProfile.value.phone
|
||||
)
|
||||
)
|
||||
result.map { it.id }.collectRequest(_profileFillState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.prodhack.moscow2025.presentation.screens.login
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.prodhack.moscow2025.R
|
||||
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
|
||||
import com.prodhack.moscow2025.presentation.components.standart.BigButton
|
||||
import com.prodhack.moscow2025.presentation.components.standart.TTPasswordField
|
||||
import com.prodhack.moscow2025.presentation.components.standart.TTTextField
|
||||
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
|
||||
import com.prodhack.moscow2025.presentation.utils.UIState
|
||||
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun ErrorCollectorScope.LoginScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
onRegisterClick: () -> Unit,
|
||||
onSuccess: () -> Unit,
|
||||
viewModel: LoginViewModel = koinViewModel()
|
||||
) {
|
||||
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
|
||||
val testCreds = listOf(
|
||||
Pair("user1@mail.ru", "qQW!!!.rty3nqc18123"),
|
||||
Pair("user2@mail.ru", "qQW!!!.rty3nqc18123"),
|
||||
Pair("user3@mail.ru", "qQW!!!.rty3nqc18123"),
|
||||
Pair("user4@mail.ru", "qQW!!!.rty3nqc18123"),
|
||||
Pair("user5@mail.ru", "qQW!!!.rty3nqc18123")
|
||||
)
|
||||
|
||||
val typography = MaterialTheme.typography
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val formState by viewModel.formState.collectAsState()
|
||||
|
||||
var errorText by remember { mutableStateOf("") }
|
||||
|
||||
val authState by viewModel.authState.collectAsStateWithCallbacks(
|
||||
onInputError = {
|
||||
errorText = it.error
|
||||
},
|
||||
onConnectionError = {
|
||||
errorText = "Нет подключения к сети"
|
||||
},
|
||||
onUnexpectedError = {
|
||||
errorText = it.error
|
||||
},
|
||||
onLoading = {
|
||||
errorText = ""
|
||||
},
|
||||
onSuccess = {
|
||||
errorText = ""
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(authState) {
|
||||
if (authState is UIState.Success) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(errorText) {
|
||||
if (errorText.isNotEmpty()) {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = "Ошибка: $errorText",
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.systemBarsPadding(),
|
||||
contentAlignment = Alignment.BottomStart
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.lottie),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.width(130.dp),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 30.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(250.dp)
|
||||
.noRippleClickable {
|
||||
showDialog.value = true
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "Вход",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontSize = 40.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
TTTextField(
|
||||
value = formState.email,
|
||||
onValueChange = viewModel::onEmailChange,
|
||||
label = "Ваш email",
|
||||
error = formState.errors[AuthField.Email]
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TTPasswordField(
|
||||
value = formState.password,
|
||||
onValueChange = viewModel::onPasswordChange,
|
||||
label = "Пароль",
|
||||
error = formState.errors[AuthField.Password],
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
onDone = viewModel::submit
|
||||
)
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
BigButton(
|
||||
onClick = viewModel::submit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
buttonText = "Войти",
|
||||
isLoading = authState is UIState.Loading
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
TextButton(
|
||||
onClick = onRegisterClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Зарегистрироваться",
|
||||
style = typography.labelMedium,
|
||||
color = colorScheme.onBackground,
|
||||
fontSize = 24.sp
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(80.dp))
|
||||
}
|
||||
if (showDialog.value) {
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
showDialog.value = false
|
||||
}
|
||||
) {
|
||||
Column {
|
||||
testCreds.forEach {
|
||||
Button(onClick = {
|
||||
viewModel.onEmailChange(it.first)
|
||||
viewModel.onPasswordChange(it.second)
|
||||
viewModel.submit()
|
||||
}) {
|
||||
Text(it.first)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.prodhack.moscow2025.presentation.screens.login
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.prodhack.moscow2025.domain.models.LoginData
|
||||
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
|
||||
import com.prodhack.moscow2025.domain.usecase.auth.LoginUserUseCase
|
||||
import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase
|
||||
import com.prodhack.moscow2025.presentation.utils.UIState
|
||||
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.annotation.KoinViewModel
|
||||
|
||||
data class LoginFormState(
|
||||
val email: String = "",
|
||||
val password: String = "",
|
||||
val errors: Map<AuthField, String> = emptyMap()
|
||||
)
|
||||
|
||||
@KoinViewModel
|
||||
class LoginViewModel(
|
||||
private val loginUserUseCase: LoginUserUseCase,
|
||||
private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val _formState = MutableStateFlow(LoginFormState())
|
||||
val formState: StateFlow<LoginFormState> = _formState
|
||||
|
||||
private val _authState = MutableUIStateFlow<String>()
|
||||
val authState: StateFlow<UIState<String>> = _authState
|
||||
|
||||
fun onEmailChange(value: String) {
|
||||
_formState.update { it.copy(email = value, errors = it.errors - AuthField.Email) }
|
||||
}
|
||||
|
||||
fun onPasswordChange(value: String) {
|
||||
_formState.update { it.copy(password = value, errors = it.errors - AuthField.Password) }
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
viewModelScope.launch {
|
||||
val validation = validateAuthFieldsUseCase.validateLogin(
|
||||
email = _formState.value.email,
|
||||
password = _formState.value.password
|
||||
)
|
||||
if (!validation.isValid) {
|
||||
_formState.update { it.copy(errors = validation.errors) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
_authState.emit(UIState.Loading())
|
||||
|
||||
val result = loginUserUseCase(
|
||||
LoginData(
|
||||
email = _formState.value.email,
|
||||
password = _formState.value.password
|
||||
)
|
||||
)
|
||||
result.collectRequest(_authState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.prodhack.moscow2025.presentation.screens.main
|
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ErrorCollectorScope.MainScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MainScreenViewModel = koinViewModel()
|
||||
) {
|
||||
Text("Main screen will be here soon")
|
||||
// val openCalendarModal = remember { mutableStateOf(false) }
|
||||
// val openTaskAddSheet = remember { mutableStateOf(false) }
|
||||
// val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
// val tasks = viewModel.taskList.collectAsLazyPagingItems()
|
||||
//
|
||||
// val selectedTask = remember { mutableStateOf<UITaskModel?>(null) }
|
||||
//
|
||||
// Box(
|
||||
// modifier = modifier
|
||||
// .fillMaxSize()
|
||||
// .padding(horizontal = Paddings.large),
|
||||
// contentAlignment = Alignment.BottomCenter
|
||||
// ) {
|
||||
// Column(
|
||||
// modifier = Modifier.fillMaxSize(),
|
||||
// horizontalAlignment = Alignment.CenterHorizontally
|
||||
// ) {
|
||||
// Spacer(modifier = Modifier.height(Paddings.large))
|
||||
// TopLogo()
|
||||
// Spacer(modifier = Modifier.height(Paddings.large))
|
||||
//
|
||||
// MainScreenFilters(viewModel = viewModel) {
|
||||
// openCalendarModal.value = true
|
||||
// }
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(Paddings.large))
|
||||
//
|
||||
// viewModel.topicList.FoldUIStateWithGlobalCallbacks { topics ->
|
||||
// BubbledCategoryFilters(
|
||||
// categories = topics,
|
||||
// selectedItemId = viewModel.selectedTopicId.value ?: -1
|
||||
// ) { categoryId ->
|
||||
// viewModel.selectTopic(categoryId)
|
||||
// }
|
||||
// }
|
||||
// Spacer(modifier = Modifier.height(Paddings.large))
|
||||
//
|
||||
// if (tasks.loadState.hasError) {
|
||||
// Text(
|
||||
// "Упс, кажется что-то пошло не так :(\nНо мы уже со всем разбираемся!",
|
||||
// style = Typography.titleMedium,
|
||||
// textAlign = TextAlign.Center,
|
||||
// fontSize = 18.sp,
|
||||
// color = MaterialTheme.colorScheme.error
|
||||
// )
|
||||
// } else if (tasks.loadState.append.endOfPaginationReached && tasks.itemCount == 0) {
|
||||
// Spacer(modifier = Modifier.weight(1f))
|
||||
//
|
||||
// Text(
|
||||
// "Сделал дело - гуляй смело\nСамое время запланировать новую поездку",
|
||||
// style = Typography.titleMedium,
|
||||
// textAlign = TextAlign.Center,
|
||||
// fontSize = 18.sp,
|
||||
// color = MaterialTheme.colorScheme.onBackground
|
||||
// )
|
||||
// Spacer(modifier = Modifier.height(Paddings.large))
|
||||
// BigButton(buttonText = "Начать", onClick = {
|
||||
//
|
||||
// }, isLoading = false)
|
||||
//
|
||||
// Spacer(modifier = Modifier.weight(3f))
|
||||
//
|
||||
// } else {
|
||||
// LazyColumn(
|
||||
// verticalArrangement = Arrangement.spacedBy(Paddings.small),
|
||||
// horizontalAlignment = Alignment.CenterHorizontally
|
||||
// ) {
|
||||
// items(tasks.itemCount) { it ->
|
||||
// val task = tasks[it]
|
||||
// task?.let {
|
||||
// TaskCard(
|
||||
// onClick = {
|
||||
// selectedTask.value = it
|
||||
// },
|
||||
// taskInfo = it,
|
||||
// isArchiveWaiting = it.id in viewModel.archiveWaitingTasksIds.value
|
||||
// ) {
|
||||
// viewModel.toggleTaskAsDone(
|
||||
// tripId = it.tripId,
|
||||
// taskId = it.id,
|
||||
// currState = it.archived
|
||||
// )
|
||||
// tasks.refresh()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// item {
|
||||
// if (!tasks.loadState.append.endOfPaginationReached) {
|
||||
// CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// TTFloatingActionButton(
|
||||
// modifier = Modifier
|
||||
// .align(Alignment.BottomCenter)
|
||||
// .padding(bottom = Paddings.medium),
|
||||
// onClick = {
|
||||
// openTaskAddSheet.value = true
|
||||
// },
|
||||
// text = "Добавить задачу"
|
||||
// )
|
||||
// }
|
||||
//
|
||||
//
|
||||
// AnimatedVisibility(openCalendarModal.value) {
|
||||
// DateRangePickerModal({
|
||||
// Log.d("DatePicker", it.toString())
|
||||
// if (it.first != null && it.second != null) {
|
||||
// viewModel.setDate(Pair(it.first!!, it.second!!))
|
||||
// openCalendarModal.value = false
|
||||
// }
|
||||
// }) {
|
||||
// openCalendarModal.value = false
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (openTaskAddSheet.value) {
|
||||
// AddTaskBottomSheet(
|
||||
// sheetState = sheetState,
|
||||
// onDismiss = {
|
||||
// openTaskAddSheet.value = false
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// val cs = MaterialTheme.colorScheme
|
||||
//
|
||||
// val viewSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
//
|
||||
// if (selectedTask.value != null) {
|
||||
//
|
||||
// val openCalendarModal2 = remember { mutableStateOf(false) }
|
||||
//
|
||||
// ModalBottomSheet(
|
||||
// onDismissRequest = {
|
||||
// selectedTask.value = null
|
||||
// },
|
||||
// sheetState = viewSheetState,
|
||||
// dragHandle = {},
|
||||
// shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp)
|
||||
// ) {
|
||||
// Column(
|
||||
// modifier = Modifier
|
||||
// .padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
// .verticalScroll(rememberScrollState()),
|
||||
// horizontalAlignment = Alignment.CenterHorizontally
|
||||
// ) {
|
||||
// Text(
|
||||
// text = "Просмотр задачи",
|
||||
// color = cs.onSurface,
|
||||
// style = Typography.titleMedium,
|
||||
// fontSize = 22.sp,
|
||||
// textAlign = TextAlign.Center,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .padding(bottom = 24.dp, top = 8.dp)
|
||||
// )
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
//
|
||||
// Text(
|
||||
// text = selectedTask.value!!.name,
|
||||
// color = cs.onSurface,
|
||||
// style = Typography.titleMedium,
|
||||
// fontSize = 20.sp,
|
||||
// textAlign = TextAlign.Center,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .padding(bottom = 24.dp, top = 8.dp)
|
||||
// )
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
//
|
||||
//
|
||||
// Text(
|
||||
// text = "Что нужно сделать",
|
||||
// color = cs.onSurface,
|
||||
// style = Typography.titleMedium,
|
||||
// fontSize = 18.sp,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .padding(bottom = 24.dp, top = 8.dp)
|
||||
// )
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(Paddings.small))
|
||||
//
|
||||
// Text(
|
||||
// text = selectedTask.value!!.whatNeedToDo,
|
||||
// color = cs.onSurface,
|
||||
// style = Typography.labelLarge,
|
||||
// fontSize = 16.sp,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .padding(bottom = 24.dp, top = 8.dp)
|
||||
// )
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
//
|
||||
// Text(
|
||||
// text = "Для чего",
|
||||
// color = cs.onSurface,
|
||||
// style = Typography.titleMedium,
|
||||
// fontSize = 18.sp,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .padding(bottom = 24.dp, top = 8.dp)
|
||||
// )
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(Paddings.small))
|
||||
//
|
||||
// Text(
|
||||
// text = selectedTask.value!!.reason,
|
||||
// color = cs.onSurface,
|
||||
// style = Typography.labelLarge,
|
||||
// fontSize = 16.sp,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .padding(bottom = 24.dp, top = 8.dp)
|
||||
// )
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(Paddings.large))
|
||||
//
|
||||
// TTTextField(
|
||||
// onClick = {
|
||||
// openCalendarModal2.value = true
|
||||
// },
|
||||
// value = timestampToDateWithYear(selectedTask.value!!.deadline),
|
||||
// readOnly = true,
|
||||
// onValueChange = {},
|
||||
// label = "Дедлайн",
|
||||
// trailingIcon = {
|
||||
// Icon(
|
||||
// modifier = Modifier
|
||||
// .size(24.dp),
|
||||
// painter = painterResource(
|
||||
// R.drawable.ic_calendar
|
||||
// ),
|
||||
// tint = MaterialTheme.colorScheme.onPrimary,
|
||||
// contentDescription = null
|
||||
// )
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// AnimatedVisibility(openCalendarModal2.value) {
|
||||
// DatePickerModal({
|
||||
// Log.d("DatePicker", it.toString())
|
||||
// it?.let { date ->
|
||||
// viewModel.changeTaskDeadline(selectedTask.value, date)
|
||||
// selectedTask.value = null
|
||||
// openCalendarModal.value = false
|
||||
// }
|
||||
// }) {
|
||||
// openCalendarModal.value = false
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.prodhack.moscow2025.presentation.screens.main
|
||||
|
||||
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
|
||||
import org.koin.android.annotation.KoinViewModel
|
||||
|
||||
|
||||
@KoinViewModel
|
||||
class MainScreenViewModel(
|
||||
// private val loadTasksUseCase: LoadTasksUseCase,
|
||||
// private val loadTasksTopicsListUseCase: LoadTasksTopicListUseCase,
|
||||
// private val setFinishedStateToTaskUseCase: SetFinishedStateToTaskUseCase,
|
||||
// private val changeDeadlineUseCase: ChangeDeadlineUseCase
|
||||
) : BaseViewModel() {
|
||||
|
||||
// var userChanged = false
|
||||
//
|
||||
// // Date filter
|
||||
// private val defaultDateFilterState =
|
||||
// getStartOfTodayTimestamp().let { Pair(it, it + 86400000) }
|
||||
//
|
||||
//
|
||||
// private val dateState =
|
||||
// mutableStateOf(defaultDateFilterState)
|
||||
//
|
||||
// val dateString = derivedStateOf {
|
||||
// Log.d(
|
||||
// "MainScreenViewModel",
|
||||
// "deriving state <dateString>, defaultDateFilterState - $defaultDateFilterState"
|
||||
// )
|
||||
// when (dateState.value.first) {
|
||||
// defaultDateFilterState.first -> "Сегодня"
|
||||
// defaultDateFilterState.second -> "Завтра"
|
||||
// else -> timestampToDate(dateState.value.first)
|
||||
// } + "-" +
|
||||
// when (dateState.value.second) {
|
||||
// defaultDateFilterState.first -> "Сегодня"
|
||||
// defaultDateFilterState.second -> "Завтра"
|
||||
// else -> timestampToDate(dateState.value.second)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// fun setDate(dates: Pair<Long, Long>) {
|
||||
// userChanged = true
|
||||
// dateState.value =
|
||||
// Pair(
|
||||
// convertGMTToSystemTimezone(dates.first),
|
||||
// convertGMTToSystemTimezone(dates.second)
|
||||
// )
|
||||
//
|
||||
// Log.d("MainScreenViewModel", "updated dates ${dateState.value}")
|
||||
// }
|
||||
//
|
||||
// // Other
|
||||
// val onlyMyTasksState = mutableStateOf(true)
|
||||
//
|
||||
// val showFinished = mutableStateOf(false)
|
||||
//
|
||||
// // Topic filters
|
||||
//
|
||||
// val selectedTopicId = mutableStateOf<Int?>(null)
|
||||
//
|
||||
// val topicList = MutableUIStateFlow<List<UITaskTopicModel>>()
|
||||
//
|
||||
// fun loadTopicList() {
|
||||
// loadTasksTopicsListUseCase().map { it -> it.map { it -> it.map { it.mapToUI() } } }
|
||||
// .collectRequest(topicList)
|
||||
// }
|
||||
//
|
||||
// fun selectTopic(id: Int) {
|
||||
// if (selectedTopicId.value == id) {
|
||||
// selectedTopicId.value = null
|
||||
// } else {
|
||||
// selectedTopicId.value = id
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Tasks
|
||||
// @OptIn(ExperimentalCoroutinesApi::class)
|
||||
// val taskList = snapshotFlow {
|
||||
// val dates = dateState.value
|
||||
// TaskFilters(
|
||||
// dateStart = dates.first,
|
||||
// dateEnd = dates.second,
|
||||
// topicId = selectedTopicId.value,
|
||||
// onlySelf = onlyMyTasksState.value,
|
||||
// showArchived = showFinished.value
|
||||
// )
|
||||
// }.flatMapLatest {
|
||||
// loadTasksUseCase(it)
|
||||
// }.map { it -> it.map { it.mapToUI() } }
|
||||
//
|
||||
// private val archiveWaitingTaskJobs = mutableStateMapOf<Long, Job>()
|
||||
//
|
||||
// val archiveWaitingTasksIds = derivedStateOf { archiveWaitingTaskJobs.keys }
|
||||
//
|
||||
// fun toggleTaskAsDone(tripId: Long, taskId: Long, currState: Boolean) {
|
||||
// if (currState) {
|
||||
// viewModelScope.launch {
|
||||
// setFinishedStateToTaskUseCase(
|
||||
// tripId = tripId,
|
||||
// taskId = taskId,
|
||||
// finishedState = false
|
||||
// )
|
||||
// }
|
||||
// } else {
|
||||
// if (taskId in archiveWaitingTasksIds.value) {
|
||||
// archiveWaitingTaskJobs[taskId]?.let { job ->
|
||||
// if (!job.isCompleted) {
|
||||
// job.cancel()
|
||||
// }
|
||||
// }
|
||||
// archiveWaitingTaskJobs.remove(taskId)
|
||||
// } else {
|
||||
// archiveWaitingTaskJobs[taskId] = viewModelScope.launch {
|
||||
// delay(1000)
|
||||
// setFinishedStateToTaskUseCase(
|
||||
// tripId = tripId,
|
||||
// taskId = taskId,
|
||||
// finishedState = true
|
||||
// )
|
||||
// }.also {
|
||||
// it.start()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// fun update() {
|
||||
// loadTopicList()
|
||||
// }
|
||||
//
|
||||
// fun changeTaskDeadline(value: UITaskModel?, date: Long) {
|
||||
// viewModelScope.launch {
|
||||
// value?.let {
|
||||
// changeDeadlineUseCase(value.tripId, value.id, date)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// init {
|
||||
// update()
|
||||
// }
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package com.prodhack.moscow2025.presentation.screens.register
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.prodhack.moscow2025.R
|
||||
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
|
||||
import com.prodhack.moscow2025.domain.utils.NetworkError
|
||||
import com.prodhack.moscow2025.presentation.components.standart.BigButton
|
||||
import com.prodhack.moscow2025.presentation.components.standart.TTPasswordField
|
||||
import com.prodhack.moscow2025.presentation.components.standart.TTTextField
|
||||
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
|
||||
import com.prodhack.moscow2025.presentation.utils.UIState
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun ErrorCollectorScope.RegisterScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
onLoginClick: () -> Unit,
|
||||
onSuccess: () -> Unit,
|
||||
viewModel: RegisterViewModel = koinViewModel()
|
||||
) {
|
||||
val typography = MaterialTheme.typography
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val formState by viewModel.formStateSignUp.collectAsState()
|
||||
var errorText by remember { mutableStateOf("") }
|
||||
val registerState by viewModel.registerState.collectAsStateWithCallbacks(
|
||||
onInputError = {
|
||||
errorText = it.error
|
||||
},
|
||||
onConnectionError = {
|
||||
errorText = "Нет подключения к сети"
|
||||
},
|
||||
onUnexpectedError = {
|
||||
errorText = it.error
|
||||
},
|
||||
onLoading = {
|
||||
errorText = ""
|
||||
},
|
||||
onSuccess = {
|
||||
errorText = ""
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(registerState) {
|
||||
if (registerState is UIState.Success) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(errorText) {
|
||||
if (errorText.isNotEmpty()) {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = "Ошибка: $errorText",
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.systemBarsPadding(),
|
||||
contentAlignment = Alignment.BottomStart
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.lottie),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.width(130.dp),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 30.dp, end = 30.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Давайте\nзнакомиться!",
|
||||
style = typography.titleLarge,
|
||||
fontSize = 31.sp
|
||||
)
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(140.dp),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
TTTextField(
|
||||
value = formState.email,
|
||||
onValueChange = viewModel::onEmailChange,
|
||||
label = "Ваш email",
|
||||
error = formState.errors[AuthField.Email]
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TTPasswordField(
|
||||
value = formState.password,
|
||||
onValueChange = viewModel::onPasswordChange,
|
||||
label = "Пароль",
|
||||
error = formState.errors[AuthField.Password]
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TTPasswordField(
|
||||
value = formState.confirmPassword,
|
||||
onValueChange = viewModel::onConfirmPasswordChange,
|
||||
label = "Повторите пароль",
|
||||
error = formState.errors[AuthField.ConfirmPassword],
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
onDone = viewModel::submit
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
BigButton(
|
||||
onClick = viewModel::submit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
buttonText = "Зарегистрироваться",
|
||||
isLoading = registerState is UIState.Loading
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
TextButton(
|
||||
onClick = onLoginClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Уже есть аккаунт?",
|
||||
style = typography.labelMedium,
|
||||
color = colorScheme.onBackground,
|
||||
fontSize = 24.sp
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.prodhack.moscow2025.presentation.screens.register
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.prodhack.moscow2025.domain.models.RegisterData
|
||||
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
|
||||
import com.prodhack.moscow2025.domain.usecase.auth.RegisterUserUseCase
|
||||
import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase
|
||||
import com.prodhack.moscow2025.presentation.utils.UIState
|
||||
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.annotation.KoinViewModel
|
||||
|
||||
data class RegisterFormState(
|
||||
val email: String = "",
|
||||
val password: String = "",
|
||||
val confirmPassword: String = "",
|
||||
val errors: Map<AuthField, String> = emptyMap()
|
||||
)
|
||||
|
||||
@KoinViewModel
|
||||
class RegisterViewModel(
|
||||
private val registerUserUseCase: RegisterUserUseCase,
|
||||
private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val _formStateSignUp = MutableStateFlow(RegisterFormState())
|
||||
val formStateSignUp: StateFlow<RegisterFormState> = _formStateSignUp
|
||||
|
||||
|
||||
private val _registerState = MutableUIStateFlow<String>()
|
||||
val registerState: StateFlow<UIState<String>> = _registerState
|
||||
|
||||
|
||||
fun onEmailChange(value: String) {
|
||||
_formStateSignUp.update { it.copy(email = value, errors = it.errors - AuthField.Email) }
|
||||
}
|
||||
|
||||
fun onPasswordChange(value: String) {
|
||||
_formStateSignUp.update {
|
||||
it.copy(
|
||||
password = value,
|
||||
errors = it.errors - AuthField.Password
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onConfirmPasswordChange(value: String) {
|
||||
_formStateSignUp.update {
|
||||
it.copy(
|
||||
confirmPassword = value,
|
||||
errors = it.errors - AuthField.ConfirmPassword
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
viewModelScope.launch {
|
||||
|
||||
val validation = validateAuthFieldsUseCase.validateSignUp(
|
||||
email = _formStateSignUp.value.email,
|
||||
password = _formStateSignUp.value.password,
|
||||
confirmPassword = _formStateSignUp.value.confirmPassword
|
||||
)
|
||||
|
||||
if (!validation.isValid) {
|
||||
_formStateSignUp.update { it.copy(errors = validation.errors) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
_registerState.emit(UIState.Loading())
|
||||
|
||||
val result = registerUserUseCase(
|
||||
RegisterData(
|
||||
email = _formStateSignUp.value.email,
|
||||
password = _formStateSignUp.value.password
|
||||
)
|
||||
)
|
||||
result.collectRequest(_registerState)
|
||||
|
||||
// val validation = validateAuthFieldsUseCase.validateRegister(
|
||||
// firstName = _formStateSignUp.value.firstName,
|
||||
// lastName = _formStateSignUp.value.lastName,
|
||||
// email = _formStateSignUp.value.email,
|
||||
// password = _formStateSignUp.value.password,
|
||||
// confirmPassword = _formStateSignUp.value.confirmPassword,
|
||||
// phone = _formStateSignUp.value.ph
|
||||
// )
|
||||
//
|
||||
// if (!validation.isValid) {
|
||||
// _formStateSignUp.update { it.copy(errors = validation.errors) }
|
||||
// return@launch
|
||||
// }
|
||||
//
|
||||
// _registerState.emit(UIState.Loading())
|
||||
//
|
||||
// val result = registerUserUseCase(
|
||||
// RegisterData(
|
||||
// firstName = _formStateSignUp.value.firstName,
|
||||
// secondName = _formStateSignUp.value.lastName,
|
||||
// email = _formStateSignUp.value.email,
|
||||
// password = _formStateSignUp.value.password
|
||||
// )
|
||||
// )
|
||||
// result.collectRequest(_registerState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.prodhack.moscow2025.presentation.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val WhitePrimary = Color(0xFF1b6b51)
|
||||
val WhiteSurfaceTint = Color(0xFF1b6b51)
|
||||
val WhiteOnPrimary = Color(0xFFFFFFFF)
|
||||
val WhitePrimaryContainer = Color(0xFFa6f2d1)
|
||||
val WhiteOnPrimaryContainer = Color(0xFF00513b)
|
||||
val WhiteSecondary = Color(0xFF4c6359)
|
||||
val WhiteOnSecondary = Color(0xFFFFFFFF)
|
||||
val WhiteSecondaryContainer = Color(0xFFcee9db)
|
||||
val WhiteOnSecondaryContainer = Color(0xFF354b41)
|
||||
val WhiteTertiary = Color(0xFF3e6374)
|
||||
val WhiteOnTertiary = Color(0xFFFFFFFF)
|
||||
val WhiteTertiaryContainer = Color(0xFFc2e8fd)
|
||||
val WhiteOnTertiaryContainer = Color(0xFF264b5c)
|
||||
val WhiteError = Color(0xFFba1a1a)
|
||||
val WhiteOnError = Color(0xFFFFFFFF)
|
||||
val WhiteErrorContainer = Color(0xFFffdad6)
|
||||
val WhiteOnErrorContainer = Color(0xFF93000a)
|
||||
val WhiteBackground = Color(0xFFf5fbf5)
|
||||
val WhiteOnBackground = Color(0xFF171d1a)
|
||||
val WhiteSurface = Color(0xFFf5fbf5)
|
||||
val WhiteOnSurface = Color(0xFF171d1a)
|
||||
val WhiteSurfaceVariant = Color(0xFFdbe5de)
|
||||
val WhiteOnSurfaceVariant = Color(0xFF404944)
|
||||
val WhiteOutline = Color(0xFF707974)
|
||||
val WhiteOutlineVariant = Color(0xFFbfc9c2)
|
||||
val WhiteShadow = Color(0xFF000000)
|
||||
val WhiteScrim = Color(0xFF000000)
|
||||
val WhiteInverseSurface = Color(0xFF2c322e)
|
||||
val WhiteInverseOnSurface = Color(0xFFecf2ed)
|
||||
val WhiteInversePrimary = Color(0xFF8bd6b6)
|
||||
val WhitePrimaryFixed = Color(0xFFa6f2d1)
|
||||
val WhiteOnPrimaryFixed = Color(0xFF002116)
|
||||
val WhitePrimaryFixedDim = Color(0xFF8bd6b6)
|
||||
val WhiteOnPrimaryFixedVariant = Color(0xFF00513b)
|
||||
val WhiteSecondaryFixed = Color(0xFFcee9db)
|
||||
val WhiteOnSecondaryFixed = Color(0xFF092017)
|
||||
val WhiteSecondaryFixedDim = Color(0xFFb3ccbf)
|
||||
val WhiteOnSecondaryFixedVariant = Color(0xFF354b41)
|
||||
val WhiteTertiaryFixed = Color(0xFFc2e8fd)
|
||||
val WhiteOnTertiaryFixed = Color(0xFF001f2a)
|
||||
val WhiteTertiaryFixedDim = Color(0xFFa6cce0)
|
||||
val WhiteOnTertiaryFixedVariant = Color(0xFF264b5c)
|
||||
val WhiteSurfaceDim = Color(0xFFd6dbd6)
|
||||
val WhiteSurfaceBright = Color(0xFFf5fbf5)
|
||||
val WhiteSurfaceContainerLowest = Color(0xFFFFFFFF)
|
||||
val WhiteSurfaceContainerLow = Color(0xFFeff5f0)
|
||||
val WhiteSurfaceContainer = Color(0xFFe9efea)
|
||||
val WhiteSurfaceContainerHigh = Color(0xFFe4eae4)
|
||||
val WhiteSurfaceContainerHighest = Color(0xFFdee4df)
|
||||
|
||||
val DarkPrimary = Color(0xFF8bd6b6)
|
||||
val DarkSurfaceTint = Color(0xFF8bd6b6)
|
||||
val DarkOnPrimary = Color(0xFF003828)
|
||||
val DarkPrimaryContainer = Color(0xFF00513b)
|
||||
val DarkOnPrimaryContainer = Color(0xFFa6f2d1)
|
||||
val DarkSecondary = Color(0xFFb3ccbf)
|
||||
val DarkOnSecondary = Color(0xFF1e352b)
|
||||
val DarkSecondaryContainer = Color(0xFF354b41)
|
||||
val DarkOnSecondaryContainer = Color(0xFFcee9db)
|
||||
val DarkTertiary = Color(0xFFa6cce0)
|
||||
val DarkOnTertiary = Color(0xFF093544)
|
||||
val DarkTertiaryContainer = Color(0xFF264b5c)
|
||||
val DarkOnTertiaryContainer = Color(0xFFc2e8fd)
|
||||
val DarkError = Color(0xFFffb4ab)
|
||||
val DarkOnError = Color(0xFF690005)
|
||||
val DarkErrorContainer = Color(0xFF93000a)
|
||||
val DarkOnErrorContainer = Color(0xFFffdad6)
|
||||
val DarkBackground = Color(0xFF0f1512)
|
||||
val DarkOnBackground = Color(0xFFdee4df)
|
||||
val DarkSurface = Color(0xFF0f1512)
|
||||
val DarkOnSurface = Color(0xFFdee4df)
|
||||
val DarkSurfaceVariant = Color(0xFF404944)
|
||||
val DarkOnSurfaceVariant = Color(0xFFbfc9c2)
|
||||
val DarkOutline = Color(0xFF89938d)
|
||||
val DarkOutlineVariant = Color(0xFF404944)
|
||||
val DarkShadow = Color(0xFF000000)
|
||||
val DarkScrim = Color(0xFF000000)
|
||||
val DarkInverseSurface = Color(0xFFdee4df)
|
||||
val DarkInverseOnSurface = Color(0xFF2c322e)
|
||||
val DarkInversePrimary = Color(0xFF1b6b51)
|
||||
val DarkPrimaryFixed = Color(0xFFa6f2d1)
|
||||
val DarkOnPrimaryFixed = Color(0xFF002116)
|
||||
val DarkPrimaryFixedDim = Color(0xFF8bd6b6)
|
||||
val DarkOnPrimaryFixedVariant = Color(0xFF00513b)
|
||||
val DarkSecondaryFixed = Color(0xFFcee9db)
|
||||
val DarkOnSecondaryFixed = Color(0xFF092017)
|
||||
val DarkSecondaryFixedDim = Color(0xFFb3ccbf)
|
||||
val DarkOnSecondaryFixedVariant = Color(0xFF354b41)
|
||||
val DarkTertiaryFixed = Color(0xFFc2e8fd)
|
||||
val DarkOnTertiaryFixed = Color(0xFF001f2a)
|
||||
val DarkTertiaryFixedDim = Color(0xFFa6cce0)
|
||||
val DarkOnTertiaryFixedVariant = Color(0xFF264b5c)
|
||||
val DarkSurfaceDim = Color(0xFF0f1512)
|
||||
val DarkSurfaceBright = Color(0xFF343b37)
|
||||
val DarkSurfaceContainerLowest = Color(0xFF0a0f0d)
|
||||
val DarkSurfaceContainerLow = Color(0xFF171d1a)
|
||||
val DarkSurfaceContainer = Color(0xFF1b211e)
|
||||
val DarkSurfaceContainerHigh = Color(0xFF252b28)
|
||||
val DarkSurfaceContainerHighest = Color(0xFF303633)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.prodhack.moscow2025.presentation.theme
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
object Paddings {
|
||||
val verySmall = 4.dp
|
||||
|
||||
val small = 8.dp
|
||||
|
||||
val medium = 12.dp
|
||||
val large = 20.dp
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.prodhack.moscow2025.presentation.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
object Shapes{
|
||||
val verySmallRoundedBox = RoundedCornerShape(Paddings.verySmall)
|
||||
|
||||
val smallRoundedBox = RoundedCornerShape(10.dp)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.prodhack.moscow2025.presentation.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
// Light color scheme
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = WhitePrimary,
|
||||
onPrimary = WhiteOnPrimary,
|
||||
primaryContainer = WhitePrimaryContainer,
|
||||
onPrimaryContainer = WhiteOnPrimaryContainer,
|
||||
inversePrimary = WhiteInversePrimary,
|
||||
|
||||
secondary = WhiteSecondary,
|
||||
onSecondary = WhiteOnSecondary,
|
||||
secondaryContainer = WhiteSecondaryContainer,
|
||||
onSecondaryContainer = WhiteOnSecondaryContainer,
|
||||
|
||||
tertiary = WhiteTertiary,
|
||||
onTertiary = WhiteOnTertiary,
|
||||
tertiaryContainer = WhiteTertiaryContainer,
|
||||
onTertiaryContainer = WhiteOnTertiaryContainer,
|
||||
|
||||
error = WhiteError,
|
||||
onError = WhiteOnError,
|
||||
errorContainer = WhiteErrorContainer,
|
||||
onErrorContainer = WhiteOnErrorContainer,
|
||||
|
||||
background = WhiteBackground,
|
||||
onBackground = WhiteOnBackground,
|
||||
surface = WhiteSurface,
|
||||
onSurface = WhiteOnSurface,
|
||||
surfaceVariant = WhiteSurfaceVariant,
|
||||
onSurfaceVariant = WhiteOnSurfaceVariant,
|
||||
inverseSurface = WhiteInverseSurface,
|
||||
inverseOnSurface = WhiteInverseOnSurface,
|
||||
|
||||
outline = WhiteOutline,
|
||||
outlineVariant = WhiteOutlineVariant,
|
||||
|
||||
scrim = WhiteScrim,
|
||||
surfaceTint = WhiteSurfaceTint,
|
||||
|
||||
// Fixed colors
|
||||
primaryFixed = WhitePrimaryFixed,
|
||||
onPrimaryFixed = WhiteOnPrimaryFixed,
|
||||
primaryFixedDim = WhitePrimaryFixedDim,
|
||||
onPrimaryFixedVariant = WhiteOnPrimaryFixedVariant,
|
||||
|
||||
secondaryFixed = WhiteSecondaryFixed,
|
||||
onSecondaryFixed = WhiteOnSecondaryFixed,
|
||||
secondaryFixedDim = WhiteSecondaryFixedDim,
|
||||
onSecondaryFixedVariant = WhiteOnSecondaryFixedVariant,
|
||||
|
||||
tertiaryFixed = WhiteTertiaryFixed,
|
||||
onTertiaryFixed = WhiteOnTertiaryFixed,
|
||||
tertiaryFixedDim = WhiteTertiaryFixedDim,
|
||||
onTertiaryFixedVariant = WhiteOnTertiaryFixedVariant,
|
||||
|
||||
surfaceDim = WhiteSurfaceDim,
|
||||
surfaceBright = WhiteSurfaceBright,
|
||||
surfaceContainerLowest = WhiteSurfaceContainerLowest,
|
||||
surfaceContainerLow = WhiteSurfaceContainerLow,
|
||||
surfaceContainer = WhiteSurfaceContainer,
|
||||
surfaceContainerHigh = WhiteSurfaceContainerHigh,
|
||||
surfaceContainerHighest = WhiteSurfaceContainerHighest
|
||||
)
|
||||
|
||||
// Dark color scheme
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = DarkPrimary,
|
||||
onPrimary = DarkOnPrimary,
|
||||
primaryContainer = DarkPrimaryContainer,
|
||||
onPrimaryContainer = DarkOnPrimaryContainer,
|
||||
inversePrimary = DarkInversePrimary,
|
||||
|
||||
secondary = DarkSecondary,
|
||||
onSecondary = DarkOnSecondary,
|
||||
secondaryContainer = DarkSecondaryContainer,
|
||||
onSecondaryContainer = DarkOnSecondaryContainer,
|
||||
|
||||
tertiary = DarkTertiary,
|
||||
onTertiary = DarkOnTertiary,
|
||||
tertiaryContainer = DarkTertiaryContainer,
|
||||
onTertiaryContainer = DarkOnTertiaryContainer,
|
||||
|
||||
error = DarkError,
|
||||
onError = DarkOnError,
|
||||
errorContainer = DarkErrorContainer,
|
||||
onErrorContainer = DarkOnErrorContainer,
|
||||
|
||||
background = DarkBackground,
|
||||
onBackground = DarkOnBackground,
|
||||
surface = DarkSurface,
|
||||
onSurface = DarkOnSurface,
|
||||
surfaceVariant = DarkSurfaceVariant,
|
||||
onSurfaceVariant = DarkOnSurfaceVariant,
|
||||
inverseSurface = DarkInverseSurface,
|
||||
inverseOnSurface = DarkInverseOnSurface,
|
||||
|
||||
outline = DarkOutline,
|
||||
outlineVariant = DarkOutlineVariant,
|
||||
|
||||
scrim = DarkScrim,
|
||||
surfaceTint = DarkSurfaceTint,
|
||||
|
||||
// Fixed colors
|
||||
primaryFixed = DarkPrimaryFixed,
|
||||
onPrimaryFixed = DarkOnPrimaryFixed,
|
||||
primaryFixedDim = DarkPrimaryFixedDim,
|
||||
onPrimaryFixedVariant = DarkOnPrimaryFixedVariant,
|
||||
|
||||
secondaryFixed = DarkSecondaryFixed,
|
||||
onSecondaryFixed = DarkOnSecondaryFixed,
|
||||
secondaryFixedDim = DarkSecondaryFixedDim,
|
||||
onSecondaryFixedVariant = DarkOnSecondaryFixedVariant,
|
||||
|
||||
tertiaryFixed = DarkTertiaryFixed,
|
||||
onTertiaryFixed = DarkOnTertiaryFixed,
|
||||
tertiaryFixedDim = DarkTertiaryFixedDim,
|
||||
onTertiaryFixedVariant = DarkOnTertiaryFixedVariant,
|
||||
|
||||
surfaceDim = DarkSurfaceDim,
|
||||
surfaceBright = DarkSurfaceBright,
|
||||
surfaceContainerLowest = DarkSurfaceContainerLowest,
|
||||
surfaceContainerLow = DarkSurfaceContainerLow,
|
||||
surfaceContainer = DarkSurfaceContainer,
|
||||
surfaceContainerHigh = DarkSurfaceContainerHigh,
|
||||
surfaceContainerHighest = DarkSurfaceContainerHighest
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MoscowHackatonTemplateTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.prodhack.moscow2025.presentation.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.prodhack.moscow2025.R
|
||||
|
||||
val TinkoffSansFamily = FontFamily(
|
||||
Font(
|
||||
R.font.tinkoff_sans_bold,
|
||||
FontWeight.Bold
|
||||
),
|
||||
Font(
|
||||
R.font.tinkoff_sans_regular,
|
||||
FontWeight.Normal
|
||||
),
|
||||
Font(
|
||||
R.font.tinkoff_sans_medium,
|
||||
FontWeight.Medium
|
||||
)
|
||||
)
|
||||
|
||||
val Typography = Typography(
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = TinkoffSansFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = TinkoffSansFamily,
|
||||
fontWeight = FontWeight.Medium
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = TinkoffSansFamily,
|
||||
fontWeight = FontWeight.Normal
|
||||
)
|
||||
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.prodhack.moscow2025.presentation.utils
|
||||
|
||||
fun <T> MutableSet<T>.toggleItem(item: T) {
|
||||
if (item in this) {
|
||||
remove(item)
|
||||
} else {
|
||||
add(item)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.prodhack.moscow2025.presentation.utils
|
||||
|
||||
fun String?.notNullOrBlank() = this != null && this.isNotBlank()
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.prodhack.moscow2025.presentation.utils
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
fun daysUntilTimestampZoned(targetTimestamp: Long, zoneId: ZoneId = ZoneId.systemDefault()): Int {
|
||||
val now = Instant.now().atZone(zoneId)
|
||||
val targetTime = Instant.ofEpochMilli(targetTimestamp).atZone(zoneId)
|
||||
|
||||
return ChronoUnit.DAYS.between(now, targetTime).toInt()
|
||||
}
|
||||
|
||||
fun getStartOfDayTimestamp(date: Date): Long {
|
||||
val localDate = date.toInstant()
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
return localDate.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
}
|
||||
|
||||
fun getStartOfTodayTimestamp(): Long {
|
||||
val today = LocalDate.now()
|
||||
return today.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
}
|
||||
|
||||
fun timestampToDate(timestamp: Long, timeZone: TimeZone = TimeZone.getDefault()): String {
|
||||
val date = Date(timestamp)
|
||||
val formatter = SimpleDateFormat("dd.MM", Locale.getDefault())
|
||||
formatter.timeZone = timeZone
|
||||
return formatter.format(date)
|
||||
}
|
||||
|
||||
fun timestampToDateWithYear(timestamp: Long, timeZone: TimeZone = TimeZone.getDefault()): String {
|
||||
val date = Date(timestamp)
|
||||
val formatter = SimpleDateFormat("dd.MM.YYYY", Locale.getDefault())
|
||||
formatter.timeZone = timeZone
|
||||
return formatter.format(date)
|
||||
}
|
||||
|
||||
fun convertGMTToSystemTimezone(gmtTimestamp: Long): Long {
|
||||
return getStartOfDayTimestamp(Date(gmtTimestamp))
|
||||
}
|
||||
|
||||
fun timestampToIso(timestamp: Long): String {
|
||||
return Instant.ofEpochMilli(timestamp).toString()
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.prodhack.moscow2025.presentation.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.prodhack.moscow2025.domain.utils.NetworkError
|
||||
import com.prodhack.moscow2025.domain.utils.convertToNetworkError
|
||||
import com.prodhack.moscow2025.presentation.utils.ui.placeholders.ErrorPlaceholder
|
||||
import com.prodhack.moscow2025.presentation.utils.ui.placeholders.LoadingPlaceholder
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
sealed class UIState<T> {
|
||||
class Idle<T> : UIState<T>()
|
||||
class Loading<T> : UIState<T>()
|
||||
class Error<T>(val error: NetworkError) : UIState<T>()
|
||||
class Success<T>(val data: T) : UIState<T>()
|
||||
|
||||
fun <S> map(mapper: (T) -> S): UIState<S> {
|
||||
return when (this) {
|
||||
is Idle -> Idle()
|
||||
is Loading -> Loading()
|
||||
is Error -> Error(this.error)
|
||||
is Success -> Success(mapper(this.data))
|
||||
}
|
||||
}
|
||||
|
||||
fun getOrNull(): T? = if (this is Success) {
|
||||
data
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val isSuccess: Boolean
|
||||
get() = this is Success
|
||||
}
|
||||
|
||||
interface ErrorCallbacks {
|
||||
fun processConnectionError(networkError: NetworkError.Connection)
|
||||
fun processUnexpectedError(networkError: NetworkError.Unexpected)
|
||||
}
|
||||
|
||||
open class ErrorCollectorScope(
|
||||
private val context: Context,
|
||||
val navController: NavController,
|
||||
private val errorCallbacks: ErrorCallbacks
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "ErrorCollectorScope"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> Flow<UIState<T>>.collectAsStateWithCallbacks(
|
||||
onInputError: ((NetworkError.InputError) -> Unit) = {
|
||||
Toast.makeText(context, "Something went wrong", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
},
|
||||
onUnexpectedError: ((NetworkError.Unexpected) -> Unit) = {},
|
||||
onConnectionError: ((NetworkError.Connection) -> Unit) = {},
|
||||
onLoading: (() -> Unit) = {},
|
||||
onSuccess: (T) -> Unit = {}
|
||||
): State<UIState<T>> = this.onEach {
|
||||
when (it) {
|
||||
is UIState.Loading -> {
|
||||
onLoading()
|
||||
}
|
||||
|
||||
is UIState.Error -> {
|
||||
Log.e(TAG, "collected error ${it.error}")
|
||||
when (it.error) {
|
||||
is NetworkError.Connection -> {
|
||||
errorCallbacks.processConnectionError(it.error)
|
||||
onConnectionError.invoke(it.error)
|
||||
}
|
||||
|
||||
is NetworkError.Unexpected -> {
|
||||
errorCallbacks.processUnexpectedError(it.error)
|
||||
onUnexpectedError.invoke(it.error)
|
||||
}
|
||||
is NetworkError.InputError -> onInputError.invoke(it.error)
|
||||
}
|
||||
}
|
||||
|
||||
is UIState.Success -> {
|
||||
onSuccess.invoke(it.data)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}.collectAsState(UIState.Idle())
|
||||
|
||||
|
||||
@Composable
|
||||
fun <T> Flow<UIState<T>>.collectAsValueStateWithCallbacks(
|
||||
onInputError: ((NetworkError.InputError) -> Unit) = {
|
||||
Toast.makeText(context, "Something went wrong", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
},
|
||||
onLoading: (() -> Unit) = {},
|
||||
onSuccess: (T) -> Unit = {}
|
||||
): State<T?> = this.map {
|
||||
when (it) {
|
||||
is UIState.Loading -> {
|
||||
onLoading()
|
||||
null
|
||||
}
|
||||
|
||||
is UIState.Error -> {
|
||||
Log.e(TAG, "collected error ${it.error}")
|
||||
when (it.error) {
|
||||
is NetworkError.Connection -> errorCallbacks.processConnectionError(it.error)
|
||||
is NetworkError.Unexpected -> errorCallbacks.processUnexpectedError(it.error)
|
||||
is NetworkError.InputError -> onInputError.invoke(it.error)
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
is UIState.Success -> {
|
||||
onSuccess.invoke(it.data)
|
||||
it.data
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}.collectAsState(null)
|
||||
|
||||
@Composable
|
||||
fun <T> Flow<UIState<T>>.FoldUIStateWithGlobalCallbacks(
|
||||
modifier: Modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 20.dp),
|
||||
onIdle: @Composable () -> Unit = {},
|
||||
onError: @Composable (NetworkError) -> Unit = { ErrorPlaceholder(modifier = modifier) { navController?.popBackStack() } },
|
||||
onLoading: @Composable () -> Unit = { LoadingPlaceholder(modifier = modifier) },
|
||||
onSuccess: @Composable (T) -> Unit
|
||||
) {
|
||||
val state = this.onEach {
|
||||
if (it is UIState.Error) {
|
||||
Log.e(TAG, "collected error ${it.error}")
|
||||
when (it.error) {
|
||||
is NetworkError.Connection -> errorCallbacks.processConnectionError(it.error)
|
||||
is NetworkError.Unexpected -> errorCallbacks.processUnexpectedError(it.error)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}.collectAsState(initial = UIState.Idle()).value
|
||||
|
||||
when (state) {
|
||||
is UIState.Idle -> {
|
||||
onIdle()
|
||||
}
|
||||
|
||||
is UIState.Error -> {
|
||||
onError(state.error.convertToNetworkError())
|
||||
}
|
||||
|
||||
is UIState.Loading -> {
|
||||
onLoading()
|
||||
}
|
||||
|
||||
is UIState.Success -> {
|
||||
onSuccess(state.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> UIState<T>.FoldUIStateWithGlobalCallbacks(
|
||||
modifier: Modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 20.dp),
|
||||
onIdle: @Composable () -> Unit = {},
|
||||
onError: @Composable (NetworkError) -> Unit = { ErrorPlaceholder(modifier = modifier) { navController?.popBackStack() } },
|
||||
onLoading: @Composable () -> Unit = { LoadingPlaceholder(modifier = modifier) },
|
||||
onSuccess: @Composable (T) -> Unit
|
||||
) {
|
||||
if (this is UIState.Error) {
|
||||
Log.e(TAG, "collected error ${this.error}")
|
||||
when (error) {
|
||||
is NetworkError.Connection -> errorCallbacks.processConnectionError(error)
|
||||
is NetworkError.Unexpected -> errorCallbacks.processUnexpectedError(error)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
when (this) {
|
||||
is UIState.Idle -> {
|
||||
onIdle()
|
||||
}
|
||||
|
||||
is UIState.Error -> {
|
||||
onError(error.convertToNetworkError())
|
||||
}
|
||||
|
||||
is UIState.Loading -> {
|
||||
onLoading()
|
||||
}
|
||||
|
||||
is UIState.Success -> {
|
||||
onSuccess.invoke(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorCollectorScope(
|
||||
context: Context,
|
||||
navController: NavController? = null,
|
||||
errorCallbacks: ErrorCallbacks,
|
||||
content: @Composable ErrorCollectorScope.() -> Unit
|
||||
) {
|
||||
ErrorCollectorScope(context, navController ?: rememberNavController(), errorCallbacks).content()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.prodhack.moscow2025.presentation.utils.base
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.map
|
||||
import com.prodhack.moscow2025.presentation.utils.UIState
|
||||
import com.prodhack.moscow2025.domain.utils.convertToNetworkError
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Base class for all [ViewModel]s
|
||||
*/
|
||||
abstract class BaseViewModel : ViewModel() {
|
||||
|
||||
/**
|
||||
* Creates [MutableStateFlow] with [UIState] and the given initial value [UIState.Idle]
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
protected fun <T> MutableUIStateFlow(defaultValue: T? = null) =
|
||||
MutableStateFlow<UIState<T>>(defaultValue?.let { UIState.Success(it) } ?: UIState.Idle())
|
||||
|
||||
/**
|
||||
* Reset [MutableUIStateFlow] to [UIState.Idle]
|
||||
*/
|
||||
protected fun <T> MutableStateFlow<UIState<T>>.reset() {
|
||||
value = UIState.Idle()
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect network request
|
||||
*
|
||||
* @return [UIState] depending request result
|
||||
*/
|
||||
protected fun <T> Flow<Result<T>>.collectRequest(
|
||||
state: MutableStateFlow<UIState<T>>,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
state.value = UIState.Loading()
|
||||
this@collectRequest.collect {
|
||||
state.value = if (it.isSuccess) {
|
||||
UIState.Success(it.getOrNull()!!)
|
||||
} else {
|
||||
UIState.Error(it.exceptionOrNull()!!.convertToNetworkError())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect network request
|
||||
*
|
||||
* @return [UIState] depending request result
|
||||
*/
|
||||
protected fun <T> Result<T>.collectRequest(
|
||||
state: MutableStateFlow<UIState<T>>
|
||||
) {
|
||||
state.value = UIState.Loading()
|
||||
state.value = if (isSuccess) {
|
||||
UIState.Success(getOrNull()!!)
|
||||
} else {
|
||||
UIState.Error(exceptionOrNull()!!.convertToNetworkError())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Collect paging request
|
||||
*/
|
||||
protected fun <T : Any, S : Any> Flow<PagingData<T>>.collectPagingRequest(
|
||||
mappedData: suspend (T) -> S
|
||||
) = map { it.map { data -> mappedData(data) } }.cachedIn(viewModelScope)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.prodhack.moscow2025.presentation.utils
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
fun Bitmap.toByteArray(): ByteArray {
|
||||
val stream = ByteArrayOutputStream() // Create a ByteArrayOutputStream
|
||||
compress(
|
||||
Bitmap.CompressFormat.JPEG,
|
||||
100,
|
||||
stream
|
||||
) // Compress Bitmap to PNG with 100% quality
|
||||
return stream.toByteArray() // Convert stream to byte array
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.prodhack.moscow2025.presentation.utils.ui
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.DateRangePicker
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberDateRangePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun DateRangePickerModal(
|
||||
onDateRangeSelected: (Pair<Long?, Long?>) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val dateRangePickerState = rememberDateRangePickerState()
|
||||
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDateRangeSelected(
|
||||
Pair(
|
||||
dateRangePickerState.selectedStartDateMillis,
|
||||
dateRangePickerState.selectedEndDateMillis
|
||||
)
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
Text("OK")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
) {
|
||||
DateRangePicker(
|
||||
state = dateRangePickerState,
|
||||
title = {
|
||||
Text(
|
||||
text = "Select date range"
|
||||
)
|
||||
},
|
||||
showModeToggle = false,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(500.dp)
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DatePickerModal(
|
||||
onDateSelected: (Long?) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val datePickerState = rememberDatePickerState()
|
||||
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onDateSelected(datePickerState.selectedDateMillis)
|
||||
onDismiss()
|
||||
}) {
|
||||
Text("OK")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
) {
|
||||
DatePicker(state = datePickerState)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.prodhack.moscow2025.presentation.utils.ui
|
||||
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
|
||||
fun Modifier.clickable(
|
||||
rippleColor: Color? = null,
|
||||
onClick: () -> Unit
|
||||
) = composed(
|
||||
inspectorInfo = debugInspectorInfo {
|
||||
name = "clickable"
|
||||
properties["rippleColor"] = rippleColor
|
||||
properties["onClick"] = onClick
|
||||
}
|
||||
) {
|
||||
this.clickable(
|
||||
onClick = onClick,
|
||||
indication = rippleColor?.let {
|
||||
ripple(
|
||||
color = it
|
||||
)
|
||||
} ?: LocalIndication.current,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.noRippleClickable(
|
||||
onClick: () -> Unit
|
||||
) = this.clickable(
|
||||
onClick = onClick,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
)
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.prodhack.moscow2025.presentation.utils.ui.placeholders
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.prodhack.moscow2025.presentation.theme.MoscowHackatonTemplateTheme
|
||||
|
||||
|
||||
data class ErrorTexts(
|
||||
val title: String = "Error",
|
||||
val mainText: String = "Oh mio dio! \n" +
|
||||
"Sembra che qualcosa non va",
|
||||
val description: String = "Lavoreremo per sistemare le cose, ti chiediamo tornare più tardi."
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ErrorPlaceholder(
|
||||
modifier: Modifier = Modifier,
|
||||
showTop: Boolean = false,
|
||||
small: Boolean = false,
|
||||
showButton: Boolean = true,
|
||||
errorTexts: ErrorTexts = ErrorTexts(),
|
||||
actionText: String = "Ok",
|
||||
onAction: () -> Unit
|
||||
) {
|
||||
Text("Error")
|
||||
}
|
||||
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ErrorPlaceHolderPreview() {
|
||||
MoscowHackatonTemplateTheme {
|
||||
Scaffold {
|
||||
ErrorPlaceholder(modifier = Modifier.padding(it), showTop = true) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.prodhack.moscow2025.presentation.utils.ui.placeholders
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.prodhack.moscow2025.presentation.theme.MoscowHackatonTemplateTheme
|
||||
|
||||
@Composable
|
||||
fun LoadingPlaceholder(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String = "Già quasi scaricato, per favore aspetta un po"
|
||||
) {
|
||||
Text(modifier = modifier, text = text)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun LoadingPlaceholderPreview() {
|
||||
Scaffold { paddingValues ->
|
||||
MoscowHackatonTemplateTheme() {
|
||||
LoadingPlaceholder(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user