Compare commits
10 Commits
103c69d7d1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8deb7668c8 | |||
| 2f6c53647e | |||
| 2de916cfd9 | |||
| 4ff516d06a | |||
| d8a0237e43 | |||
| b41de4aaf5 | |||
| 5105e68970 | |||
| 59087fe6d9 | |||
| c56399dde9 | |||
| b809b1e6e6 |
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DeviceTable">
|
||||||
|
<option name="columnSorters">
|
||||||
|
<list>
|
||||||
|
<ColumnSorterState>
|
||||||
|
<option name="column" value="Name" />
|
||||||
|
<option name="order" value="ASCENDING" />
|
||||||
|
</ColumnSorterState>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -100,6 +100,9 @@ dependencies {
|
|||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
|
|
||||||
|
// DataStore
|
||||||
|
implementation(libs.datastore.preferences)
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:theme="@style/Theme.NewPlanet">
|
android:theme="@style/Theme.NewPlanet">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import com.novayaplaneta.data.local.entity.UserEntity
|
|||||||
RewardEntity::class,
|
RewardEntity::class,
|
||||||
ChatMessageEntity::class
|
ChatMessageEntity::class
|
||||||
],
|
],
|
||||||
version = 1,
|
version = 4,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class NewPlanetDatabase : RoomDatabase() {
|
abstract class NewPlanetDatabase : RoomDatabase() {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.novayaplaneta.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "auth_preferences")
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class TokenManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private val accessTokenKey = stringPreferencesKey("access_token")
|
||||||
|
private val refreshTokenKey = stringPreferencesKey("refresh_token")
|
||||||
|
|
||||||
|
val accessToken: Flow<String?> = context.dataStore.data.map { preferences ->
|
||||||
|
preferences[accessTokenKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
val refreshToken: Flow<String?> = context.dataStore.data.map { preferences ->
|
||||||
|
preferences[refreshTokenKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getAccessToken(): String? {
|
||||||
|
return accessToken.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getRefreshToken(): String? {
|
||||||
|
return refreshToken.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveTokens(accessToken: String, refreshToken: String) {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences[accessTokenKey] = accessToken
|
||||||
|
preferences[refreshTokenKey] = refreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearTokens() {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences.remove(accessTokenKey)
|
||||||
|
preferences.remove(refreshTokenKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun isAuthenticated(): Boolean {
|
||||||
|
return getAccessToken() != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -10,8 +10,11 @@ data class RewardEntity(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
val points: Int,
|
val pointsRequired: Int,
|
||||||
|
val isClaimed: Boolean = false,
|
||||||
val earnedAt: Long?, // timestamp
|
val earnedAt: Long?, // timestamp
|
||||||
val userId: String
|
val userId: String,
|
||||||
|
val createdAt: Long? = null, // timestamp
|
||||||
|
val updatedAt: Long? = null // timestamp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ data class ScheduleEntity(
|
|||||||
val description: String?,
|
val description: String?,
|
||||||
val date: Long, // timestamp
|
val date: Long, // timestamp
|
||||||
val createdAt: Long, // timestamp
|
val createdAt: Long, // timestamp
|
||||||
val userId: String
|
val userId: String,
|
||||||
|
val rewardId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ data class TaskEntity(
|
|||||||
val completed: Boolean = false,
|
val completed: Boolean = false,
|
||||||
val scheduledTime: Long?, // timestamp
|
val scheduledTime: Long?, // timestamp
|
||||||
val duration: Int? = null,
|
val duration: Int? = null,
|
||||||
val scheduleId: String
|
val scheduleId: String,
|
||||||
|
val order: Int? = null,
|
||||||
|
val category: String? = null,
|
||||||
|
val createdAt: Long? = null, // timestamp
|
||||||
|
val updatedAt: Long? = null // timestamp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import androidx.room.PrimaryKey
|
|||||||
data class UserEntity(
|
data class UserEntity(
|
||||||
@PrimaryKey
|
@PrimaryKey
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val fullName: String,
|
||||||
val email: String,
|
val email: String,
|
||||||
val role: String,
|
val role: String,
|
||||||
val token: String?
|
val createdAt: String?,
|
||||||
|
val updatedAt: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,18 @@ fun RewardEntity.toDomain(): Reward {
|
|||||||
title = title,
|
title = title,
|
||||||
description = description,
|
description = description,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
points = points,
|
pointsRequired = pointsRequired,
|
||||||
|
isClaimed = isClaimed,
|
||||||
earnedAt = earnedAt?.let {
|
earnedAt = earnedAt?.let {
|
||||||
LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())
|
LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())
|
||||||
},
|
},
|
||||||
userId = userId
|
userId = userId,
|
||||||
|
createdAt = createdAt?.let {
|
||||||
|
LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())
|
||||||
|
},
|
||||||
|
updatedAt = updatedAt?.let {
|
||||||
|
LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,9 +33,12 @@ fun Reward.toEntity(): RewardEntity {
|
|||||||
title = title,
|
title = title,
|
||||||
description = description,
|
description = description,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
points = points,
|
pointsRequired = pointsRequired,
|
||||||
|
isClaimed = isClaimed,
|
||||||
earnedAt = earnedAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(),
|
earnedAt = earnedAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(),
|
||||||
userId = userId
|
userId = userId,
|
||||||
|
createdAt = createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(),
|
||||||
|
updatedAt = updatedAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ fun ScheduleEntity.toDomain(tasks: List<Task> = emptyList()): Schedule {
|
|||||||
tasks = tasks,
|
tasks = tasks,
|
||||||
date = LocalDateTime.ofInstant(Instant.ofEpochMilli(date), ZoneId.systemDefault()),
|
date = LocalDateTime.ofInstant(Instant.ofEpochMilli(date), ZoneId.systemDefault()),
|
||||||
createdAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(createdAt), ZoneId.systemDefault()),
|
createdAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(createdAt), ZoneId.systemDefault()),
|
||||||
userId = userId
|
userId = userId,
|
||||||
|
rewardId = rewardId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +27,8 @@ fun Schedule.toEntity(): ScheduleEntity {
|
|||||||
description = description,
|
description = description,
|
||||||
date = date.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(),
|
date = date.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(),
|
||||||
createdAt = createdAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(),
|
createdAt = createdAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(),
|
||||||
userId = userId
|
userId = userId,
|
||||||
|
rewardId = rewardId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,15 @@ fun TaskEntity.toDomain(): Task {
|
|||||||
LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())
|
LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())
|
||||||
},
|
},
|
||||||
duration = duration,
|
duration = duration,
|
||||||
scheduleId = scheduleId
|
scheduleId = scheduleId,
|
||||||
|
order = order ?: 0,
|
||||||
|
category = category,
|
||||||
|
createdAt = createdAt?.let {
|
||||||
|
LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())
|
||||||
|
},
|
||||||
|
updatedAt = updatedAt?.let {
|
||||||
|
LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +38,11 @@ fun Task.toEntity(): TaskEntity {
|
|||||||
completed = completed,
|
completed = completed,
|
||||||
scheduledTime = scheduledTime?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(),
|
scheduledTime = scheduledTime?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(),
|
||||||
duration = duration,
|
duration = duration,
|
||||||
scheduleId = scheduleId
|
scheduleId = scheduleId,
|
||||||
|
order = order,
|
||||||
|
category = category,
|
||||||
|
createdAt = createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(),
|
||||||
|
updatedAt = updatedAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,20 +7,22 @@ import com.novayaplaneta.domain.model.UserRole
|
|||||||
fun UserEntity.toDomain(): User {
|
fun UserEntity.toDomain(): User {
|
||||||
return User(
|
return User(
|
||||||
id = id,
|
id = id,
|
||||||
name = name,
|
fullName = fullName,
|
||||||
email = email,
|
email = email,
|
||||||
role = UserRole.valueOf(role),
|
role = UserRole.valueOf(role),
|
||||||
token = token
|
createdAt = createdAt,
|
||||||
|
updatedAt = updatedAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun User.toEntity(): UserEntity {
|
fun User.toEntity(): UserEntity {
|
||||||
return UserEntity(
|
return UserEntity(
|
||||||
id = id,
|
id = id,
|
||||||
name = name,
|
fullName = fullName,
|
||||||
email = email,
|
email = email,
|
||||||
role = role.name,
|
role = role.name,
|
||||||
token = token
|
createdAt = createdAt,
|
||||||
|
updatedAt = updatedAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
app/src/main/java/com/novayaplaneta/data/remote/AiApi.kt
Normal file
22
app/src/main/java/com/novayaplaneta/data/remote/AiApi.kt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package com.novayaplaneta.data.remote
|
||||||
|
|
||||||
|
import com.novayaplaneta.data.remote.dto.ChatRequest
|
||||||
|
import com.novayaplaneta.data.remote.dto.ChatResponse
|
||||||
|
import com.novayaplaneta.data.remote.dto.GenerateScheduleRequest
|
||||||
|
import com.novayaplaneta.data.remote.dto.GenerateScheduleResponse
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.POST
|
||||||
|
|
||||||
|
interface AiApi {
|
||||||
|
@POST("api/v1/ai/chat")
|
||||||
|
suspend fun chat(
|
||||||
|
@Body request: ChatRequest
|
||||||
|
): Response<ChatResponse>
|
||||||
|
|
||||||
|
@POST("api/v1/ai/schedule/generate")
|
||||||
|
suspend fun generateSchedule(
|
||||||
|
@Body request: GenerateScheduleRequest
|
||||||
|
): Response<GenerateScheduleResponse>
|
||||||
|
}
|
||||||
|
|
||||||
42
app/src/main/java/com/novayaplaneta/data/remote/AuthApi.kt
Normal file
42
app/src/main/java/com/novayaplaneta/data/remote/AuthApi.kt
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package com.novayaplaneta.data.remote
|
||||||
|
|
||||||
|
import com.novayaplaneta.data.remote.dto.MeResponse
|
||||||
|
import com.novayaplaneta.data.remote.dto.RefreshRequest
|
||||||
|
import com.novayaplaneta.data.remote.dto.RefreshResponse
|
||||||
|
import com.novayaplaneta.data.remote.dto.RegisterRequest
|
||||||
|
import com.novayaplaneta.data.remote.dto.RegisterResponse
|
||||||
|
import com.novayaplaneta.data.remote.dto.TokenResponse
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.Field
|
||||||
|
import retrofit2.http.FormUrlEncoded
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
|
||||||
|
interface AuthApi {
|
||||||
|
@POST("api/v1/auth/register")
|
||||||
|
suspend fun register(
|
||||||
|
@Body request: RegisterRequest
|
||||||
|
): Response<RegisterResponse>
|
||||||
|
|
||||||
|
@FormUrlEncoded
|
||||||
|
@POST("api/v1/auth/login")
|
||||||
|
suspend fun login(
|
||||||
|
@Field("grant_type") grantType: String = "password",
|
||||||
|
@Field("username") username: String,
|
||||||
|
@Field("password") password: String,
|
||||||
|
@Field("scope") scope: String = "",
|
||||||
|
@Field("client_id") clientId: String? = null,
|
||||||
|
@Field("client_secret") clientSecret: String? = null
|
||||||
|
): Response<TokenResponse>
|
||||||
|
|
||||||
|
@POST("api/v1/auth/refresh")
|
||||||
|
suspend fun refresh(
|
||||||
|
@Body request: RefreshRequest
|
||||||
|
): Response<RefreshResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/auth/me")
|
||||||
|
suspend fun getMe(): Response<MeResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.novayaplaneta.data.remote
|
||||||
|
|
||||||
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
import com.novayaplaneta.data.local.TokenManager
|
||||||
|
import com.novayaplaneta.data.remote.dto.RefreshRequest
|
||||||
|
import com.novayaplaneta.data.remote.dto.RefreshResponse
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Response
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AuthInterceptor @Inject constructor(
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
// Создаем отдельный Retrofit для refresh запросов без interceptor
|
||||||
|
private val refreshRetrofit: Retrofit by lazy {
|
||||||
|
val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
encodeDefaults = false
|
||||||
|
}
|
||||||
|
val contentType = "application/json".toMediaType()
|
||||||
|
val okHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
Retrofit.Builder()
|
||||||
|
.baseUrl("http://localhost:8000/")
|
||||||
|
.client(okHttpClient)
|
||||||
|
.addConverterFactory(json.asConverterFactory(contentType))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val refreshAuthApi: AuthApi by lazy {
|
||||||
|
refreshRetrofit.create(AuthApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val originalRequest = chain.request()
|
||||||
|
|
||||||
|
// Не добавляем токен к auth эндпоинтам
|
||||||
|
val isAuthEndpoint = originalRequest.url.encodedPath.contains("/auth/login") ||
|
||||||
|
originalRequest.url.encodedPath.contains("/auth/register") ||
|
||||||
|
originalRequest.url.encodedPath.contains("/auth/refresh")
|
||||||
|
|
||||||
|
// Добавляем токен к запросу, если он есть и это не auth эндпоинт
|
||||||
|
val accessToken = runBlocking { tokenManager.getAccessToken() }
|
||||||
|
val requestWithToken = if (accessToken != null && !isAuthEndpoint) {
|
||||||
|
originalRequest.newBuilder()
|
||||||
|
.header("Authorization", "Bearer $accessToken")
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
originalRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = chain.proceed(requestWithToken)
|
||||||
|
|
||||||
|
// Если получили 401, пытаемся обновить токен
|
||||||
|
if (response.code == 401 && !isAuthEndpoint && !originalRequest.url.encodedPath.contains("/auth/refresh")) {
|
||||||
|
val refreshToken = runBlocking { tokenManager.getRefreshToken() }
|
||||||
|
|
||||||
|
if (refreshToken != null) {
|
||||||
|
try {
|
||||||
|
val refreshResponse = runBlocking {
|
||||||
|
refreshAuthApi.refresh(RefreshRequest(refreshToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshResponse.isSuccessful && refreshResponse.body() != null) {
|
||||||
|
val tokenResponse = refreshResponse.body()!!
|
||||||
|
runBlocking {
|
||||||
|
tokenManager.saveTokens(
|
||||||
|
tokenResponse.access_token,
|
||||||
|
tokenResponse.refresh_token
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторяем исходный запрос с новым токеном
|
||||||
|
val newRequest = originalRequest.newBuilder()
|
||||||
|
.header("Authorization", "Bearer ${tokenResponse.access_token}")
|
||||||
|
.build()
|
||||||
|
response.close()
|
||||||
|
response = chain.proceed(newRequest)
|
||||||
|
} else {
|
||||||
|
// Refresh не удался, очищаем токены
|
||||||
|
runBlocking { tokenManager.clearTokens() }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ошибка при refresh, очищаем токены
|
||||||
|
runBlocking { tokenManager.clearTokens() }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Нет refresh токена, очищаем
|
||||||
|
runBlocking { tokenManager.clearTokens() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,13 +2,25 @@ package com.novayaplaneta.data.remote
|
|||||||
|
|
||||||
import com.novayaplaneta.data.remote.dto.ChatRequest
|
import com.novayaplaneta.data.remote.dto.ChatRequest
|
||||||
import com.novayaplaneta.data.remote.dto.ChatResponse
|
import com.novayaplaneta.data.remote.dto.ChatResponse
|
||||||
|
import com.novayaplaneta.data.remote.dto.ClaimRewardResponse
|
||||||
|
import com.novayaplaneta.data.remote.dto.CompleteTaskResponse
|
||||||
|
import com.novayaplaneta.data.remote.dto.CreateRewardRequest
|
||||||
|
import com.novayaplaneta.data.remote.dto.CreateScheduleRequest
|
||||||
|
import com.novayaplaneta.data.remote.dto.CreateTaskRequest
|
||||||
import com.novayaplaneta.data.remote.dto.LoginRequest
|
import com.novayaplaneta.data.remote.dto.LoginRequest
|
||||||
import com.novayaplaneta.data.remote.dto.LoginResponse
|
import com.novayaplaneta.data.remote.dto.LoginResponse
|
||||||
|
import com.novayaplaneta.data.remote.dto.RewardDto
|
||||||
|
import com.novayaplaneta.data.remote.dto.ScheduleDto
|
||||||
|
import com.novayaplaneta.data.remote.dto.TaskDto
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Header
|
import retrofit2.http.Header
|
||||||
|
import retrofit2.http.PATCH
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
interface BackendApi {
|
interface BackendApi {
|
||||||
@POST("api/v1/auth/login")
|
@POST("api/v1/auth/login")
|
||||||
@@ -21,5 +33,82 @@ interface BackendApi {
|
|||||||
@Header("Authorization") token: String,
|
@Header("Authorization") token: String,
|
||||||
@Body request: ChatRequest
|
@Body request: ChatRequest
|
||||||
): Response<ChatResponse>
|
): Response<ChatResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/schedules")
|
||||||
|
suspend fun getSchedules(
|
||||||
|
@Query("skip") skip: Int = 0,
|
||||||
|
@Query("limit") limit: Int = 100,
|
||||||
|
@Query("schedule_date") scheduleDate: String? = null
|
||||||
|
): Response<List<ScheduleDto>>
|
||||||
|
|
||||||
|
@POST("api/v1/schedules")
|
||||||
|
suspend fun createSchedule(
|
||||||
|
@Body request: CreateScheduleRequest
|
||||||
|
): Response<ScheduleDto>
|
||||||
|
|
||||||
|
@GET("api/v1/schedules/{schedule_id}")
|
||||||
|
suspend fun getScheduleById(
|
||||||
|
@Path("schedule_id") scheduleId: String
|
||||||
|
): Response<ScheduleDto>
|
||||||
|
|
||||||
|
@DELETE("api/v1/schedules/{schedule_id}")
|
||||||
|
suspend fun deleteSchedule(
|
||||||
|
@Path("schedule_id") scheduleId: String
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
// Tasks endpoints
|
||||||
|
@GET("api/v1/tasks/schedule/{schedule_id}")
|
||||||
|
suspend fun getTasksByScheduleId(
|
||||||
|
@Path("schedule_id") scheduleId: String
|
||||||
|
): Response<List<TaskDto>>
|
||||||
|
|
||||||
|
@GET("api/v1/tasks/{task_id}")
|
||||||
|
suspend fun getTaskById(
|
||||||
|
@Path("task_id") taskId: String
|
||||||
|
): Response<TaskDto>
|
||||||
|
|
||||||
|
@POST("api/v1/tasks")
|
||||||
|
suspend fun createTask(
|
||||||
|
@Body request: CreateTaskRequest
|
||||||
|
): Response<TaskDto>
|
||||||
|
|
||||||
|
@DELETE("api/v1/tasks/{task_id}")
|
||||||
|
suspend fun deleteTask(
|
||||||
|
@Path("task_id") taskId: String
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
@PATCH("api/v1/tasks/{task_id}/complete")
|
||||||
|
suspend fun completeTask(
|
||||||
|
@Path("task_id") taskId: String,
|
||||||
|
@Query("completed") completed: Boolean
|
||||||
|
): Response<CompleteTaskResponse>
|
||||||
|
|
||||||
|
// Rewards endpoints
|
||||||
|
@GET("api/v1/rewards")
|
||||||
|
suspend fun getRewards(
|
||||||
|
@Query("skip") skip: Int = 0,
|
||||||
|
@Query("limit") limit: Int = 100,
|
||||||
|
@Query("is_claimed") isClaimed: Boolean? = null
|
||||||
|
): Response<List<RewardDto>>
|
||||||
|
|
||||||
|
@GET("api/v1/rewards/{reward_id}")
|
||||||
|
suspend fun getRewardById(
|
||||||
|
@Path("reward_id") rewardId: String
|
||||||
|
): Response<RewardDto>
|
||||||
|
|
||||||
|
@POST("api/v1/rewards")
|
||||||
|
suspend fun createReward(
|
||||||
|
@Body request: CreateRewardRequest
|
||||||
|
): Response<RewardDto>
|
||||||
|
|
||||||
|
@DELETE("api/v1/rewards/{reward_id}")
|
||||||
|
suspend fun deleteReward(
|
||||||
|
@Path("reward_id") rewardId: String
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/v1/rewards/{reward_id}/claim")
|
||||||
|
suspend fun claimReward(
|
||||||
|
@Path("reward_id") rewardId: String
|
||||||
|
): Response<ClaimRewardResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ChatRequest(
|
data class ChatRequest(
|
||||||
val message: String
|
val message: String,
|
||||||
|
val conversation_id: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ChatResponse(
|
data class ChatResponse(
|
||||||
val message: String
|
val response: String,
|
||||||
|
val conversation_id: String,
|
||||||
|
val tokens_used: Int? = null,
|
||||||
|
val model: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ClaimRewardResponse(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val description: String?,
|
||||||
|
val image_url: String?,
|
||||||
|
val points_required: Int,
|
||||||
|
val user_id: String,
|
||||||
|
val is_claimed: Boolean,
|
||||||
|
val created_at: String,
|
||||||
|
val updated_at: String
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CompleteTaskResponse(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val description: String?,
|
||||||
|
val image_url: String?,
|
||||||
|
val duration_minutes: Int?,
|
||||||
|
val order: Int,
|
||||||
|
val category: String?,
|
||||||
|
val schedule_id: String,
|
||||||
|
val completed: Boolean,
|
||||||
|
val created_at: String,
|
||||||
|
val updated_at: String
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CreateRewardRequest(
|
||||||
|
val title: String,
|
||||||
|
val description: String? = null,
|
||||||
|
val image_url: String? = null,
|
||||||
|
val points_required: Int
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CreateScheduleRequest(
|
||||||
|
val title: String,
|
||||||
|
val date: String, // yyyy-MM-dd
|
||||||
|
val description: String,
|
||||||
|
val reward_id: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CreateTaskRequest(
|
||||||
|
val title: String,
|
||||||
|
val description: String? = null,
|
||||||
|
val image_url: String? = null,
|
||||||
|
val duration_minutes: Int? = null,
|
||||||
|
val order: Int = 0,
|
||||||
|
val category: String? = null,
|
||||||
|
val schedule_id: String
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GenerateScheduleRequest(
|
||||||
|
val child_age: Int,
|
||||||
|
val preferences: List<String>,
|
||||||
|
val date: String,
|
||||||
|
val description: String
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GenerateScheduleResponse(
|
||||||
|
val schedule_id: String,
|
||||||
|
val title: String,
|
||||||
|
val tasks: List<JsonObject>,
|
||||||
|
val tokens_used: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MeResponse(
|
||||||
|
val email: String,
|
||||||
|
val full_name: String,
|
||||||
|
val role: String,
|
||||||
|
val id: String,
|
||||||
|
val created_at: String,
|
||||||
|
val updated_at: String
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RefreshRequest(
|
||||||
|
val refresh_token: String
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RefreshResponse(
|
||||||
|
val access_token: String,
|
||||||
|
val token_type: String,
|
||||||
|
val refresh_token: String
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.EncodeDefault
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RegisterRequest(
|
||||||
|
val email: String,
|
||||||
|
val full_name: String,
|
||||||
|
@EncodeDefault
|
||||||
|
val role: String = "CHILD",
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RegisterResponse(
|
||||||
|
val email: String,
|
||||||
|
val full_name: String,
|
||||||
|
val role: String,
|
||||||
|
val id: String,
|
||||||
|
val created_at: String,
|
||||||
|
val updated_at: String
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RewardDto(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val description: String?,
|
||||||
|
val image_url: String?,
|
||||||
|
val points_required: Int,
|
||||||
|
val user_id: String,
|
||||||
|
val is_claimed: Boolean,
|
||||||
|
val created_at: String,
|
||||||
|
val updated_at: String
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ScheduleDto(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val date: String, // yyyy-MM-dd
|
||||||
|
val description: String,
|
||||||
|
val user_id: String,
|
||||||
|
val created_at: String, // ISO date
|
||||||
|
val updated_at: String, // ISO date
|
||||||
|
val tasks: List<TaskDto> = emptyList(),
|
||||||
|
val reward_id: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskDto moved to separate file
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TaskDto(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val description: String?,
|
||||||
|
val image_url: String?,
|
||||||
|
val duration_minutes: Int?,
|
||||||
|
val order: Int,
|
||||||
|
val category: String?,
|
||||||
|
val schedule_id: String,
|
||||||
|
val completed: Boolean,
|
||||||
|
val created_at: String,
|
||||||
|
val updated_at: String
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TokenResponse(
|
||||||
|
val access_token: String,
|
||||||
|
val token_type: String,
|
||||||
|
val refresh_token: String
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.novayaplaneta.data.remote.mapper
|
||||||
|
|
||||||
|
import com.novayaplaneta.data.remote.dto.ClaimRewardResponse
|
||||||
|
import com.novayaplaneta.data.remote.dto.CreateRewardRequest
|
||||||
|
import com.novayaplaneta.data.remote.dto.RewardDto
|
||||||
|
import com.novayaplaneta.domain.model.Reward
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
fun RewardDto.toDomain(): Reward {
|
||||||
|
val dateFormatter = DateTimeFormatter.ISO_DATE_TIME
|
||||||
|
|
||||||
|
val createdAtDateTime = try {
|
||||||
|
LocalDateTime.parse(created_at, dateFormatter)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val updatedAtDateTime = try {
|
||||||
|
LocalDateTime.parse(updated_at, dateFormatter)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val earnedAtDateTime = if (is_claimed) updatedAtDateTime else null
|
||||||
|
|
||||||
|
return Reward(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
imageUrl = image_url,
|
||||||
|
pointsRequired = points_required,
|
||||||
|
isClaimed = is_claimed,
|
||||||
|
earnedAt = earnedAtDateTime,
|
||||||
|
userId = user_id,
|
||||||
|
createdAt = createdAtDateTime,
|
||||||
|
updatedAt = updatedAtDateTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ClaimRewardResponse.toDomain(): Reward {
|
||||||
|
val dateFormatter = DateTimeFormatter.ISO_DATE_TIME
|
||||||
|
|
||||||
|
val createdAtDateTime = try {
|
||||||
|
LocalDateTime.parse(created_at, dateFormatter)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val updatedAtDateTime = try {
|
||||||
|
LocalDateTime.parse(updated_at, dateFormatter)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val earnedAtDateTime = if (is_claimed) updatedAtDateTime else null
|
||||||
|
|
||||||
|
return Reward(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
imageUrl = image_url,
|
||||||
|
pointsRequired = points_required,
|
||||||
|
isClaimed = is_claimed,
|
||||||
|
earnedAt = earnedAtDateTime,
|
||||||
|
userId = user_id,
|
||||||
|
createdAt = createdAtDateTime,
|
||||||
|
updatedAt = updatedAtDateTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Reward.toCreateRequest(): CreateRewardRequest {
|
||||||
|
return CreateRewardRequest(
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
image_url = imageUrl,
|
||||||
|
points_required = pointsRequired
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.novayaplaneta.data.remote.mapper
|
||||||
|
|
||||||
|
import com.novayaplaneta.data.remote.dto.ScheduleDto
|
||||||
|
import com.novayaplaneta.data.remote.dto.TaskDto
|
||||||
|
import com.novayaplaneta.domain.model.Schedule
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
fun ScheduleDto.toDomain(): Schedule {
|
||||||
|
val dateFormatter = DateTimeFormatter.ISO_DATE_TIME
|
||||||
|
val dateLocalDate = LocalDate.parse(date, DateTimeFormatter.ISO_DATE)
|
||||||
|
val dateLocalDateTime = dateLocalDate.atStartOfDay()
|
||||||
|
|
||||||
|
val createdAtDateTime = try {
|
||||||
|
LocalDateTime.parse(created_at, dateFormatter)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LocalDateTime.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
val updatedAtDateTime = try {
|
||||||
|
LocalDateTime.parse(updated_at, dateFormatter)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LocalDateTime.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Schedule(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
tasks = tasks.map { it.toDomain() },
|
||||||
|
date = dateLocalDateTime,
|
||||||
|
createdAt = createdAtDateTime,
|
||||||
|
userId = user_id,
|
||||||
|
rewardId = reward_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskDto.toDomain() moved to TaskMapper.kt
|
||||||
|
|
||||||
|
fun Schedule.toCreateRequest(): com.novayaplaneta.data.remote.dto.CreateScheduleRequest {
|
||||||
|
val dateFormatter = DateTimeFormatter.ISO_DATE
|
||||||
|
val dateString = date.toLocalDate().format(dateFormatter)
|
||||||
|
|
||||||
|
return com.novayaplaneta.data.remote.dto.CreateScheduleRequest(
|
||||||
|
title = title,
|
||||||
|
date = dateString,
|
||||||
|
description = description ?: "",
|
||||||
|
reward_id = rewardId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.novayaplaneta.data.remote.mapper
|
||||||
|
|
||||||
|
import com.novayaplaneta.data.remote.dto.CompleteTaskResponse
|
||||||
|
import com.novayaplaneta.data.remote.dto.CreateTaskRequest
|
||||||
|
import com.novayaplaneta.data.remote.dto.TaskDto
|
||||||
|
import com.novayaplaneta.domain.model.Task
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
fun TaskDto.toDomain(): Task {
|
||||||
|
val dateFormatter = DateTimeFormatter.ISO_DATE_TIME
|
||||||
|
|
||||||
|
val createdAtDateTime = try {
|
||||||
|
LocalDateTime.parse(created_at, dateFormatter)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val updatedAtDateTime = try {
|
||||||
|
LocalDateTime.parse(updated_at, dateFormatter)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
imageUrl = image_url,
|
||||||
|
completed = completed,
|
||||||
|
scheduledTime = null, // Not provided by API
|
||||||
|
duration = duration_minutes,
|
||||||
|
scheduleId = schedule_id,
|
||||||
|
order = order,
|
||||||
|
category = category,
|
||||||
|
createdAt = createdAtDateTime,
|
||||||
|
updatedAt = updatedAtDateTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun CompleteTaskResponse.toDomain(): Task {
|
||||||
|
val dateFormatter = DateTimeFormatter.ISO_DATE_TIME
|
||||||
|
|
||||||
|
val createdAtDateTime = try {
|
||||||
|
LocalDateTime.parse(created_at, dateFormatter)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val updatedAtDateTime = try {
|
||||||
|
LocalDateTime.parse(updated_at, dateFormatter)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
imageUrl = image_url,
|
||||||
|
completed = completed,
|
||||||
|
scheduledTime = null,
|
||||||
|
duration = duration_minutes,
|
||||||
|
scheduleId = schedule_id,
|
||||||
|
order = order,
|
||||||
|
category = category,
|
||||||
|
createdAt = createdAtDateTime,
|
||||||
|
updatedAt = updatedAtDateTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Task.toCreateRequest(): CreateTaskRequest {
|
||||||
|
return CreateTaskRequest(
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
image_url = imageUrl,
|
||||||
|
duration_minutes = duration,
|
||||||
|
order = order,
|
||||||
|
category = category,
|
||||||
|
schedule_id = scheduleId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,29 +3,29 @@ package com.novayaplaneta.data.repository
|
|||||||
import com.novayaplaneta.data.local.dao.ChatMessageDao
|
import com.novayaplaneta.data.local.dao.ChatMessageDao
|
||||||
import com.novayaplaneta.data.local.mapper.toDomain
|
import com.novayaplaneta.data.local.mapper.toDomain
|
||||||
import com.novayaplaneta.data.local.mapper.toEntity
|
import com.novayaplaneta.data.local.mapper.toEntity
|
||||||
import com.novayaplaneta.data.remote.BackendApi
|
import com.novayaplaneta.data.remote.AiApi
|
||||||
import com.novayaplaneta.data.remote.dto.ChatRequest
|
import com.novayaplaneta.data.remote.dto.ChatRequest
|
||||||
|
import com.novayaplaneta.data.remote.dto.GenerateScheduleRequest
|
||||||
import com.novayaplaneta.domain.model.ChatMessage
|
import com.novayaplaneta.domain.model.ChatMessage
|
||||||
import com.novayaplaneta.domain.repository.AIRepository
|
import com.novayaplaneta.domain.repository.AIRepository
|
||||||
|
import com.novayaplaneta.domain.repository.ChatResponse
|
||||||
|
import com.novayaplaneta.domain.repository.GenerateScheduleResult
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class AIRepositoryImpl @Inject constructor(
|
class AIRepositoryImpl @Inject constructor(
|
||||||
private val chatMessageDao: ChatMessageDao,
|
private val chatMessageDao: ChatMessageDao,
|
||||||
private val api: BackendApi,
|
private val aiApi: AiApi
|
||||||
private val authRepository: com.novayaplaneta.domain.repository.AuthRepository
|
|
||||||
) : AIRepository {
|
) : AIRepository {
|
||||||
|
|
||||||
override suspend fun sendMessage(userId: String, message: String): Result<String> {
|
override suspend fun sendMessage(userId: String, message: String, conversationId: String?): Result<ChatResponse> {
|
||||||
return try {
|
return try {
|
||||||
// Get token from current user
|
|
||||||
val user = authRepository.getCurrentUser().first()
|
|
||||||
val token = user?.token ?: ""
|
|
||||||
|
|
||||||
// Save user message
|
// Save user message
|
||||||
val userMessage = ChatMessage(
|
val userMessage = ChatMessage(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
@@ -36,28 +36,32 @@ class AIRepositoryImpl @Inject constructor(
|
|||||||
)
|
)
|
||||||
chatMessageDao.insertMessage(userMessage.toEntity())
|
chatMessageDao.insertMessage(userMessage.toEntity())
|
||||||
|
|
||||||
// Send to API (only if token exists)
|
// Send request to API
|
||||||
if (token.isNotEmpty()) {
|
val request = ChatRequest(message = message, conversation_id = conversationId)
|
||||||
val response = api.chatWithAI("Bearer $token", ChatRequest(message))
|
val response = aiApi.chat(request)
|
||||||
if (response.isSuccessful && response.body() != null) {
|
|
||||||
val aiResponse = response.body()!!.message
|
|
||||||
|
|
||||||
// Save AI response
|
if (response.isSuccessful && response.body() != null) {
|
||||||
val aiMessage = ChatMessage(
|
val chatResponse = response.body()!!
|
||||||
id = UUID.randomUUID().toString(),
|
|
||||||
message = aiResponse,
|
// Save AI response
|
||||||
isFromAI = true,
|
val aiMessage = ChatMessage(
|
||||||
timestamp = LocalDateTime.now(),
|
id = UUID.randomUUID().toString(),
|
||||||
userId = userId
|
message = chatResponse.response,
|
||||||
|
isFromAI = true,
|
||||||
|
timestamp = LocalDateTime.now(),
|
||||||
|
userId = userId
|
||||||
|
)
|
||||||
|
chatMessageDao.insertMessage(aiMessage.toEntity())
|
||||||
|
|
||||||
|
Result.success(
|
||||||
|
ChatResponse(
|
||||||
|
response = chatResponse.response,
|
||||||
|
conversationId = chatResponse.conversation_id
|
||||||
)
|
)
|
||||||
chatMessageDao.insertMessage(aiMessage.toEntity())
|
)
|
||||||
|
|
||||||
Result.success(aiResponse)
|
|
||||||
} else {
|
|
||||||
Result.failure(Exception("Failed to get AI response"))
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Result.failure(Exception("User not authenticated"))
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Failed to send message: $errorBody (code: ${response.code()})"))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
@@ -70,9 +74,79 @@ class AIRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun generateSchedule(userId: String, preferences: String): Result<String> {
|
override suspend fun generateSchedule(
|
||||||
// TODO: Implement schedule generation
|
childAge: Int,
|
||||||
return Result.failure(Exception("Not implemented"))
|
preferences: List<String>,
|
||||||
|
date: String,
|
||||||
|
description: String
|
||||||
|
): Result<GenerateScheduleResult> {
|
||||||
|
return try {
|
||||||
|
val request = GenerateScheduleRequest(
|
||||||
|
child_age = childAge,
|
||||||
|
preferences = preferences,
|
||||||
|
date = date,
|
||||||
|
description = description
|
||||||
|
)
|
||||||
|
val response = aiApi.generateSchedule(request)
|
||||||
|
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val generateResponse = response.body()!!
|
||||||
|
// Конвертируем JsonObject в Map<String, Any>
|
||||||
|
val tasksAsMap = generateResponse.tasks.map { jsonObject ->
|
||||||
|
jsonObject.toMap()
|
||||||
|
}
|
||||||
|
Result.success(
|
||||||
|
GenerateScheduleResult(
|
||||||
|
scheduleId = generateResponse.schedule_id,
|
||||||
|
title = generateResponse.title,
|
||||||
|
tasks = tasksAsMap
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Failed to generate schedule: $errorBody (code: ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.toMap(): Map<String, Any> {
|
||||||
|
return entries.associate { (key, value) ->
|
||||||
|
key to when (value) {
|
||||||
|
is JsonPrimitive -> {
|
||||||
|
when {
|
||||||
|
value.isString -> value.content
|
||||||
|
else -> {
|
||||||
|
// Пытаемся парсить как число или boolean
|
||||||
|
val content = value.content
|
||||||
|
when {
|
||||||
|
content == "true" || content == "false" -> content.toBoolean()
|
||||||
|
content.toLongOrNull() != null -> content.toLong()
|
||||||
|
content.toDoubleOrNull() != null -> content.toDouble()
|
||||||
|
else -> content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is JsonObject -> value.toMap()
|
||||||
|
is JsonArray -> value.map {
|
||||||
|
if (it is JsonObject) it.toMap()
|
||||||
|
else if (it is JsonPrimitive) {
|
||||||
|
val content = it.content
|
||||||
|
when {
|
||||||
|
content == "true" || content == "false" -> content.toBoolean()
|
||||||
|
content.toLongOrNull() != null -> content.toLong()
|
||||||
|
content.toDoubleOrNull() != null -> content.toDouble()
|
||||||
|
else -> content
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
it.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> value.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.novayaplaneta.data.repository
|
package com.novayaplaneta.data.repository
|
||||||
|
|
||||||
|
import com.novayaplaneta.data.local.TokenManager
|
||||||
import com.novayaplaneta.data.local.dao.UserDao
|
import com.novayaplaneta.data.local.dao.UserDao
|
||||||
import com.novayaplaneta.data.local.mapper.toDomain
|
import com.novayaplaneta.data.local.mapper.toDomain
|
||||||
import com.novayaplaneta.data.local.mapper.toEntity
|
import com.novayaplaneta.data.local.mapper.toEntity
|
||||||
import com.novayaplaneta.data.remote.BackendApi
|
import com.novayaplaneta.data.remote.AuthApi
|
||||||
import com.novayaplaneta.data.remote.dto.LoginRequest
|
import com.novayaplaneta.data.remote.dto.RefreshRequest
|
||||||
|
import com.novayaplaneta.data.remote.dto.RegisterRequest
|
||||||
import com.novayaplaneta.domain.model.User
|
import com.novayaplaneta.domain.model.User
|
||||||
import com.novayaplaneta.domain.model.UserRole
|
import com.novayaplaneta.domain.model.UserRole
|
||||||
import com.novayaplaneta.domain.repository.AuthRepository
|
import com.novayaplaneta.domain.repository.AuthRepository
|
||||||
@@ -14,25 +16,123 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
class AuthRepositoryImpl @Inject constructor(
|
class AuthRepositoryImpl @Inject constructor(
|
||||||
private val userDao: UserDao,
|
private val userDao: UserDao,
|
||||||
private val api: BackendApi
|
private val authApi: AuthApi,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
) : AuthRepository {
|
) : AuthRepository {
|
||||||
|
|
||||||
|
override suspend fun register(email: String, fullName: String, password: String, role: String): Result<User> {
|
||||||
|
return try {
|
||||||
|
val response = authApi.register(RegisterRequest(email, fullName, role, password))
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val registerResponse = response.body()!!
|
||||||
|
val user = User(
|
||||||
|
id = registerResponse.id,
|
||||||
|
fullName = registerResponse.full_name,
|
||||||
|
email = registerResponse.email,
|
||||||
|
role = UserRole.valueOf(registerResponse.role),
|
||||||
|
createdAt = registerResponse.created_at,
|
||||||
|
updatedAt = registerResponse.updated_at
|
||||||
|
)
|
||||||
|
// После регистрации нужно залогиниться, чтобы получить токены
|
||||||
|
val loginResult = login(email, password)
|
||||||
|
if (loginResult.isSuccess) {
|
||||||
|
saveUser(user)
|
||||||
|
Result.success(user)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("Registration successful but login failed"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Registration failed"
|
||||||
|
Result.failure(Exception(errorBody))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun login(email: String, password: String): Result<User> {
|
override suspend fun login(email: String, password: String): Result<User> {
|
||||||
return try {
|
return try {
|
||||||
val response = api.login(LoginRequest(email, password))
|
val response = authApi.login(username = email, password = password)
|
||||||
if (response.isSuccessful && response.body() != null) {
|
if (response.isSuccessful && response.body() != null) {
|
||||||
val loginResponse = response.body()!!
|
val tokenResponse = response.body()!!
|
||||||
|
// Сохраняем токены
|
||||||
|
tokenManager.saveTokens(tokenResponse.access_token, tokenResponse.refresh_token)
|
||||||
|
|
||||||
|
// Убеждаемся, что токен действительно сохранен перед вызовом getMe()
|
||||||
|
// Это гарантирует, что DataStore завершил сохранение
|
||||||
|
val savedToken = tokenManager.getAccessToken()
|
||||||
|
if (savedToken == null) {
|
||||||
|
return Result.failure(Exception("Failed to save access token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем информацию о пользователе
|
||||||
|
val meResponse = authApi.getMe()
|
||||||
|
if (meResponse.isSuccessful && meResponse.body() != null) {
|
||||||
|
val me = meResponse.body()!!
|
||||||
|
val user = User(
|
||||||
|
id = me.id,
|
||||||
|
fullName = me.full_name,
|
||||||
|
email = me.email,
|
||||||
|
role = UserRole.valueOf(me.role),
|
||||||
|
createdAt = me.created_at,
|
||||||
|
updatedAt = me.updated_at
|
||||||
|
)
|
||||||
|
saveUser(user)
|
||||||
|
Result.success(user)
|
||||||
|
} else {
|
||||||
|
val errorCode = meResponse.code()
|
||||||
|
val errorBody = meResponse.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Failed to get user info: HTTP $errorCode - $errorBody"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorCode = response.code()
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Login failed: HTTP $errorCode - $errorBody"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun refresh(): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
val refreshToken = tokenManager.getRefreshToken()
|
||||||
|
if (refreshToken == null) {
|
||||||
|
return Result.failure(Exception("No refresh token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = authApi.refresh(RefreshRequest(refreshToken))
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val tokenResponse = response.body()!!
|
||||||
|
tokenManager.saveTokens(tokenResponse.access_token, tokenResponse.refresh_token)
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
tokenManager.clearTokens()
|
||||||
|
Result.failure(Exception("Refresh failed"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
tokenManager.clearTokens()
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMe(): Result<User> {
|
||||||
|
return try {
|
||||||
|
val response = authApi.getMe()
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val me = response.body()!!
|
||||||
val user = User(
|
val user = User(
|
||||||
id = loginResponse.user.id,
|
id = me.id,
|
||||||
name = loginResponse.user.name,
|
fullName = me.full_name,
|
||||||
email = loginResponse.user.email,
|
email = me.email,
|
||||||
role = UserRole.valueOf(loginResponse.user.role),
|
role = UserRole.valueOf(me.role),
|
||||||
token = loginResponse.token
|
createdAt = me.created_at,
|
||||||
|
updatedAt = me.updated_at
|
||||||
)
|
)
|
||||||
saveUser(user)
|
saveUser(user)
|
||||||
Result.success(user)
|
Result.success(user)
|
||||||
} else {
|
} else {
|
||||||
Result.failure(Exception("Login failed"))
|
Result.failure(Exception("Failed to get user info"))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
@@ -41,6 +141,7 @@ class AuthRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun logout() {
|
override suspend fun logout() {
|
||||||
userDao.deleteAllUsers()
|
userDao.deleteAllUsers()
|
||||||
|
tokenManager.clearTokens()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentUser(): Flow<User?> {
|
override fun getCurrentUser(): Flow<User?> {
|
||||||
|
|||||||
@@ -3,43 +3,165 @@ package com.novayaplaneta.data.repository
|
|||||||
import com.novayaplaneta.data.local.dao.RewardDao
|
import com.novayaplaneta.data.local.dao.RewardDao
|
||||||
import com.novayaplaneta.data.local.mapper.toDomain
|
import com.novayaplaneta.data.local.mapper.toDomain
|
||||||
import com.novayaplaneta.data.local.mapper.toEntity
|
import com.novayaplaneta.data.local.mapper.toEntity
|
||||||
|
import com.novayaplaneta.data.remote.BackendApi
|
||||||
|
import com.novayaplaneta.data.remote.dto.CreateRewardRequest
|
||||||
|
import com.novayaplaneta.data.remote.mapper.toCreateRequest
|
||||||
|
import com.novayaplaneta.data.remote.mapper.toDomain
|
||||||
import com.novayaplaneta.domain.model.Reward
|
import com.novayaplaneta.domain.model.Reward
|
||||||
import com.novayaplaneta.domain.repository.RewardRepository
|
import com.novayaplaneta.domain.repository.RewardRepository
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import java.time.LocalDateTime
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class RewardRepositoryImpl @Inject constructor(
|
class RewardRepositoryImpl @Inject constructor(
|
||||||
private val rewardDao: RewardDao
|
private val rewardDao: RewardDao,
|
||||||
|
private val backendApi: BackendApi
|
||||||
) : RewardRepository {
|
) : RewardRepository {
|
||||||
|
|
||||||
override fun getRewards(userId: String): Flow<List<Reward>> {
|
// Кэш наград
|
||||||
return rewardDao.getRewardsByUserId(userId).map { rewards ->
|
private val _rewardsCache = MutableStateFlow<List<Reward>>(emptyList())
|
||||||
rewards.map { it.toDomain() }
|
|
||||||
|
override suspend fun loadRewards(skip: Int, limit: Int, isClaimed: Boolean?): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
val response = backendApi.getRewards(skip = skip, limit = limit, isClaimed = isClaimed)
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val rewards = response.body()!!.map { it.toDomain() }
|
||||||
|
|
||||||
|
// Обновляем кэш
|
||||||
|
if (skip == 0) {
|
||||||
|
_rewardsCache.value = rewards
|
||||||
|
} else {
|
||||||
|
val currentRewards = _rewardsCache.value.toMutableList()
|
||||||
|
currentRewards.addAll(rewards)
|
||||||
|
_rewardsCache.value = currentRewards
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем в локальную БД
|
||||||
|
rewards.forEach { reward ->
|
||||||
|
rewardDao.insertReward(reward.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Failed to load rewards: $errorBody (code: ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getRewardById(id: String): Reward? {
|
override fun getRewards(userId: String): Flow<List<Reward>> {
|
||||||
return rewardDao.getRewardById(id)?.toDomain()
|
return _rewardsCache.asStateFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createReward(reward: Reward) {
|
override suspend fun getRewardById(id: String): Result<Reward> {
|
||||||
rewardDao.insertReward(reward.toEntity())
|
return try {
|
||||||
|
val response = backendApi.getRewardById(id)
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val reward = response.body()!!.toDomain()
|
||||||
|
|
||||||
|
// Обновляем в кэше
|
||||||
|
val currentRewards = _rewardsCache.value.toMutableList()
|
||||||
|
val index = currentRewards.indexOfFirst { it.id == id }
|
||||||
|
if (index >= 0) {
|
||||||
|
currentRewards[index] = reward
|
||||||
|
} else {
|
||||||
|
currentRewards.add(reward)
|
||||||
|
}
|
||||||
|
_rewardsCache.value = currentRewards
|
||||||
|
|
||||||
|
// Сохраняем в локальную БД
|
||||||
|
rewardDao.insertReward(reward.toEntity())
|
||||||
|
|
||||||
|
Result.success(reward)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Failed to get reward: $errorBody (code: ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createReward(reward: Reward): Result<Reward> {
|
||||||
|
return try {
|
||||||
|
val request = reward.toCreateRequest()
|
||||||
|
val response = backendApi.createReward(request)
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val createdReward = response.body()!!.toDomain()
|
||||||
|
|
||||||
|
// Обновляем кэш
|
||||||
|
val currentRewards = _rewardsCache.value.toMutableList()
|
||||||
|
currentRewards.add(createdReward)
|
||||||
|
_rewardsCache.value = currentRewards
|
||||||
|
|
||||||
|
// Сохраняем в локальную БД
|
||||||
|
rewardDao.insertReward(createdReward.toEntity())
|
||||||
|
|
||||||
|
Result.success(createdReward)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Failed to create reward: $errorBody (code: ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateReward(reward: Reward) {
|
override suspend fun updateReward(reward: Reward) {
|
||||||
rewardDao.updateReward(reward.toEntity())
|
rewardDao.updateReward(reward.toEntity())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteReward(id: String) {
|
override suspend fun deleteReward(id: String): Result<Unit> {
|
||||||
rewardDao.deleteReward(id)
|
return try {
|
||||||
|
val response = backendApi.deleteReward(id)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
// Удаляем из кэша
|
||||||
|
val currentRewards = _rewardsCache.value.toMutableList()
|
||||||
|
currentRewards.removeAll { it.id == id }
|
||||||
|
_rewardsCache.value = currentRewards
|
||||||
|
|
||||||
|
// Удаляем из локальной БД
|
||||||
|
rewardDao.deleteReward(id)
|
||||||
|
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Failed to delete reward: $errorBody (code: ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun earnReward(userId: String, rewardId: String) {
|
override suspend fun claimReward(rewardId: String): Result<Reward> {
|
||||||
val reward = getRewardById(rewardId)
|
return try {
|
||||||
reward?.let {
|
val response = backendApi.claimReward(rewardId)
|
||||||
updateReward(it.copy(earnedAt = LocalDateTime.now()))
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val claimedReward = response.body()!!.toDomain()
|
||||||
|
|
||||||
|
// Обновляем в кэше
|
||||||
|
val currentRewards = _rewardsCache.value.toMutableList()
|
||||||
|
val index = currentRewards.indexOfFirst { it.id == rewardId }
|
||||||
|
if (index >= 0) {
|
||||||
|
currentRewards[index] = claimedReward
|
||||||
|
_rewardsCache.value = currentRewards
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем в локальную БД
|
||||||
|
rewardDao.insertReward(claimedReward.toEntity())
|
||||||
|
|
||||||
|
Result.success(claimedReward)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Failed to claim reward: $errorBody (code: ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,47 +4,126 @@ import com.novayaplaneta.data.local.dao.ScheduleDao
|
|||||||
import com.novayaplaneta.data.local.dao.TaskDao
|
import com.novayaplaneta.data.local.dao.TaskDao
|
||||||
import com.novayaplaneta.data.local.mapper.toDomain
|
import com.novayaplaneta.data.local.mapper.toDomain
|
||||||
import com.novayaplaneta.data.local.mapper.toEntity
|
import com.novayaplaneta.data.local.mapper.toEntity
|
||||||
|
import com.novayaplaneta.data.remote.BackendApi
|
||||||
|
import com.novayaplaneta.data.remote.mapper.toCreateRequest
|
||||||
|
import com.novayaplaneta.data.remote.mapper.toDomain
|
||||||
import com.novayaplaneta.domain.model.Schedule
|
import com.novayaplaneta.domain.model.Schedule
|
||||||
import com.novayaplaneta.domain.repository.ScheduleRepository
|
import com.novayaplaneta.domain.repository.ScheduleRepository
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class ScheduleRepositoryImpl @Inject constructor(
|
class ScheduleRepositoryImpl @Inject constructor(
|
||||||
private val scheduleDao: ScheduleDao,
|
private val scheduleDao: ScheduleDao,
|
||||||
private val taskDao: TaskDao
|
private val taskDao: TaskDao,
|
||||||
|
private val backendApi: BackendApi
|
||||||
) : ScheduleRepository {
|
) : ScheduleRepository {
|
||||||
|
|
||||||
|
private val _schedulesCache = MutableStateFlow<List<Schedule>>(emptyList())
|
||||||
|
val schedulesCache: StateFlow<List<Schedule>> = _schedulesCache.asStateFlow()
|
||||||
|
|
||||||
override fun getSchedules(userId: String): Flow<List<Schedule>> {
|
override fun getSchedules(userId: String): Flow<List<Schedule>> {
|
||||||
return scheduleDao.getSchedulesByUserId(userId).map { schedules ->
|
return _schedulesCache.asStateFlow()
|
||||||
schedules.map { scheduleEntity ->
|
|
||||||
// Note: In production, you'd need to fetch tasks for each schedule
|
|
||||||
scheduleEntity.toDomain(emptyList())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getScheduleById(id: String): Schedule? {
|
override suspend fun getScheduleById(id: String): Schedule? {
|
||||||
val scheduleEntity = scheduleDao.getScheduleById(id) ?: return null
|
return try {
|
||||||
val tasks = taskDao.getTasksByScheduleId(id)
|
val response = backendApi.getScheduleById(id)
|
||||||
// Simplified - would need proper Flow handling
|
if (response.isSuccessful && response.body() != null) {
|
||||||
return scheduleEntity.toDomain(emptyList())
|
val schedule = response.body()!!.toDomain()
|
||||||
|
// Обновляем кэш, если расписание уже есть, или добавляем новое
|
||||||
|
val currentSchedules = _schedulesCache.value.toMutableList()
|
||||||
|
val index = currentSchedules.indexOfFirst { it.id == id }
|
||||||
|
if (index >= 0) {
|
||||||
|
currentSchedules[index] = schedule
|
||||||
|
} else {
|
||||||
|
currentSchedules.add(schedule)
|
||||||
|
}
|
||||||
|
_schedulesCache.value = currentSchedules
|
||||||
|
schedule
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createSchedule(schedule: Schedule) {
|
override suspend fun createSchedule(schedule: Schedule) {
|
||||||
scheduleDao.insertSchedule(schedule.toEntity())
|
try {
|
||||||
|
val request = schedule.toCreateRequest()
|
||||||
|
val response = backendApi.createSchedule(request)
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val createdSchedule = response.body()!!.toDomain()
|
||||||
|
// Обновляем кэш
|
||||||
|
val currentSchedules = _schedulesCache.value.toMutableList()
|
||||||
|
currentSchedules.add(createdSchedule)
|
||||||
|
_schedulesCache.value = currentSchedules
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
throw Exception("Failed to create schedule: $errorBody (code: ${response.code()})")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateSchedule(schedule: Schedule) {
|
override suspend fun updateSchedule(schedule: Schedule) {
|
||||||
|
// TODO: Implement if API supports update
|
||||||
scheduleDao.updateSchedule(schedule.toEntity())
|
scheduleDao.updateSchedule(schedule.toEntity())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteSchedule(id: String) {
|
override suspend fun deleteSchedule(id: String) {
|
||||||
scheduleDao.deleteSchedule(id)
|
try {
|
||||||
|
val response = backendApi.deleteSchedule(id)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
// Удаляем из кэша
|
||||||
|
val currentSchedules = _schedulesCache.value.toMutableList()
|
||||||
|
currentSchedules.removeAll { it.id == id }
|
||||||
|
_schedulesCache.value = currentSchedules
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
throw Exception("Failed to delete schedule: $errorBody (code: ${response.code()})")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun syncSchedules(userId: String) {
|
override suspend fun syncSchedules(userId: String) {
|
||||||
// TODO: Implement sync with backend
|
try {
|
||||||
|
val response = backendApi.getSchedules(skip = 0, limit = 100)
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val schedules = response.body()!!.map { it.toDomain() }
|
||||||
|
_schedulesCache.value = schedules
|
||||||
|
} else {
|
||||||
|
throw Exception("Failed to sync schedules: ${response.errorBody()?.string()}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loadSchedules(scheduleDate: String?) {
|
||||||
|
try {
|
||||||
|
val response = backendApi.getSchedules(
|
||||||
|
skip = 0,
|
||||||
|
limit = 100,
|
||||||
|
scheduleDate = scheduleDate
|
||||||
|
)
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val schedules = response.body()!!.map { it.toDomain() }
|
||||||
|
_schedulesCache.value = schedules
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
throw Exception("Failed to load schedules: $errorBody (code: ${response.code()})")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,40 +3,182 @@ package com.novayaplaneta.data.repository
|
|||||||
import com.novayaplaneta.data.local.dao.TaskDao
|
import com.novayaplaneta.data.local.dao.TaskDao
|
||||||
import com.novayaplaneta.data.local.mapper.toDomain
|
import com.novayaplaneta.data.local.mapper.toDomain
|
||||||
import com.novayaplaneta.data.local.mapper.toEntity
|
import com.novayaplaneta.data.local.mapper.toEntity
|
||||||
|
import com.novayaplaneta.data.remote.BackendApi
|
||||||
|
import com.novayaplaneta.data.remote.dto.CreateTaskRequest
|
||||||
|
import com.novayaplaneta.data.remote.mapper.toCreateRequest
|
||||||
|
import com.novayaplaneta.data.remote.mapper.toDomain
|
||||||
import com.novayaplaneta.domain.model.Task
|
import com.novayaplaneta.domain.model.Task
|
||||||
import com.novayaplaneta.domain.repository.TaskRepository
|
import com.novayaplaneta.domain.repository.TaskRepository
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class TaskRepositoryImpl @Inject constructor(
|
class TaskRepositoryImpl @Inject constructor(
|
||||||
private val taskDao: TaskDao
|
private val taskDao: TaskDao,
|
||||||
|
private val backendApi: BackendApi
|
||||||
) : TaskRepository {
|
) : TaskRepository {
|
||||||
|
|
||||||
override fun getTasks(scheduleId: String): Flow<List<Task>> {
|
// Кэш задач по scheduleId
|
||||||
return taskDao.getTasksByScheduleId(scheduleId).map { tasks ->
|
private val _tasksCache = mutableMapOf<String, MutableStateFlow<List<Task>>>()
|
||||||
tasks.map { it.toDomain() }
|
|
||||||
|
override suspend fun loadTasks(scheduleId: String): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
val response = backendApi.getTasksByScheduleId(scheduleId)
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val tasks = response.body()!!.map { it.toDomain() }.sortedBy { it.order }
|
||||||
|
|
||||||
|
// Обновляем кэш
|
||||||
|
val cache = _tasksCache.getOrPut(scheduleId) { MutableStateFlow(emptyList()) }
|
||||||
|
cache.value = tasks
|
||||||
|
|
||||||
|
// Сохраняем в локальную БД
|
||||||
|
tasks.forEach { task ->
|
||||||
|
taskDao.insertTask(task.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Failed to load tasks: $errorBody (code: ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTaskById(id: String): Task? {
|
override fun getTasks(scheduleId: String): Flow<List<Task>> {
|
||||||
return taskDao.getTaskById(id)?.toDomain()
|
val cache = _tasksCache.getOrPut(scheduleId) { MutableStateFlow(emptyList()) }
|
||||||
|
return cache.asStateFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createTask(task: Task) {
|
override suspend fun getTaskById(id: String): Result<Task> {
|
||||||
taskDao.insertTask(task.toEntity())
|
return try {
|
||||||
|
val response = backendApi.getTaskById(id)
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val task = response.body()!!.toDomain()
|
||||||
|
|
||||||
|
// Обновляем в кэше
|
||||||
|
val scheduleId = task.scheduleId
|
||||||
|
val cache = _tasksCache[scheduleId]
|
||||||
|
if (cache != null) {
|
||||||
|
val currentTasks = cache.value.toMutableList()
|
||||||
|
val index = currentTasks.indexOfFirst { it.id == id }
|
||||||
|
if (index >= 0) {
|
||||||
|
currentTasks[index] = task
|
||||||
|
} else {
|
||||||
|
currentTasks.add(task)
|
||||||
|
}
|
||||||
|
cache.value = currentTasks.sortedBy { it.order }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем в локальную БД
|
||||||
|
taskDao.insertTask(task.toEntity())
|
||||||
|
|
||||||
|
Result.success(task)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Failed to get task: $errorBody (code: ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createTask(task: Task): Result<Task> {
|
||||||
|
return try {
|
||||||
|
val request = task.toCreateRequest()
|
||||||
|
val response = backendApi.createTask(request)
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val createdTask = response.body()!!.toDomain()
|
||||||
|
|
||||||
|
// Обновляем кэш
|
||||||
|
val cache = _tasksCache[task.scheduleId]
|
||||||
|
if (cache != null) {
|
||||||
|
val currentTasks = cache.value.toMutableList()
|
||||||
|
currentTasks.add(createdTask)
|
||||||
|
cache.value = currentTasks.sortedBy { it.order }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем в локальную БД
|
||||||
|
taskDao.insertTask(createdTask.toEntity())
|
||||||
|
|
||||||
|
Result.success(createdTask)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Failed to create task: $errorBody (code: ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateTask(task: Task) {
|
override suspend fun updateTask(task: Task) {
|
||||||
taskDao.updateTask(task.toEntity())
|
taskDao.updateTask(task.toEntity())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteTask(id: String) {
|
override suspend fun deleteTask(id: String): Result<Unit> {
|
||||||
taskDao.deleteTask(id)
|
return try {
|
||||||
|
// Сначала получаем задачу для определения scheduleId
|
||||||
|
val task = taskDao.getTaskById(id)?.toDomain()
|
||||||
|
val scheduleId = task?.scheduleId
|
||||||
|
|
||||||
|
val response = backendApi.deleteTask(id)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
// Удаляем из кэша
|
||||||
|
if (scheduleId != null) {
|
||||||
|
val cache = _tasksCache[scheduleId]
|
||||||
|
if (cache != null) {
|
||||||
|
val currentTasks = cache.value.toMutableList()
|
||||||
|
currentTasks.removeAll { it.id == id }
|
||||||
|
cache.value = currentTasks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем из локальной БД
|
||||||
|
taskDao.deleteTask(id)
|
||||||
|
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Failed to delete task: $errorBody (code: ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun completeTask(id: String) {
|
override suspend fun completeTask(id: String, completed: Boolean): Result<Task> {
|
||||||
taskDao.completeTask(id)
|
return try {
|
||||||
|
val response = backendApi.completeTask(id, completed)
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val updatedTask = response.body()!!.toDomain()
|
||||||
|
|
||||||
|
// Обновляем в кэше
|
||||||
|
val scheduleId = updatedTask.scheduleId
|
||||||
|
val cache = _tasksCache[scheduleId]
|
||||||
|
if (cache != null) {
|
||||||
|
val currentTasks = cache.value.toMutableList()
|
||||||
|
val index = currentTasks.indexOfFirst { it.id == id }
|
||||||
|
if (index >= 0) {
|
||||||
|
currentTasks[index] = updatedTask
|
||||||
|
cache.value = currentTasks.sortedBy { it.order }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем в локальную БД
|
||||||
|
taskDao.insertTask(updatedTask.toEntity())
|
||||||
|
|
||||||
|
Result.success(updatedTask)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Failed to complete task: $errorBody (code: ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ object DatabaseModule {
|
|||||||
context,
|
context,
|
||||||
NewPlanetDatabase::class.java,
|
NewPlanetDatabase::class.java,
|
||||||
"newplanet_database"
|
"newplanet_database"
|
||||||
).build()
|
)
|
||||||
|
.fallbackToDestructiveMigration() // Пересоздает БД при изменении схемы (для разработки)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.novayaplaneta.di
|
package com.novayaplaneta.di
|
||||||
|
|
||||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
import com.novayaplaneta.data.remote.AiApi
|
||||||
|
import com.novayaplaneta.data.remote.AuthApi
|
||||||
|
import com.novayaplaneta.data.remote.AuthInterceptor
|
||||||
import com.novayaplaneta.data.remote.BackendApi
|
import com.novayaplaneta.data.remote.BackendApi
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -26,26 +29,30 @@ object NetworkModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideOkHttpClient(): OkHttpClient {
|
fun provideOkHttpClient(
|
||||||
|
authInterceptor: AuthInterceptor
|
||||||
|
): OkHttpClient {
|
||||||
val loggingInterceptor = HttpLoggingInterceptor().apply {
|
val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||||
level = HttpLoggingInterceptor.Level.BODY
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
}
|
}
|
||||||
|
|
||||||
return OkHttpClient.Builder()
|
return OkHttpClient.Builder()
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
.addInterceptor(loggingInterceptor)
|
.addInterceptor(loggingInterceptor)
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.writeTimeout(30, TimeUnit.SECONDS)
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
fun provideRetrofit(
|
||||||
|
okHttpClient: OkHttpClient
|
||||||
|
): Retrofit {
|
||||||
val contentType = "application/json".toMediaType()
|
val contentType = "application/json".toMediaType()
|
||||||
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl("https://api.novayaplaneta.ru/")
|
.baseUrl("http://10.0.2.2:8000/")
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(json.asConverterFactory(contentType))
|
.addConverterFactory(json.asConverterFactory(contentType))
|
||||||
.build()
|
.build()
|
||||||
@@ -53,8 +60,26 @@ object NetworkModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideBackendApi(retrofit: Retrofit): BackendApi {
|
fun provideAuthApi(
|
||||||
|
retrofit: Retrofit
|
||||||
|
): AuthApi {
|
||||||
|
return retrofit.create(AuthApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideBackendApi(
|
||||||
|
retrofit: Retrofit
|
||||||
|
): BackendApi {
|
||||||
return retrofit.create(BackendApi::class.java)
|
return retrofit.create(BackendApi::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAiApi(
|
||||||
|
retrofit: Retrofit
|
||||||
|
): AiApi {
|
||||||
|
return retrofit.create(AiApi::class.java)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ data class Reward(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
val points: Int,
|
val pointsRequired: Int,
|
||||||
|
val isClaimed: Boolean = false,
|
||||||
val earnedAt: LocalDateTime? = null,
|
val earnedAt: LocalDateTime? = null,
|
||||||
val userId: String
|
val userId: String,
|
||||||
|
val createdAt: LocalDateTime? = null,
|
||||||
|
val updatedAt: LocalDateTime? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ data class Schedule(
|
|||||||
val tasks: List<Task>,
|
val tasks: List<Task>,
|
||||||
val date: LocalDateTime,
|
val date: LocalDateTime,
|
||||||
val createdAt: LocalDateTime,
|
val createdAt: LocalDateTime,
|
||||||
val userId: String
|
val userId: String,
|
||||||
|
val rewardId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ data class Task(
|
|||||||
val completed: Boolean = false,
|
val completed: Boolean = false,
|
||||||
val scheduledTime: LocalDateTime?,
|
val scheduledTime: LocalDateTime?,
|
||||||
val duration: Int? = null, // in minutes
|
val duration: Int? = null, // in minutes
|
||||||
val scheduleId: String
|
val scheduleId: String,
|
||||||
|
val order: Int = 0,
|
||||||
|
val category: String? = null,
|
||||||
|
val createdAt: LocalDateTime? = null,
|
||||||
|
val updatedAt: LocalDateTime? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package com.novayaplaneta.domain.model
|
|||||||
|
|
||||||
data class User(
|
data class User(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val fullName: String,
|
||||||
val email: String,
|
val email: String,
|
||||||
val role: UserRole,
|
val role: UserRole,
|
||||||
val token: String? = null
|
val createdAt: String? = null,
|
||||||
|
val updatedAt: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class UserRole {
|
enum class UserRole {
|
||||||
|
|||||||
@@ -4,8 +4,24 @@ import com.novayaplaneta.domain.model.ChatMessage
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface AIRepository {
|
interface AIRepository {
|
||||||
suspend fun sendMessage(userId: String, message: String): Result<String>
|
suspend fun sendMessage(userId: String, message: String, conversationId: String?): Result<ChatResponse>
|
||||||
fun getChatHistory(userId: String): Flow<List<ChatMessage>>
|
fun getChatHistory(userId: String): Flow<List<ChatMessage>>
|
||||||
suspend fun generateSchedule(userId: String, preferences: String): Result<String>
|
suspend fun generateSchedule(
|
||||||
|
childAge: Int,
|
||||||
|
preferences: List<String>,
|
||||||
|
date: String,
|
||||||
|
description: String
|
||||||
|
): Result<GenerateScheduleResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class ChatResponse(
|
||||||
|
val response: String,
|
||||||
|
val conversationId: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GenerateScheduleResult(
|
||||||
|
val scheduleId: String,
|
||||||
|
val title: String,
|
||||||
|
val tasks: List<Map<String, Any>>
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import com.novayaplaneta.domain.model.User
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface AuthRepository {
|
interface AuthRepository {
|
||||||
|
suspend fun register(email: String, fullName: String, password: String, role: String = "CHILD"): Result<User>
|
||||||
suspend fun login(email: String, password: String): Result<User>
|
suspend fun login(email: String, password: String): Result<User>
|
||||||
|
suspend fun refresh(): Result<Unit>
|
||||||
|
suspend fun getMe(): Result<User>
|
||||||
suspend fun logout()
|
suspend fun logout()
|
||||||
fun getCurrentUser(): Flow<User?>
|
fun getCurrentUser(): Flow<User?>
|
||||||
suspend fun saveUser(user: User)
|
suspend fun saveUser(user: User)
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import com.novayaplaneta.domain.model.Reward
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface RewardRepository {
|
interface RewardRepository {
|
||||||
|
suspend fun loadRewards(skip: Int = 0, limit: Int = 100, isClaimed: Boolean? = null): Result<Unit>
|
||||||
fun getRewards(userId: String): Flow<List<Reward>>
|
fun getRewards(userId: String): Flow<List<Reward>>
|
||||||
suspend fun getRewardById(id: String): Reward?
|
suspend fun getRewardById(id: String): Result<Reward>
|
||||||
suspend fun createReward(reward: Reward)
|
suspend fun createReward(reward: Reward): Result<Reward>
|
||||||
suspend fun updateReward(reward: Reward)
|
suspend fun updateReward(reward: Reward)
|
||||||
suspend fun deleteReward(id: String)
|
suspend fun deleteReward(id: String): Result<Unit>
|
||||||
suspend fun earnReward(userId: String, rewardId: String)
|
suspend fun claimReward(rewardId: String): Result<Reward>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.novayaplaneta.domain.repository
|
|||||||
|
|
||||||
import com.novayaplaneta.domain.model.Schedule
|
import com.novayaplaneta.domain.model.Schedule
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
interface ScheduleRepository {
|
interface ScheduleRepository {
|
||||||
fun getSchedules(userId: String): Flow<List<Schedule>>
|
fun getSchedules(userId: String): Flow<List<Schedule>>
|
||||||
@@ -11,5 +10,6 @@ interface ScheduleRepository {
|
|||||||
suspend fun updateSchedule(schedule: Schedule)
|
suspend fun updateSchedule(schedule: Schedule)
|
||||||
suspend fun deleteSchedule(id: String)
|
suspend fun deleteSchedule(id: String)
|
||||||
suspend fun syncSchedules(userId: String)
|
suspend fun syncSchedules(userId: String)
|
||||||
|
suspend fun loadSchedules(scheduleDate: String? = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import com.novayaplaneta.domain.model.Task
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface TaskRepository {
|
interface TaskRepository {
|
||||||
|
suspend fun loadTasks(scheduleId: String): Result<Unit>
|
||||||
fun getTasks(scheduleId: String): Flow<List<Task>>
|
fun getTasks(scheduleId: String): Flow<List<Task>>
|
||||||
suspend fun getTaskById(id: String): Task?
|
suspend fun getTaskById(id: String): Result<Task>
|
||||||
suspend fun createTask(task: Task)
|
suspend fun createTask(task: Task): Result<Task>
|
||||||
suspend fun updateTask(task: Task)
|
suspend fun updateTask(task: Task)
|
||||||
suspend fun deleteTask(id: String)
|
suspend fun deleteTask(id: String): Result<Unit>
|
||||||
suspend fun completeTask(id: String)
|
suspend fun completeTask(id: String, completed: Boolean): Result<Task>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.model.Reward
|
||||||
|
import com.novayaplaneta.domain.repository.RewardRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ClaimRewardUseCase @Inject constructor(
|
||||||
|
private val repository: RewardRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(rewardId: String): Result<Reward> {
|
||||||
|
return repository.claimReward(rewardId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
package com.novayaplaneta.domain.usecase
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.model.Task
|
||||||
import com.novayaplaneta.domain.repository.TaskRepository
|
import com.novayaplaneta.domain.repository.TaskRepository
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class CompleteTaskUseCase @Inject constructor(
|
class CompleteTaskUseCase @Inject constructor(
|
||||||
private val repository: TaskRepository
|
private val repository: TaskRepository
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(taskId: String) {
|
suspend operator fun invoke(id: String, completed: Boolean): Result<Task> {
|
||||||
repository.completeTask(taskId)
|
return repository.completeTask(id, completed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.model.Reward
|
||||||
|
import com.novayaplaneta.domain.repository.RewardRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class CreateRewardUseCase @Inject constructor(
|
||||||
|
private val repository: RewardRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(reward: Reward): Result<Reward> {
|
||||||
|
return repository.createReward(reward)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,8 +7,17 @@ import javax.inject.Inject
|
|||||||
class CreateScheduleUseCase @Inject constructor(
|
class CreateScheduleUseCase @Inject constructor(
|
||||||
private val repository: ScheduleRepository
|
private val repository: ScheduleRepository
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(schedule: Schedule) {
|
suspend operator fun invoke(schedule: Schedule): Result<Schedule> {
|
||||||
repository.createSchedule(schedule)
|
return try {
|
||||||
|
repository.createSchedule(schedule)
|
||||||
|
// После создания перезагружаем список, чтобы получить актуальные данные
|
||||||
|
repository.loadSchedules()
|
||||||
|
// Возвращаем успешный результат
|
||||||
|
// Созданное расписание уже добавлено в кэш через createSchedule
|
||||||
|
Result.success(schedule)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.model.Task
|
||||||
|
import com.novayaplaneta.domain.repository.TaskRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class CreateTaskUseCase @Inject constructor(
|
||||||
|
private val repository: TaskRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(task: Task): Result<Task> {
|
||||||
|
return repository.createTask(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.repository.RewardRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class DeleteRewardUseCase @Inject constructor(
|
||||||
|
private val repository: RewardRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(id: String): Result<Unit> {
|
||||||
|
return repository.deleteReward(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.repository.ScheduleRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class DeleteScheduleUseCase @Inject constructor(
|
||||||
|
private val repository: ScheduleRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(id: String): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
repository.deleteSchedule(id)
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.repository.TaskRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class DeleteTaskUseCase @Inject constructor(
|
||||||
|
private val repository: TaskRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(id: String): Result<Unit> {
|
||||||
|
return repository.deleteTask(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.repository.AIRepository
|
||||||
|
import com.novayaplaneta.domain.repository.GenerateScheduleResult
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GenerateScheduleUseCase @Inject constructor(
|
||||||
|
private val aiRepository: AIRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
childAge: Int,
|
||||||
|
preferences: List<String>,
|
||||||
|
date: String,
|
||||||
|
description: String
|
||||||
|
): Result<GenerateScheduleResult> {
|
||||||
|
return aiRepository.generateSchedule(childAge, preferences, date, description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.model.User
|
||||||
|
import com.novayaplaneta.domain.repository.AuthRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GetMeUseCase @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(): Result<User> {
|
||||||
|
return authRepository.getMe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.model.Reward
|
||||||
|
import com.novayaplaneta.domain.repository.RewardRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GetRewardByIdUseCase @Inject constructor(
|
||||||
|
private val repository: RewardRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(id: String): Result<Reward> {
|
||||||
|
return repository.getRewardById(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.model.Reward
|
||||||
|
import com.novayaplaneta.domain.repository.RewardRepository
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GetRewardsUseCase @Inject constructor(
|
||||||
|
private val repository: RewardRepository
|
||||||
|
) {
|
||||||
|
operator fun invoke(userId: String): Flow<List<Reward>> {
|
||||||
|
return repository.getRewards(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadRewards(skip: Int = 0, limit: Int = 100, isClaimed: Boolean? = null): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
repository.loadRewards(skip, limit, isClaimed)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.model.Schedule
|
||||||
|
import com.novayaplaneta.domain.repository.ScheduleRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GetScheduleByIdUseCase @Inject constructor(
|
||||||
|
private val repository: ScheduleRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(id: String): Result<Schedule> {
|
||||||
|
return try {
|
||||||
|
val schedule = repository.getScheduleById(id)
|
||||||
|
if (schedule != null) {
|
||||||
|
Result.success(schedule)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("Schedule not found"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,5 +11,14 @@ class GetSchedulesUseCase @Inject constructor(
|
|||||||
operator fun invoke(userId: String): Flow<List<Schedule>> {
|
operator fun invoke(userId: String): Flow<List<Schedule>> {
|
||||||
return repository.getSchedules(userId)
|
return repository.getSchedules(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun loadSchedules(scheduleDate: String? = null): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
repository.loadSchedules(scheduleDate)
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.model.Task
|
||||||
|
import com.novayaplaneta.domain.repository.TaskRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GetTaskByIdUseCase @Inject constructor(
|
||||||
|
private val repository: TaskRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(id: String): Result<Task> {
|
||||||
|
return repository.getTaskById(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.model.Task
|
||||||
|
import com.novayaplaneta.domain.repository.TaskRepository
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GetTasksUseCase @Inject constructor(
|
||||||
|
private val repository: TaskRepository
|
||||||
|
) {
|
||||||
|
operator fun invoke(scheduleId: String): Flow<List<Task>> {
|
||||||
|
return repository.getTasks(scheduleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadTasks(scheduleId: String): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
repository.loadTasks(scheduleId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.model.User
|
||||||
|
import com.novayaplaneta.domain.repository.AuthRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class LoginUseCase @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(email: String, password: String): Result<User> {
|
||||||
|
return authRepository.login(email, password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.repository.AuthRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class LogoutUseCase @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke() {
|
||||||
|
authRepository.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
|
import com.novayaplaneta.domain.model.User
|
||||||
|
import com.novayaplaneta.domain.repository.AuthRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class RegisterUseCase @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
email: String,
|
||||||
|
fullName: String,
|
||||||
|
password: String,
|
||||||
|
role: String = "CHILD"
|
||||||
|
): Result<User> {
|
||||||
|
return authRepository.register(email, fullName, password, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
package com.novayaplaneta.domain.usecase
|
package com.novayaplaneta.domain.usecase
|
||||||
|
|
||||||
import com.novayaplaneta.domain.repository.AIRepository
|
import com.novayaplaneta.domain.repository.AIRepository
|
||||||
|
import com.novayaplaneta.domain.repository.ChatResponse
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class SendAIMessageUseCase @Inject constructor(
|
class SendAIMessageUseCase @Inject constructor(
|
||||||
private val repository: AIRepository
|
private val repository: AIRepository
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(userId: String, message: String): Result<String> {
|
suspend operator fun invoke(userId: String, message: String, conversationId: String? = null): Result<ChatResponse> {
|
||||||
return repository.sendMessage(userId, message)
|
return repository.sendMessage(userId, message, conversationId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ package com.novayaplaneta.ui.navigation
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.navArgument
|
||||||
import com.novayaplaneta.ui.screens.ai.AIScreen
|
import com.novayaplaneta.ui.screens.ai.AIScreen
|
||||||
import com.novayaplaneta.ui.screens.auth.ForgotPasswordScreen
|
import com.novayaplaneta.ui.screens.auth.ForgotPasswordScreen
|
||||||
import com.novayaplaneta.ui.screens.auth.LoginScreen
|
import com.novayaplaneta.ui.screens.auth.LoginScreen
|
||||||
import com.novayaplaneta.ui.screens.auth.RegistrationScreen
|
import com.novayaplaneta.ui.screens.auth.RegistrationScreen
|
||||||
|
import com.novayaplaneta.ui.screens.auth.SplashScreen
|
||||||
import com.novayaplaneta.ui.screens.rewards.RewardsScreen
|
import com.novayaplaneta.ui.screens.rewards.RewardsScreen
|
||||||
import com.novayaplaneta.ui.screens.schedule.ScheduleScreen
|
import com.novayaplaneta.ui.screens.schedule.ScheduleScreen
|
||||||
import com.novayaplaneta.ui.screens.settings.SettingsScreen
|
import com.novayaplaneta.ui.screens.settings.SettingsScreen
|
||||||
@@ -19,13 +22,16 @@ import com.novayaplaneta.ui.screens.timer.TimerScreen
|
|||||||
fun NewPlanetNavigation(
|
fun NewPlanetNavigation(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
startDestination: String = "login"
|
startDestination: String = "splash"
|
||||||
) {
|
) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = startDestination,
|
startDestination = startDestination,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
|
composable("splash") {
|
||||||
|
SplashScreen(navController = navController)
|
||||||
|
}
|
||||||
composable("login") {
|
composable("login") {
|
||||||
LoginScreen(navController = navController)
|
LoginScreen(navController = navController)
|
||||||
}
|
}
|
||||||
@@ -38,8 +44,17 @@ fun NewPlanetNavigation(
|
|||||||
composable("schedule") {
|
composable("schedule") {
|
||||||
ScheduleScreen(navController = navController)
|
ScheduleScreen(navController = navController)
|
||||||
}
|
}
|
||||||
composable("tasks") {
|
composable(
|
||||||
TaskScreen()
|
route = "tasks/{scheduleId}",
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("scheduleId") { type = NavType.StringType }
|
||||||
|
)
|
||||||
|
) { backStackEntry ->
|
||||||
|
val scheduleId = backStackEntry.arguments?.getString("scheduleId")
|
||||||
|
TaskScreen(
|
||||||
|
scheduleId = scheduleId,
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable("timer") {
|
composable("timer") {
|
||||||
TimerScreen()
|
TimerScreen()
|
||||||
@@ -48,7 +63,7 @@ fun NewPlanetNavigation(
|
|||||||
RewardsScreen(navController = navController)
|
RewardsScreen(navController = navController)
|
||||||
}
|
}
|
||||||
composable("ai") {
|
composable("ai") {
|
||||||
AIScreen()
|
AIScreen(navController = navController)
|
||||||
}
|
}
|
||||||
composable("settings") {
|
composable("settings") {
|
||||||
SettingsScreen(navController = navController)
|
SettingsScreen(navController = navController)
|
||||||
|
|||||||
@@ -1,94 +1,403 @@
|
|||||||
package com.novayaplaneta.ui.screens.ai
|
package com.novayaplaneta.ui.screens.ai
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CalendarToday
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Public
|
||||||
|
import androidx.compose.material.icons.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import kotlin.math.sin
|
||||||
|
import androidx.compose.ui.unit.lerp
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.novayaplaneta.R
|
||||||
|
import com.novayaplaneta.ui.components.NovayaPlanetaLogo
|
||||||
|
import com.novayaplaneta.ui.theme.*
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AIScreen(
|
fun AIScreen(
|
||||||
|
navController: androidx.navigation.NavController? = null,
|
||||||
viewModel: AIViewModel = hiltViewModel(),
|
viewModel: AIViewModel = hiltViewModel(),
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
var messageText by remember { mutableStateOf("") }
|
var messageText by remember { mutableStateOf("") }
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
Scaffold(
|
// Показываем ошибки через Snackbar
|
||||||
topBar = {
|
LaunchedEffect(uiState.error) {
|
||||||
TopAppBar(
|
uiState.error?.let { error ->
|
||||||
title = { Text("ИИ-помощник \"Земля\"") }
|
snackbarHostState.showSnackbar(
|
||||||
|
message = error,
|
||||||
|
duration = SnackbarDuration.Long
|
||||||
)
|
)
|
||||||
|
viewModel.clearError()
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
}
|
||||||
Column(
|
|
||||||
modifier = modifier
|
val configuration = LocalConfiguration.current
|
||||||
.fillMaxSize()
|
val screenWidthDp = configuration.screenWidthDp
|
||||||
.padding(paddingValues)
|
val screenHeightDp = configuration.screenHeightDp
|
||||||
|
|
||||||
|
// Цвета из autism-friendly палитры
|
||||||
|
val backgroundColor = LoginBackgroundTurquoise
|
||||||
|
val navPanelColor = LoginInputLightBlue
|
||||||
|
val accentGreen = LoginGreenAccent
|
||||||
|
|
||||||
|
// Состояние прокрутки чата
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Автопрокрутка вниз при новых сообщениях
|
||||||
|
LaunchedEffect(uiState.messages.size) {
|
||||||
|
if (uiState.messages.isNotEmpty()) {
|
||||||
|
listState.animateScrollToItem(uiState.messages.size - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(color = backgroundColor)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
// Левая панель навигации с логотипом над ней
|
||||||
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.width((screenWidthDp * 0.22f).dp.coerceIn(160.dp, 240.dp))
|
||||||
.padding(16.dp),
|
.fillMaxHeight()
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
.padding(vertical = 20.dp, horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
items(uiState.messages) { message ->
|
// Логотип над панелью навигации
|
||||||
Card(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
colors = CardDefaults.cardColors(
|
.fillMaxWidth()
|
||||||
containerColor = if (message.isFromAI) {
|
.padding(bottom = 16.dp),
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
contentAlignment = Alignment.Center
|
||||||
} else {
|
) {
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
val logoSize = (screenHeightDp * 0.22f).toInt().coerceIn(140, 240).dp
|
||||||
}
|
NovayaPlanetaLogo(size = logoSize)
|
||||||
)
|
}
|
||||||
) {
|
|
||||||
Text(
|
// Панель навигации
|
||||||
text = message.message,
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.bodyLarge
|
.fillMaxWidth()
|
||||||
)
|
.weight(1f)
|
||||||
}
|
.clip(RoundedCornerShape(32.dp))
|
||||||
|
.background(color = navPanelColor)
|
||||||
|
.padding(vertical = 24.dp, horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(32.dp)
|
||||||
|
) {
|
||||||
|
NavItem(
|
||||||
|
icon = Icons.Filled.CalendarToday,
|
||||||
|
text = "Расписание",
|
||||||
|
isSelected = false,
|
||||||
|
onClick = { navController?.navigate("schedule") }
|
||||||
|
)
|
||||||
|
|
||||||
|
NavItem(
|
||||||
|
icon = Icons.Filled.Star,
|
||||||
|
text = "Награды",
|
||||||
|
isSelected = false,
|
||||||
|
onClick = { navController?.navigate("rewards") }
|
||||||
|
)
|
||||||
|
|
||||||
|
NavItem(
|
||||||
|
icon = Icons.Filled.Person,
|
||||||
|
text = "Профиль",
|
||||||
|
isSelected = false,
|
||||||
|
onClick = { navController?.navigate("settings") }
|
||||||
|
)
|
||||||
|
|
||||||
|
NavItem(
|
||||||
|
icon = Icons.Filled.Public,
|
||||||
|
text = "Земля",
|
||||||
|
isSelected = true,
|
||||||
|
onClick = { }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.isLoading) {
|
// Основная область с AI агентом и чатом
|
||||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.weight(1f)
|
||||||
.padding(16.dp),
|
.fillMaxHeight()
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
.padding(24.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
// Левая часть: Анимированная картинка AI агента
|
||||||
value = messageText,
|
Column(
|
||||||
onValueChange = { messageText = it },
|
modifier = Modifier
|
||||||
modifier = Modifier.weight(1f),
|
.width((screenWidthDp * 0.3f).dp.coerceIn(200.dp, 400.dp))
|
||||||
placeholder = { Text("Введите сообщение...") }
|
.fillMaxHeight(),
|
||||||
)
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
Button(
|
verticalArrangement = Arrangement.Center
|
||||||
onClick = {
|
) {
|
||||||
if (messageText.isNotBlank()) {
|
AnimatedAIAgent(
|
||||||
viewModel.sendMessage("userId", messageText)
|
isSpeaking = uiState.isLoading,
|
||||||
messageText = ""
|
screenHeightDp = screenHeightDp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Правая часть: Чат
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.background(color = LoginCardLightBlue)
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок чата
|
||||||
|
val titleSize = (screenHeightDp * 0.04f).toInt().coerceIn(32, 52).sp
|
||||||
|
Text(
|
||||||
|
text = "Помощник Земля",
|
||||||
|
fontSize = titleSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black,
|
||||||
|
modifier = Modifier.padding(bottom = 24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Список сообщений
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
items(uiState.messages) { message ->
|
||||||
|
ChatBubble(
|
||||||
|
message = message.message,
|
||||||
|
isFromAI = message.isFromAI,
|
||||||
|
screenHeightDp = screenHeightDp,
|
||||||
|
accentGreen = accentGreen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Поле ввода и кнопка отправки
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = messageText,
|
||||||
|
onValueChange = { messageText = it },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "Напишите сообщение...",
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
singleLine = false,
|
||||||
|
maxLines = 3,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = Color.Black,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
unfocusedBorderColor = Color.Gray
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(20.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (messageText.isNotBlank()) {
|
||||||
|
viewModel.sendMessage(messageText)
|
||||||
|
messageText = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.height(56.dp)
|
||||||
|
.width(56.dp),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = accentGreen,
|
||||||
|
contentColor = Color.White
|
||||||
|
),
|
||||||
|
enabled = messageText.isNotBlank() && !uiState.isLoading
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Send,
|
||||||
|
contentDescription = "Отправить",
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
|
||||||
Text("Отправить")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snackbar для отображения ошибок
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AnimatedAIAgent(
|
||||||
|
isSpeaking: Boolean,
|
||||||
|
screenHeightDp: Int
|
||||||
|
) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "ai_agent_animation")
|
||||||
|
|
||||||
|
// Анимация покачивания Земли - более быстрое когда говорит
|
||||||
|
val bounceFloat by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 2f * kotlin.math.PI.toFloat(),
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(
|
||||||
|
durationMillis = if (isSpeaking) 400 else 2000,
|
||||||
|
easing = LinearEasing
|
||||||
|
),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "bounce_float"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Интенсивное покачивание когда говорит, легкое когда молчит
|
||||||
|
val offsetY = if (isSpeaking) {
|
||||||
|
sin(bounceFloat) * 20f // Активное прыгание при ответе
|
||||||
|
} else {
|
||||||
|
sin(bounceFloat) * 8f // Легкое покачивание в покое
|
||||||
|
}
|
||||||
|
|
||||||
|
val earthSize = (screenHeightDp * 0.4f).dp.coerceIn(200.dp, 400.dp)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(earthSize),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
// Изображение Земли с покачиванием
|
||||||
|
androidx.compose.foundation.Image(
|
||||||
|
painter = painterResource(id = R.drawable.logo_earth),
|
||||||
|
contentDescription = "AI Агент Земля",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.offset(y = offsetY.dp),
|
||||||
|
contentScale = ContentScale.Fit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatBubble(
|
||||||
|
message: String,
|
||||||
|
isFromAI: Boolean,
|
||||||
|
screenHeightDp: Int,
|
||||||
|
accentGreen: Color
|
||||||
|
) {
|
||||||
|
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 20).sp
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = if (isFromAI) Arrangement.Start else Arrangement.End
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.widthIn(max = (screenHeightDp * 0.4f).dp.coerceIn(200.dp, 400.dp)),
|
||||||
|
shape = RoundedCornerShape(
|
||||||
|
topStart = 20.dp,
|
||||||
|
topEnd = 20.dp,
|
||||||
|
bottomStart = if (isFromAI) 4.dp else 20.dp,
|
||||||
|
bottomEnd = if (isFromAI) 20.dp else 4.dp
|
||||||
|
),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (isFromAI) {
|
||||||
|
LoginGreenSoft // Мягкий зеленый для AI
|
||||||
|
} else {
|
||||||
|
LoginGreenAccent // Зеленый акцент для пользователя
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
fontSize = textSize,
|
||||||
|
color = Color.Black,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NavItem(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
text: String,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val iconSize = 40.dp
|
||||||
|
val textSize = 18.sp
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.clickable { onClick() }
|
||||||
|
.background(
|
||||||
|
color = if (isSelected) Color.White.copy(alpha = 0.3f) else Color.Transparent
|
||||||
|
)
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = text,
|
||||||
|
modifier = Modifier.size(iconSize),
|
||||||
|
tint = LoginGreenAccent
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = textSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,42 +7,117 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AIViewModel @Inject constructor(
|
class AIViewModel @Inject constructor(
|
||||||
private val aiRepository: AIRepository
|
private val aiRepository: AIRepository,
|
||||||
|
private val authRepository: com.novayaplaneta.domain.repository.AuthRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(AIUiState())
|
private val _uiState = MutableStateFlow(AIUiState())
|
||||||
val uiState: StateFlow<AIUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<AIUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
fun loadChatHistory(userId: String) {
|
private var conversationId: String? = null
|
||||||
|
private var currentUserId: String? = null
|
||||||
|
private var sendMessageJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadUserId()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadUserId() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
aiRepository.getChatHistory(userId).collect { messages ->
|
try {
|
||||||
_uiState.value = _uiState.value.copy(messages = messages)
|
val user = authRepository.getCurrentUser().first()
|
||||||
|
currentUserId = user?.id
|
||||||
|
if (currentUserId != null) {
|
||||||
|
loadChatHistory(currentUserId!!)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
error = e.message ?: "Ошибка загрузки пользователя"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage(userId: String, message: String) {
|
fun loadChatHistory(userId: String? = null) {
|
||||||
|
val targetUserId = userId ?: currentUserId ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
try {
|
||||||
aiRepository.sendMessage(userId, message).fold(
|
val messages = aiRepository.getChatHistory(targetUserId).first()
|
||||||
|
_uiState.value = _uiState.value.copy(messages = messages)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
error = e.message ?: "Ошибка загрузки истории чата"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(message: String) {
|
||||||
|
val userId = currentUserId ?: return
|
||||||
|
|
||||||
|
// Предотвращаем повторные отправки
|
||||||
|
if (_uiState.value.isLoading || sendMessageJob?.isActive == true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessageJob = viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||||
|
|
||||||
|
aiRepository.sendMessage(userId, message, conversationId).fold(
|
||||||
onSuccess = { response ->
|
onSuccess = { response ->
|
||||||
|
// Сохраняем conversation_id для следующих сообщений
|
||||||
|
conversationId = response.conversationId
|
||||||
|
// Перезагружаем историю чата
|
||||||
|
loadChatHistory(userId)
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
val errorMessage = error.message ?: "Ошибка отправки сообщения"
|
||||||
|
|
||||||
|
// Если ошибка связана с дубликатом conversation_id, сбрасываем его
|
||||||
|
if (errorMessage.contains("duplicate key", ignoreCase = true) ||
|
||||||
|
errorMessage.contains("conversation_id", ignoreCase = true)) {
|
||||||
|
conversationId = null
|
||||||
|
// Пытаемся отправить сообщение снова без conversation_id
|
||||||
|
retrySendMessage(userId, message)
|
||||||
|
} else {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = errorMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun retrySendMessage(userId: String, message: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
aiRepository.sendMessage(userId, message, null).fold(
|
||||||
|
onSuccess = { response ->
|
||||||
|
conversationId = response.conversationId
|
||||||
loadChatHistory(userId)
|
loadChatHistory(userId)
|
||||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
},
|
},
|
||||||
onFailure = { error ->
|
onFailure = { error ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = error.message
|
error = error.message ?: "Ошибка отправки сообщения"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class AIUiState(
|
data class AIUiState(
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ object EmailValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,39 +76,33 @@ fun ForgotPasswordScreen(
|
|||||||
val horizontalPadding = (screenWidthDp * 0.04f).toInt().coerceIn(24, 48).dp
|
val horizontalPadding = (screenWidthDp * 0.04f).toInt().coerceIn(24, 48).dp
|
||||||
val verticalPadding = (screenHeightDp * 0.03f).toInt().coerceIn(16, 32).dp
|
val verticalPadding = (screenHeightDp * 0.03f).toInt().coerceIn(16, 32).dp
|
||||||
|
|
||||||
Column(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
.padding(horizontal = horizontalPadding, vertical = verticalPadding)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
) {
|
||||||
// Логотип вверху слева
|
// Логотип вверху слева - увеличенный размер
|
||||||
val logoSizeByHeight = (screenHeightDp * 0.15f).toInt()
|
val logoSizeByHeight = (screenHeightDp * 0.28f).toInt()
|
||||||
val logoSizeByWidth = screenWidthDp / 5
|
val logoSizeByWidth = screenWidthDp / 3
|
||||||
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(100, 160).dp
|
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(180, 280).dp
|
||||||
|
|
||||||
Row(
|
NovayaPlanetaLogo(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
horizontalArrangement = Arrangement.Start
|
.align(Alignment.TopStart)
|
||||||
) {
|
.padding(bottom = 4.dp)
|
||||||
NovayaPlanetaLogo(
|
.clickable {
|
||||||
modifier = Modifier
|
navController.navigate("login") {
|
||||||
.padding(bottom = 4.dp)
|
popUpTo("login") { inclusive = false }
|
||||||
.clickable {
|
}
|
||||||
navController.navigate("login") {
|
},
|
||||||
popUpTo("login") { inclusive = false }
|
size = logoSize
|
||||||
}
|
)
|
||||||
},
|
|
||||||
size = logoSize
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(0.05f))
|
|
||||||
|
|
||||||
// Центрированный контент - адаптивная ширина (50-70% экрана)
|
// Центрированный контент - адаптивная ширина (50-70% экрана)
|
||||||
val contentWidthRatio = if (isLandscape) 0.5f else 0.7f
|
val contentWidthRatio = if (isLandscape) 0.5f else 0.7f
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
.fillMaxWidth(contentWidthRatio),
|
.fillMaxWidth(contentWidthRatio),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
@@ -370,8 +364,6 @@ fun ForgotPasswordScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(0.1f))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,33 +76,28 @@ fun LoginScreen(
|
|||||||
val horizontalPadding = (screenWidthDp * 0.04f).toInt().coerceIn(24, 48).dp
|
val horizontalPadding = (screenWidthDp * 0.04f).toInt().coerceIn(24, 48).dp
|
||||||
val verticalPadding = (screenHeightDp * 0.03f).toInt().coerceIn(16, 32).dp
|
val verticalPadding = (screenHeightDp * 0.03f).toInt().coerceIn(16, 32).dp
|
||||||
|
|
||||||
Column(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
.padding(horizontal = horizontalPadding, vertical = verticalPadding)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
) {
|
||||||
// Логотип вверху слева - уменьшенный размер
|
// Логотип вверху слева - большой размер
|
||||||
val logoSizeByHeight = (screenHeightDp * 0.15f).toInt()
|
val logoSizeByHeight = (screenHeightDp * 0.28f).toInt()
|
||||||
val logoSizeByWidth = screenWidthDp / 5
|
val logoSizeByWidth = screenWidthDp / 3
|
||||||
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(100, 160).dp
|
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(180, 280).dp
|
||||||
|
|
||||||
Row(
|
NovayaPlanetaLogo(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
horizontalArrangement = Arrangement.Start
|
.align(Alignment.TopStart)
|
||||||
) {
|
.padding(bottom = 4.dp),
|
||||||
NovayaPlanetaLogo(
|
size = logoSize
|
||||||
modifier = Modifier.padding(bottom = 4.dp),
|
)
|
||||||
size = logoSize
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(0.05f))
|
|
||||||
|
|
||||||
// Центрированный контент - адаптивная ширина (50-70% экрана)
|
// Центрированный контент - адаптивная ширина (50-70% экрана)
|
||||||
val contentWidthRatio = if (isLandscape) 0.5f else 0.7f
|
val contentWidthRatio = if (isLandscape) 0.5f else 0.7f
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
.fillMaxWidth(contentWidthRatio),
|
.fillMaxWidth(contentWidthRatio),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
@@ -127,44 +122,64 @@ fun LoginScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// Поле логина
|
// Поле email
|
||||||
Box(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
.height(height = inputHeight)
|
|
||||||
.background(
|
|
||||||
color = LoginInputLightBlue,
|
|
||||||
shape = RoundedCornerShape(20.dp)
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.CenterStart
|
|
||||||
) {
|
) {
|
||||||
TextField(
|
Box(
|
||||||
value = uiState.login,
|
|
||||||
onValueChange = { viewModel.onLoginChange(it) },
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
text = "Введи логин",
|
|
||||||
fontSize = inputTextSize,
|
|
||||||
color = Color.Gray.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp),
|
.height(height = inputHeight)
|
||||||
colors = TextFieldDefaults.colors(
|
.background(
|
||||||
unfocusedContainerColor = Color.Transparent,
|
color = if (uiState.errorMessage != null && uiState.email.isNotBlank() && !EmailValidator.isValid(uiState.email)) {
|
||||||
focusedContainerColor = Color.Transparent,
|
Color(0xFFFFEBEE)
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
} else {
|
||||||
focusedIndicatorColor = Color.Transparent,
|
LoginInputLightBlue
|
||||||
unfocusedTextColor = Color.Black,
|
},
|
||||||
focusedTextColor = Color.Black
|
shape = RoundedCornerShape(20.dp)
|
||||||
),
|
),
|
||||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
contentAlignment = Alignment.CenterStart
|
||||||
fontSize = inputTextSize
|
) {
|
||||||
),
|
TextField(
|
||||||
singleLine = true,
|
value = uiState.email,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
|
onValueChange = { viewModel.onEmailChange(it) },
|
||||||
)
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "Введи email",
|
||||||
|
fontSize = inputTextSize,
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedTextColor = Color.Black
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
|
isError = uiState.errorMessage != null && uiState.email.isNotBlank() && !EmailValidator.isValid(uiState.email)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сообщение об ошибке email
|
||||||
|
if (uiState.errorMessage != null && uiState.email.isNotBlank() && !EmailValidator.isValid(uiState.email)) {
|
||||||
|
Text(
|
||||||
|
text = "Введите корректный email",
|
||||||
|
fontSize = (inputTextSizeValue * 0.8f).toInt().sp,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Поле пароля
|
// Поле пароля
|
||||||
@@ -213,12 +228,12 @@ fun LoginScreen(
|
|||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (uiState.isFormValid) {
|
if (uiState.isFormValid) {
|
||||||
// Переход на экран расписания при заполненных полях
|
viewModel.login {
|
||||||
navController.navigate("schedule") {
|
// Переход на экран расписания после успешного входа
|
||||||
popUpTo(0) { inclusive = true }
|
navController.navigate("schedule") {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Также вызываем логин для проверки через API (в фоне)
|
|
||||||
viewModel.login { }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -228,7 +243,7 @@ fun LoginScreen(
|
|||||||
enabled = uiState.isFormValid && !uiState.isLoading,
|
enabled = uiState.isFormValid && !uiState.isLoading,
|
||||||
colors = if (uiState.isFormValid) {
|
colors = if (uiState.isFormValid) {
|
||||||
ButtonDefaults.buttonColors(
|
ButtonDefaults.buttonColors(
|
||||||
containerColor = LoginButtonBlue,
|
containerColor = LoginGreenAccent,
|
||||||
contentColor = Color.White
|
contentColor = Color.White
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -261,12 +276,12 @@ fun LoginScreen(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
val linkTextSize = (screenHeightDp * 0.024f).toInt().coerceIn(18, 26).sp
|
val linkTextSize = (screenHeightDp * 0.032f).toInt().coerceIn(24, 32).sp
|
||||||
Text(
|
Text(
|
||||||
text = "Нет логина и пароля?",
|
text = "Нет логина и пароля?",
|
||||||
fontSize = linkTextSize,
|
fontSize = linkTextSize,
|
||||||
color = LoginGreenAccent,
|
color = LoginGreenAccent,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
navController.navigate("registration")
|
navController.navigate("registration")
|
||||||
}
|
}
|
||||||
@@ -276,15 +291,13 @@ fun LoginScreen(
|
|||||||
text = "Не помнишь пароль?",
|
text = "Не помнишь пароль?",
|
||||||
fontSize = linkTextSize,
|
fontSize = linkTextSize,
|
||||||
color = LoginGreenAccent,
|
color = LoginGreenAccent,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
navController.navigate("forgot_password")
|
navController.navigate("forgot_password")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(0.1f))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.novayaplaneta.ui.screens.auth
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.novayaplaneta.domain.repository.AuthRepository
|
import com.novayaplaneta.domain.usecase.LoginUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -12,23 +12,25 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class LoginViewModel @Inject constructor(
|
class LoginViewModel @Inject constructor(
|
||||||
private val authRepository: AuthRepository
|
private val loginUseCase: LoginUseCase
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(LoginUiState())
|
private val _uiState = MutableStateFlow(LoginUiState())
|
||||||
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
fun onLoginChange(login: String) {
|
fun onEmailChange(email: String) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
login = login,
|
email = email,
|
||||||
isFormValid = isFormValid(login, _uiState.value.password)
|
isFormValid = isFormValid(email, _uiState.value.password),
|
||||||
|
errorMessage = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onPasswordChange(password: String) {
|
fun onPasswordChange(password: String) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
password = password,
|
password = password,
|
||||||
isFormValid = isFormValid(_uiState.value.login, password)
|
isFormValid = isFormValid(_uiState.value.email, password),
|
||||||
|
errorMessage = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@ class LoginViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
val result = authRepository.login(_uiState.value.login, _uiState.value.password)
|
val result = loginUseCase(_uiState.value.email, _uiState.value.password)
|
||||||
|
|
||||||
result.onSuccess { user ->
|
result.onSuccess { user ->
|
||||||
_uiState.value = _uiState.value.copy(isLoading = false, isLoggedIn = true)
|
_uiState.value = _uiState.value.copy(isLoading = false, isLoggedIn = true)
|
||||||
@@ -52,13 +54,13 @@ class LoginViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isFormValid(login: String, password: String): Boolean {
|
private fun isFormValid(email: String, password: String): Boolean {
|
||||||
return login.isNotBlank() && password.isNotBlank()
|
return email.isNotBlank() && password.isNotBlank() && EmailValidator.isValid(email)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class LoginUiState(
|
data class LoginUiState(
|
||||||
val login: String = "",
|
val email: String = "",
|
||||||
val password: String = "",
|
val password: String = "",
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isFormValid: Boolean = false,
|
val isFormValid: Boolean = false,
|
||||||
|
|||||||
@@ -76,39 +76,33 @@ fun RegistrationScreen(
|
|||||||
val horizontalPadding = (screenWidthDp * 0.04f).toInt().coerceIn(24, 48).dp
|
val horizontalPadding = (screenWidthDp * 0.04f).toInt().coerceIn(24, 48).dp
|
||||||
val verticalPadding = (screenHeightDp * 0.03f).toInt().coerceIn(16, 32).dp
|
val verticalPadding = (screenHeightDp * 0.03f).toInt().coerceIn(16, 32).dp
|
||||||
|
|
||||||
Column(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
.padding(horizontal = horizontalPadding, vertical = verticalPadding)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
) {
|
||||||
// Логотип вверху слева
|
// Логотип вверху слева - увеличенный размер
|
||||||
val logoSizeByHeight = (screenHeightDp * 0.15f).toInt()
|
val logoSizeByHeight = (screenHeightDp * 0.28f).toInt()
|
||||||
val logoSizeByWidth = screenWidthDp / 5
|
val logoSizeByWidth = screenWidthDp / 3
|
||||||
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(100, 160).dp
|
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(180, 280).dp
|
||||||
|
|
||||||
Row(
|
NovayaPlanetaLogo(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
horizontalArrangement = Arrangement.Start
|
.align(Alignment.TopStart)
|
||||||
) {
|
.padding(bottom = 4.dp)
|
||||||
NovayaPlanetaLogo(
|
.clickable {
|
||||||
modifier = Modifier
|
navController.navigate("login") {
|
||||||
.padding(bottom = 4.dp)
|
popUpTo("login") { inclusive = false }
|
||||||
.clickable {
|
}
|
||||||
navController.navigate("login") {
|
},
|
||||||
popUpTo("login") { inclusive = false }
|
size = logoSize
|
||||||
}
|
)
|
||||||
},
|
|
||||||
size = logoSize
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(0.05f))
|
|
||||||
|
|
||||||
// Центрированный контент - адаптивная ширина (50-70% экрана)
|
// Центрированный контент - адаптивная ширина (50-70% экрана)
|
||||||
val contentWidthRatio = if (isLandscape) 0.5f else 0.7f
|
val contentWidthRatio = if (isLandscape) 0.5f else 0.7f
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
.fillMaxWidth(contentWidthRatio),
|
.fillMaxWidth(contentWidthRatio),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
@@ -133,7 +127,7 @@ fun RegistrationScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// Поле логина
|
// Поле полного имени
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -145,11 +139,11 @@ fun RegistrationScreen(
|
|||||||
contentAlignment = Alignment.CenterStart
|
contentAlignment = Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
TextField(
|
TextField(
|
||||||
value = uiState.login,
|
value = uiState.fullName,
|
||||||
onValueChange = { viewModel.onLoginChange(it) },
|
onValueChange = { viewModel.onFullNameChange(it) },
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = "Введи логин",
|
text = "Введи полное имя",
|
||||||
fontSize = inputTextSize,
|
fontSize = inputTextSize,
|
||||||
color = Color.Gray.copy(alpha = 0.7f)
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
)
|
)
|
||||||
@@ -174,85 +168,114 @@ fun RegistrationScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Поле пароля
|
// Поле пароля
|
||||||
Box(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
.height(height = inputHeight)
|
|
||||||
.background(
|
|
||||||
color = LoginInputLightBlue,
|
|
||||||
shape = RoundedCornerShape(20.dp)
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.CenterStart
|
|
||||||
) {
|
) {
|
||||||
TextField(
|
Box(
|
||||||
value = uiState.password,
|
|
||||||
onValueChange = { viewModel.onPasswordChange(it) },
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
text = "Введи пароль",
|
|
||||||
fontSize = inputTextSize,
|
|
||||||
color = Color.Gray.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp),
|
.height(height = inputHeight)
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
.background(
|
||||||
colors = TextFieldDefaults.colors(
|
color = if (uiState.passwordError != null) {
|
||||||
unfocusedContainerColor = Color.Transparent,
|
Color(0xFFFFEBEE)
|
||||||
focusedContainerColor = Color.Transparent,
|
} else {
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
LoginInputLightBlue
|
||||||
focusedIndicatorColor = Color.Transparent,
|
},
|
||||||
unfocusedTextColor = Color.Black,
|
shape = RoundedCornerShape(20.dp)
|
||||||
focusedTextColor = Color.Black
|
),
|
||||||
),
|
contentAlignment = Alignment.CenterStart
|
||||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
) {
|
||||||
fontSize = inputTextSize
|
TextField(
|
||||||
),
|
value = uiState.password,
|
||||||
singleLine = true,
|
onValueChange = { viewModel.onPasswordChange(it) },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
placeholder = {
|
||||||
)
|
Text(
|
||||||
|
text = "Введи пароль",
|
||||||
|
fontSize = inputTextSize,
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedTextColor = Color.Black
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
isError = uiState.passwordError != null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.passwordError != null) {
|
||||||
|
Text(
|
||||||
|
text = uiState.passwordError!!,
|
||||||
|
fontSize = (inputTextSizeValue * 0.8f).toInt().sp,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Поле повторения пароля
|
// Поле повторения пароля
|
||||||
Box(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
.height(height = inputHeight)
|
|
||||||
.background(
|
|
||||||
color = LoginInputLightBlue,
|
|
||||||
shape = RoundedCornerShape(20.dp)
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.CenterStart
|
|
||||||
) {
|
) {
|
||||||
TextField(
|
Box(
|
||||||
value = uiState.confirmPassword,
|
|
||||||
onValueChange = { viewModel.onConfirmPasswordChange(it) },
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
text = "Повтори пароль",
|
|
||||||
fontSize = inputTextSize,
|
|
||||||
color = Color.Gray.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp),
|
.height(height = inputHeight)
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
.background(
|
||||||
colors = TextFieldDefaults.colors(
|
color = if (uiState.passwordError != null) {
|
||||||
unfocusedContainerColor = Color.Transparent,
|
Color(0xFFFFEBEE)
|
||||||
focusedContainerColor = Color.Transparent,
|
} else {
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
LoginInputLightBlue
|
||||||
focusedIndicatorColor = Color.Transparent,
|
},
|
||||||
unfocusedTextColor = Color.Black,
|
shape = RoundedCornerShape(20.dp)
|
||||||
focusedTextColor = Color.Black
|
),
|
||||||
),
|
contentAlignment = Alignment.CenterStart
|
||||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
) {
|
||||||
fontSize = inputTextSize
|
TextField(
|
||||||
),
|
value = uiState.confirmPassword,
|
||||||
singleLine = true,
|
onValueChange = { viewModel.onConfirmPasswordChange(it) },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
placeholder = {
|
||||||
)
|
Text(
|
||||||
|
text = "Повтори пароль",
|
||||||
|
fontSize = inputTextSize,
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedTextColor = Color.Black
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
isError = uiState.passwordError != null
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Поле email
|
// Поле email
|
||||||
@@ -315,76 +338,17 @@ fun RegistrationScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Поле кода (показывается после нажатия "Готово!" когда все 4 поля заполнены)
|
|
||||||
if (uiState.showCodeField) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(height = inputHeight)
|
|
||||||
.background(
|
|
||||||
color = LoginInputLightBlue,
|
|
||||||
shape = RoundedCornerShape(20.dp)
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.CenterStart
|
|
||||||
) {
|
|
||||||
TextField(
|
|
||||||
value = uiState.code,
|
|
||||||
onValueChange = { viewModel.onCodeChange(it) },
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
text = "Введи код из письма на почте",
|
|
||||||
fontSize = inputTextSize,
|
|
||||||
color = Color.Gray.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
unfocusedContainerColor = Color.Transparent,
|
|
||||||
focusedContainerColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedTextColor = Color.Black,
|
|
||||||
focusedTextColor = Color.Black
|
|
||||||
),
|
|
||||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
|
||||||
fontSize = inputTextSize
|
|
||||||
),
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кнопка "Готово!" - адаптивный размер
|
|
||||||
// Активна когда заполнены все 4 поля (до показа кода) или когда заполнен код
|
|
||||||
val isButtonEnabled = if (!uiState.showCodeField) {
|
|
||||||
uiState.isBasicFormValid && !uiState.isLoading
|
|
||||||
} else {
|
|
||||||
uiState.isFormValid && !uiState.isLoading
|
|
||||||
}
|
|
||||||
|
|
||||||
val buttonColor = if (isButtonEnabled) {
|
|
||||||
ButtonDefaults.buttonColors(
|
|
||||||
containerColor = LoginGreenAccent,
|
|
||||||
contentColor = Color.White
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ButtonDefaults.buttonColors(
|
|
||||||
containerColor = LoginInputLightBlue,
|
|
||||||
contentColor = Color.Gray,
|
|
||||||
disabledContainerColor = LoginInputLightBlue,
|
|
||||||
disabledContentColor = Color.Gray
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Кнопка "Зарегистрироваться" - адаптивный размер
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.onReadyClick {
|
if (uiState.isFormValid) {
|
||||||
navController.navigate("login") {
|
viewModel.register {
|
||||||
popUpTo("login") { inclusive = false }
|
// Переход на экран расписания после успешной регистрации
|
||||||
|
navController.navigate("schedule") {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -392,8 +356,20 @@ fun RegistrationScreen(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(height = inputHeight),
|
.height(height = inputHeight),
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = RoundedCornerShape(20.dp),
|
||||||
enabled = isButtonEnabled,
|
enabled = uiState.isFormValid && !uiState.isLoading,
|
||||||
colors = buttonColor
|
colors = if (uiState.isFormValid) {
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = LoginGreenAccent,
|
||||||
|
contentColor = Color.White
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = LoginInputLightBlue,
|
||||||
|
contentColor = Color.Gray,
|
||||||
|
disabledContainerColor = LoginInputLightBlue,
|
||||||
|
disabledContentColor = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
if (uiState.isLoading) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
@@ -403,15 +379,13 @@ fun RegistrationScreen(
|
|||||||
} else {
|
} else {
|
||||||
val buttonTextSize = (screenHeightDp * 0.027f).toInt().coerceIn(22, 34).sp
|
val buttonTextSize = (screenHeightDp * 0.027f).toInt().coerceIn(22, 34).sp
|
||||||
Text(
|
Text(
|
||||||
text = "Готово!",
|
text = "Зарегистрироваться",
|
||||||
fontSize = buttonTextSize,
|
fontSize = buttonTextSize,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(0.1f))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package com.novayaplaneta.ui.screens.auth
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.novayaplaneta.domain.usecase.RegisterUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -11,64 +11,74 @@ import kotlinx.coroutines.launch
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class RegistrationViewModel @Inject constructor() : ViewModel() {
|
class RegistrationViewModel @Inject constructor(
|
||||||
|
private val registerUseCase: RegisterUseCase
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(RegistrationUiState())
|
private val _uiState = MutableStateFlow(RegistrationUiState())
|
||||||
val uiState: StateFlow<RegistrationUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<RegistrationUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
fun onLoginChange(login: String) {
|
fun onFullNameChange(fullName: String) {
|
||||||
_uiState.value = _uiState.value.copy(login = login)
|
_uiState.value = _uiState.value.copy(
|
||||||
|
fullName = fullName,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onPasswordChange(password: String) {
|
fun onPasswordChange(password: String) {
|
||||||
_uiState.value = _uiState.value.copy(password = password)
|
_uiState.value = _uiState.value.copy(
|
||||||
|
password = password,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onConfirmPasswordChange(password: String) {
|
fun onConfirmPasswordChange(password: String) {
|
||||||
_uiState.value = _uiState.value.copy(confirmPassword = password)
|
_uiState.value = _uiState.value.copy(
|
||||||
|
confirmPassword = password,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onEmailChange(email: String) {
|
fun onEmailChange(email: String) {
|
||||||
_uiState.value = _uiState.value.copy(email = email)
|
_uiState.value = _uiState.value.copy(
|
||||||
|
email = email,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onCodeChange(code: String) {
|
fun register(onSuccess: () -> Unit) {
|
||||||
_uiState.value = _uiState.value.copy(code = code)
|
if (!_uiState.value.isFormValid) return
|
||||||
}
|
|
||||||
|
|
||||||
fun onReadyClick(onSuccess: () -> Unit) {
|
viewModelScope.launch {
|
||||||
val currentState = _uiState.value
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
// Если поле кода еще не показано, но все 4 поля заполнены - показываем поле кода
|
val result = registerUseCase(
|
||||||
if (!currentState.showCodeField && currentState.isBasicFormValid) {
|
email = _uiState.value.email,
|
||||||
_uiState.value = currentState.copy(showCodeField = true)
|
fullName = _uiState.value.fullName,
|
||||||
return
|
password = _uiState.value.password,
|
||||||
}
|
role = "CHILD"
|
||||||
|
)
|
||||||
|
|
||||||
// Если форма полностью валидна (включая код), выполняем регистрацию
|
result.onSuccess { user ->
|
||||||
if (currentState.isFormValid) {
|
_uiState.value = _uiState.value.copy(isLoading = false, isRegistered = true)
|
||||||
viewModelScope.launch {
|
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
|
||||||
|
|
||||||
// TODO: Реализовать вызов API для регистрации
|
|
||||||
// Пока что просто эмулируем успешную регистрацию
|
|
||||||
delay(1000)
|
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
|
||||||
onSuccess()
|
onSuccess()
|
||||||
|
}.onFailure { exception ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = exception.message ?: "Ошибка регистрации"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class RegistrationUiState(
|
data class RegistrationUiState(
|
||||||
val login: String = "",
|
val fullName: String = "",
|
||||||
val password: String = "",
|
val password: String = "",
|
||||||
val confirmPassword: String = "",
|
val confirmPassword: String = "",
|
||||||
val email: String = "",
|
val email: String = "",
|
||||||
val code: String = "",
|
|
||||||
val showCodeField: Boolean = false,
|
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
|
val isRegistered: Boolean = false,
|
||||||
val errorMessage: String? = null
|
val errorMessage: String? = null
|
||||||
) {
|
) {
|
||||||
// Валидация email
|
// Валидация email
|
||||||
@@ -81,17 +91,19 @@ data class RegistrationUiState(
|
|||||||
"Введите корректный email"
|
"Введите корректный email"
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
// Валидация первых 4 полей (без кода)
|
// Валидация паролей
|
||||||
val isBasicFormValid: Boolean
|
val passwordError: String?
|
||||||
get() = login.isNotBlank() &&
|
get() = if (confirmPassword.isNotBlank() && password != confirmPassword) {
|
||||||
|
"Пароли не совпадают"
|
||||||
|
} else null
|
||||||
|
|
||||||
|
// Валидация формы
|
||||||
|
val isFormValid: Boolean
|
||||||
|
get() = fullName.isNotBlank() &&
|
||||||
password.isNotBlank() &&
|
password.isNotBlank() &&
|
||||||
confirmPassword.isNotBlank() &&
|
confirmPassword.isNotBlank() &&
|
||||||
isEmailValid &&
|
isEmailValid &&
|
||||||
password == confirmPassword
|
password == confirmPassword &&
|
||||||
|
password.length >= 6
|
||||||
// Валидация полной формы (включая код)
|
|
||||||
val isFormValid: Boolean
|
|
||||||
get() = isBasicFormValid &&
|
|
||||||
code.isNotBlank()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.novayaplaneta.ui.screens.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.novayaplaneta.ui.theme.LoginBackgroundTurquoise
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SplashScreen(
|
||||||
|
navController: NavController,
|
||||||
|
viewModel: SplashViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.checkAuth { isAuthenticated ->
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navController.navigate("schedule") {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navController.navigate("login") {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(color = LoginBackgroundTurquoise),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.novayaplaneta.ui.screens.auth
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.novayaplaneta.data.local.TokenManager
|
||||||
|
import com.novayaplaneta.domain.usecase.GetMeUseCase
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SplashViewModel @Inject constructor(
|
||||||
|
private val tokenManager: TokenManager,
|
||||||
|
private val getMeUseCase: GetMeUseCase
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
fun checkAuth(onResult: (Boolean) -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val hasToken = tokenManager.isAuthenticated()
|
||||||
|
|
||||||
|
if (hasToken) {
|
||||||
|
// Пытаемся получить информацию о пользователе
|
||||||
|
val result = getMeUseCase()
|
||||||
|
if (result.isSuccess) {
|
||||||
|
onResult(true)
|
||||||
|
} else {
|
||||||
|
// Токен невалиден, очищаем и переходим на логин
|
||||||
|
tokenManager.clearTokens()
|
||||||
|
onResult(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onResult(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -7,7 +7,9 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
|||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.CalendarToday
|
import androidx.compose.material.icons.filled.CalendarToday
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
@@ -22,9 +24,11 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.novayaplaneta.ui.components.NovayaPlanetaLogo
|
import com.novayaplaneta.ui.components.NovayaPlanetaLogo
|
||||||
import com.novayaplaneta.ui.theme.*
|
import com.novayaplaneta.ui.theme.*
|
||||||
@@ -76,14 +80,14 @@ fun RewardsScreen(
|
|||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.padding(vertical = 20.dp, horizontal = 16.dp)
|
.padding(vertical = 20.dp, horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
// Логотип над панелью навигации (увеличен)
|
// Логотип над панелью навигации
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(bottom = 16.dp),
|
.padding(bottom = 16.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
val logoSize = (screenHeightDp * 0.18f).toInt().coerceIn(120, 200).dp
|
val logoSize = (screenHeightDp * 0.22f).toInt().coerceIn(140, 240).dp
|
||||||
NovayaPlanetaLogo(size = logoSize)
|
NovayaPlanetaLogo(size = logoSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,8 +181,26 @@ fun RewardsScreen(
|
|||||||
modifier = Modifier.padding(bottom = 24.dp)
|
modifier = Modifier.padding(bottom = 24.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Отображение ошибок
|
||||||
|
uiState.errorMessage?.let { error ->
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Список наград в сетке (несколько колонок)
|
// Список наград в сетке (несколько колонок)
|
||||||
if (uiState.isLoading) {
|
if (uiState.isLoading && uiState.rewards.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -195,13 +217,42 @@ fun RewardsScreen(
|
|||||||
items(uiState.rewards) { reward ->
|
items(uiState.rewards) { reward ->
|
||||||
RewardCard(
|
RewardCard(
|
||||||
reward = reward,
|
reward = reward,
|
||||||
screenHeightDp = screenHeightDp
|
screenHeightDp = screenHeightDp,
|
||||||
|
accentGreen = accentGreen,
|
||||||
|
onClaimClick = { viewModel.claimReward(reward.id) },
|
||||||
|
onDeleteClick = { viewModel.deleteReward(reward.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка добавления в конце списка
|
||||||
|
item {
|
||||||
|
AddRewardButton(
|
||||||
|
onClick = { viewModel.showAddDialog() },
|
||||||
|
screenHeightDp = screenHeightDp,
|
||||||
|
accentGreen = accentGreen
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Диалог добавления награды
|
||||||
|
if (uiState.showAddDialog) {
|
||||||
|
AddRewardDialog(
|
||||||
|
rewardTitle = uiState.newRewardTitle,
|
||||||
|
rewardDescription = uiState.newRewardDescription,
|
||||||
|
rewardPoints = uiState.newRewardPoints,
|
||||||
|
onTitleChange = { viewModel.updateNewRewardTitle(it) },
|
||||||
|
onDescriptionChange = { viewModel.updateNewRewardDescription(it) },
|
||||||
|
onPointsChange = { viewModel.updateNewRewardPoints(it) },
|
||||||
|
onAdd = { viewModel.createReward() },
|
||||||
|
onDismiss = { viewModel.hideAddDialog() },
|
||||||
|
accentGreen = accentGreen,
|
||||||
|
screenHeightDp = screenHeightDp,
|
||||||
|
isLoading = uiState.isLoading
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,16 +296,20 @@ fun NavItem(
|
|||||||
@Composable
|
@Composable
|
||||||
fun RewardCard(
|
fun RewardCard(
|
||||||
reward: com.novayaplaneta.domain.model.Reward,
|
reward: com.novayaplaneta.domain.model.Reward,
|
||||||
screenHeightDp: Int
|
screenHeightDp: Int,
|
||||||
|
accentGreen: Color,
|
||||||
|
onClaimClick: () -> Unit = {},
|
||||||
|
onDeleteClick: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val cardHeight = 180.dp
|
val cardHeight = 180.dp
|
||||||
|
val isClaimed = reward.isClaimed
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(cardHeight)
|
.height(cardHeight)
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.background(color = Color.LightGray)
|
.background(color = if (isClaimed) accentGreen.copy(alpha = 0.3f) else Color.LightGray)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
@@ -264,14 +319,14 @@ fun RewardCard(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(0.6f)
|
.weight(0.6f)
|
||||||
.background(color = Color.White.copy(alpha = 0.5f)),
|
.background(color = if (isClaimed) accentGreen.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.5f)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Star,
|
imageVector = Icons.Filled.Star,
|
||||||
contentDescription = reward.title,
|
contentDescription = reward.title,
|
||||||
modifier = Modifier.size(60.dp),
|
modifier = Modifier.size(60.dp),
|
||||||
tint = AccentGold
|
tint = if (isClaimed) accentGreen else AccentGold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,18 +335,250 @@ fun RewardCard(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(0.4f)
|
.weight(0.4f)
|
||||||
.background(color = Color.LightGray)
|
.background(color = if (isClaimed) accentGreen.copy(alpha = 0.3f) else Color.LightGray)
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp
|
Column(
|
||||||
Text(
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
text = reward.title,
|
) {
|
||||||
fontSize = textSize,
|
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp
|
||||||
fontWeight = FontWeight.Bold,
|
Text(
|
||||||
color = Color.Black,
|
text = reward.title,
|
||||||
textAlign = TextAlign.Center
|
fontSize = textSize,
|
||||||
)
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${reward.pointsRequired} очков",
|
||||||
|
fontSize = textSize * 0.7f,
|
||||||
|
color = Color.Black.copy(alpha = 0.7f),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
if (isClaimed) {
|
||||||
|
Text(
|
||||||
|
text = "Получено",
|
||||||
|
fontSize = textSize * 0.6f,
|
||||||
|
color = accentGreen,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
onClick = onClaimClick,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = accentGreen,
|
||||||
|
contentColor = Color.White
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Получить", fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddRewardButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
screenHeightDp: Int,
|
||||||
|
accentGreen: Color
|
||||||
|
) {
|
||||||
|
val cardHeight = 180.dp
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(cardHeight)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(color = accentGreen)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Add,
|
||||||
|
contentDescription = "Добавить награду",
|
||||||
|
modifier = Modifier.size(60.dp),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddRewardDialog(
|
||||||
|
rewardTitle: String,
|
||||||
|
rewardDescription: String,
|
||||||
|
rewardPoints: Int,
|
||||||
|
onTitleChange: (String) -> Unit,
|
||||||
|
onDescriptionChange: (String) -> Unit,
|
||||||
|
onPointsChange: (Int) -> Unit,
|
||||||
|
onAdd: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
accentGreen: Color,
|
||||||
|
screenHeightDp: Int,
|
||||||
|
isLoading: Boolean = false
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.8f)
|
||||||
|
.wrapContentHeight(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color.White
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок
|
||||||
|
val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp
|
||||||
|
Text(
|
||||||
|
text = "Добавить награду",
|
||||||
|
fontSize = titleSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле ввода названия награды
|
||||||
|
val inputTextSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 22).sp
|
||||||
|
OutlinedTextField(
|
||||||
|
value = rewardTitle,
|
||||||
|
onValueChange = onTitleChange,
|
||||||
|
label = { Text("Название") },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "Введите название награды",
|
||||||
|
fontSize = inputTextSize,
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isLoading,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = Color.Black,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
unfocusedBorderColor = Color.Gray
|
||||||
|
),
|
||||||
|
textStyle = androidx.compose.ui.text.TextStyle(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле ввода описания
|
||||||
|
OutlinedTextField(
|
||||||
|
value = rewardDescription,
|
||||||
|
onValueChange = onDescriptionChange,
|
||||||
|
label = { Text("Описание") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 2,
|
||||||
|
enabled = !isLoading,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = Color.Black,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
unfocusedBorderColor = Color.Gray
|
||||||
|
),
|
||||||
|
textStyle = androidx.compose.ui.text.TextStyle(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле ввода очков
|
||||||
|
OutlinedTextField(
|
||||||
|
value = rewardPoints.toString(),
|
||||||
|
onValueChange = {
|
||||||
|
it.toIntOrNull()?.let { points ->
|
||||||
|
if (points > 0) onPointsChange(points)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Очки") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = Color.Black,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
unfocusedBorderColor = Color.Gray
|
||||||
|
),
|
||||||
|
textStyle = androidx.compose.ui.text.TextStyle(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Кнопки внизу
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Кнопка "Назад"
|
||||||
|
Button(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color.LightGray,
|
||||||
|
contentColor = Color.Black
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
||||||
|
Text(
|
||||||
|
text = "Назад",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Добавить"
|
||||||
|
Button(
|
||||||
|
onClick = onAdd,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(56.dp),
|
||||||
|
enabled = rewardTitle.isNotBlank() && !isLoading,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = accentGreen,
|
||||||
|
contentColor = Color.White,
|
||||||
|
disabledContainerColor = Color.LightGray,
|
||||||
|
disabledContentColor = Color.Gray
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
||||||
|
Text(
|
||||||
|
text = "Добавить",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,127 +3,221 @@ package com.novayaplaneta.ui.screens.rewards
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.novayaplaneta.domain.model.Reward
|
import com.novayaplaneta.domain.model.Reward
|
||||||
import com.novayaplaneta.domain.repository.RewardRepository
|
import com.novayaplaneta.domain.repository.AuthRepository
|
||||||
|
import com.novayaplaneta.domain.usecase.ClaimRewardUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.CreateRewardUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.DeleteRewardUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.GetRewardsUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class RewardsViewModel @Inject constructor(
|
class RewardsViewModel @Inject constructor(
|
||||||
private val rewardRepository: RewardRepository
|
private val getRewardsUseCase: GetRewardsUseCase,
|
||||||
|
private val createRewardUseCase: CreateRewardUseCase,
|
||||||
|
private val deleteRewardUseCase: DeleteRewardUseCase,
|
||||||
|
private val claimRewardUseCase: ClaimRewardUseCase,
|
||||||
|
private val authRepository: AuthRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(RewardsUiState())
|
private val _uiState = MutableStateFlow(RewardsUiState())
|
||||||
val uiState: StateFlow<RewardsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<RewardsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private var currentUserId: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadDefaultRewards()
|
loadUserId()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadDefaultRewards() {
|
private fun loadUserId() {
|
||||||
// Создаем список наград по умолчанию
|
|
||||||
val defaultRewards = listOf(
|
|
||||||
Reward(
|
|
||||||
id = "reward_1",
|
|
||||||
title = "Золотая звезда",
|
|
||||||
description = "За выполнение 3 задач",
|
|
||||||
imageUrl = null,
|
|
||||||
points = 10,
|
|
||||||
earnedAt = null,
|
|
||||||
userId = "default"
|
|
||||||
),
|
|
||||||
Reward(
|
|
||||||
id = "reward_2",
|
|
||||||
title = "Подарочная коробка",
|
|
||||||
description = "За выполнение 5 задач",
|
|
||||||
imageUrl = null,
|
|
||||||
points = 20,
|
|
||||||
earnedAt = null,
|
|
||||||
userId = "default"
|
|
||||||
),
|
|
||||||
Reward(
|
|
||||||
id = "reward_3",
|
|
||||||
title = "Игрушка",
|
|
||||||
description = "За выполнение всех задач дня",
|
|
||||||
imageUrl = null,
|
|
||||||
points = 30,
|
|
||||||
earnedAt = null,
|
|
||||||
userId = "default"
|
|
||||||
),
|
|
||||||
Reward(
|
|
||||||
id = "reward_4",
|
|
||||||
title = "Мультфильм",
|
|
||||||
description = "За неделю без пропусков",
|
|
||||||
imageUrl = null,
|
|
||||||
points = 50,
|
|
||||||
earnedAt = null,
|
|
||||||
userId = "default"
|
|
||||||
),
|
|
||||||
Reward(
|
|
||||||
id = "reward_5",
|
|
||||||
title = "Поход в парк",
|
|
||||||
description = "За месяц работы",
|
|
||||||
imageUrl = null,
|
|
||||||
points = 100,
|
|
||||||
earnedAt = null,
|
|
||||||
userId = "default"
|
|
||||||
),
|
|
||||||
Reward(
|
|
||||||
id = "reward_6",
|
|
||||||
title = "Новая книга",
|
|
||||||
description = "За выполнение 10 задач",
|
|
||||||
imageUrl = null,
|
|
||||||
points = 40,
|
|
||||||
earnedAt = null,
|
|
||||||
userId = "default"
|
|
||||||
),
|
|
||||||
Reward(
|
|
||||||
id = "reward_7",
|
|
||||||
title = "Любимая игра",
|
|
||||||
description = "За хорошее поведение",
|
|
||||||
imageUrl = null,
|
|
||||||
points = 25,
|
|
||||||
earnedAt = null,
|
|
||||||
userId = "default"
|
|
||||||
),
|
|
||||||
Reward(
|
|
||||||
id = "reward_8",
|
|
||||||
title = "Специальный обед",
|
|
||||||
description = "За старание в учебе",
|
|
||||||
imageUrl = null,
|
|
||||||
points = 35,
|
|
||||||
earnedAt = null,
|
|
||||||
userId = "default"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(rewards = defaultRewards, isLoading = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadRewards(userId: String) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
try {
|
||||||
rewardRepository.getRewards(userId).collect { rewards ->
|
val user = authRepository.getCurrentUser().first()
|
||||||
|
currentUserId = user?.id
|
||||||
|
if (currentUserId != null) {
|
||||||
|
loadRewards()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
rewards = if (rewards.isEmpty()) _uiState.value.rewards else rewards,
|
errorMessage = e.message ?: "Ошибка загрузки пользователя"
|
||||||
isLoading = false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun earnReward(userId: String, rewardId: String) {
|
fun loadRewards(isClaimed: Boolean? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
rewardRepository.earnReward(userId, rewardId)
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val loadResult = getRewardsUseCase.loadRewards(skip = 0, limit = 100, isClaimed = isClaimed)
|
||||||
|
if (loadResult.isFailure) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = loadResult.exceptionOrNull()?.message ?: "Ошибка загрузки наград"
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val userId = currentUserId ?: return@launch
|
||||||
|
getRewardsUseCase(userId).collect { rewards ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
rewards = rewards,
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = e.message ?: "Ошибка загрузки наград"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun claimReward(rewardId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
// Оптимистичное обновление UI
|
||||||
|
val currentRewards = _uiState.value.rewards.toMutableList()
|
||||||
|
val rewardIndex = currentRewards.indexOfFirst { it.id == rewardId }
|
||||||
|
if (rewardIndex >= 0) {
|
||||||
|
val oldReward = currentRewards[rewardIndex]
|
||||||
|
if (oldReward.isClaimed) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = "Награда уже получена"
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
currentRewards[rewardIndex] = oldReward.copy(isClaimed = true)
|
||||||
|
_uiState.value = _uiState.value.copy(rewards = currentRewards)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = claimRewardUseCase(rewardId)
|
||||||
|
result.onSuccess {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
// Список обновится автоматически через Flow
|
||||||
|
}.onFailure { exception ->
|
||||||
|
// Откатываем изменение в случае ошибки
|
||||||
|
if (rewardIndex >= 0) {
|
||||||
|
val oldReward = currentRewards[rewardIndex]
|
||||||
|
currentRewards[rewardIndex] = oldReward.copy(isClaimed = false)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
rewards = currentRewards,
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = exception.message ?: "Ошибка получения награды"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showAddDialog() {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
showAddDialog = true,
|
||||||
|
newRewardTitle = "",
|
||||||
|
newRewardDescription = "",
|
||||||
|
newRewardPoints = 10
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideAddDialog() {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
showAddDialog = false,
|
||||||
|
newRewardTitle = "",
|
||||||
|
newRewardDescription = "",
|
||||||
|
newRewardPoints = 10
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewRewardTitle(title: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newRewardTitle = title)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewRewardDescription(description: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newRewardDescription = description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewRewardPoints(points: Int) {
|
||||||
|
_uiState.value = _uiState.value.copy(newRewardPoints = points)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createReward() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val state = _uiState.value
|
||||||
|
val userId = currentUserId ?: return@launch
|
||||||
|
|
||||||
|
if (state.newRewardTitle.isBlank()) return@launch
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
val newReward = Reward(
|
||||||
|
id = "", // Будет присвоен сервером
|
||||||
|
title = state.newRewardTitle,
|
||||||
|
description = state.newRewardDescription.takeIf { it.isNotBlank() },
|
||||||
|
imageUrl = null,
|
||||||
|
pointsRequired = state.newRewardPoints,
|
||||||
|
isClaimed = false,
|
||||||
|
earnedAt = null,
|
||||||
|
userId = userId
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = createRewardUseCase(newReward)
|
||||||
|
result.onSuccess {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
showAddDialog = false,
|
||||||
|
newRewardTitle = "",
|
||||||
|
newRewardDescription = "",
|
||||||
|
newRewardPoints = 10
|
||||||
|
)
|
||||||
|
// Список обновится автоматически через Flow
|
||||||
|
}.onFailure { exception ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = exception.message ?: "Ошибка создания награды"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteReward(rewardId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
val result = deleteRewardUseCase(rewardId)
|
||||||
|
result.onSuccess {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
// Список обновится автоматически через Flow
|
||||||
|
}.onFailure { exception ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = exception.message ?: "Ошибка удаления награды"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(errorMessage = null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class RewardsUiState(
|
data class RewardsUiState(
|
||||||
val rewards: List<Reward> = emptyList(),
|
val rewards: List<Reward> = emptyList(),
|
||||||
val isLoading: Boolean = false
|
val isLoading: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val showAddDialog: Boolean = false,
|
||||||
|
val newRewardTitle: String = "",
|
||||||
|
val newRewardDescription: String = "",
|
||||||
|
val newRewardPoints: Int = 10
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package com.novayaplaneta.ui.screens.schedule
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
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
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GenerateScheduleDialog(
|
||||||
|
childAge: Int,
|
||||||
|
preferences: List<String>,
|
||||||
|
date: String,
|
||||||
|
description: String,
|
||||||
|
onChildAgeChange: (Int) -> Unit,
|
||||||
|
onPreferencesChange: (List<String>) -> Unit,
|
||||||
|
onDateChange: (String) -> Unit,
|
||||||
|
onDescriptionChange: (String) -> Unit,
|
||||||
|
onGenerate: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
accentGreen: Color,
|
||||||
|
screenHeightDp: Int,
|
||||||
|
isLoading: Boolean
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.9f)
|
||||||
|
.wrapContentHeight(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color.White
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок
|
||||||
|
val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp
|
||||||
|
Text(
|
||||||
|
text = "Сгенерировать расписание через ИИ",
|
||||||
|
fontSize = titleSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле возраста ребенка
|
||||||
|
OutlinedTextField(
|
||||||
|
value = childAge.toString(),
|
||||||
|
onValueChange = {
|
||||||
|
it.toIntOrNull()?.let { age ->
|
||||||
|
if (age > 0 && age <= 18) onChildAgeChange(age)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Возраст ребенка") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле даты
|
||||||
|
OutlinedTextField(
|
||||||
|
value = date,
|
||||||
|
onValueChange = onDateChange,
|
||||||
|
label = { Text("Дата (yyyy-MM-dd)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading,
|
||||||
|
placeholder = { Text("2025-12-25") }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле описания/предпочтений
|
||||||
|
OutlinedTextField(
|
||||||
|
value = description,
|
||||||
|
onValueChange = onDescriptionChange,
|
||||||
|
label = { Text("Описание/предпочтения") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 3,
|
||||||
|
enabled = !isLoading,
|
||||||
|
placeholder = { Text("Опишите предпочтения ребенка...") }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Кнопки внизу
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Кнопка "Отмена"
|
||||||
|
Button(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color.LightGray,
|
||||||
|
contentColor = Color.Black
|
||||||
|
),
|
||||||
|
enabled = !isLoading
|
||||||
|
) {
|
||||||
|
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
||||||
|
Text(
|
||||||
|
text = "Отмена",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Сгенерировать"
|
||||||
|
Button(
|
||||||
|
onClick = onGenerate,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(56.dp),
|
||||||
|
enabled = date.isNotBlank() && !isLoading,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = accentGreen,
|
||||||
|
contentColor = Color.White,
|
||||||
|
disabledContainerColor = Color.LightGray,
|
||||||
|
disabledContentColor = Color.Gray
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
||||||
|
Text(
|
||||||
|
text = "Сгенерировать",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.novayaplaneta.ui.screens.schedule
|
package com.novayaplaneta.ui.screens.schedule
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -12,6 +13,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.CalendarToday
|
import androidx.compose.material.icons.filled.CalendarToday
|
||||||
|
import androidx.compose.material.icons.filled.SmartToy
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
@@ -19,6 +22,9 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -80,14 +86,14 @@ fun ScheduleScreen(
|
|||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.padding(vertical = 20.dp, horizontal = 16.dp)
|
.padding(vertical = 20.dp, horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
// Логотип над панелью навигации (увеличен)
|
// Логотип над панелью навигации
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(bottom = 16.dp),
|
.padding(bottom = 16.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
val logoSize = (screenHeightDp * 0.18f).toInt().coerceIn(120, 200).dp
|
val logoSize = (screenHeightDp * 0.22f).toInt().coerceIn(140, 240).dp
|
||||||
NovayaPlanetaLogo(size = logoSize)
|
NovayaPlanetaLogo(size = logoSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,28 +177,100 @@ fun ScheduleScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(48.dp))
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
|
||||||
// Основной контент: только сегодняшняя дата (опущена ниже)
|
// Основной контент: список расписаний
|
||||||
DateSection(
|
if (uiState.isLoading) {
|
||||||
date = dateOnly,
|
Box(
|
||||||
dayOfWeek = dayOfWeek,
|
modifier = Modifier.fillMaxSize(),
|
||||||
dateCardColor = dateCardColor,
|
contentAlignment = Alignment.Center
|
||||||
accentGreen = accentGreen,
|
) {
|
||||||
screenHeightDp = screenHeightDp,
|
CircularProgressIndicator(color = accentGreen)
|
||||||
tasks = uiState.tasks,
|
}
|
||||||
onAddClick = { viewModel.showAddDialog() }
|
} else if (uiState.errorMessage != null) {
|
||||||
)
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Ошибка",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = uiState.errorMessage ?: "Неизвестная ошибка",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.loadSchedules() },
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = accentGreen)
|
||||||
|
) {
|
||||||
|
Text("Повторить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SchedulesListSection(
|
||||||
|
schedules = uiState.schedules,
|
||||||
|
availableRewards = uiState.availableRewards,
|
||||||
|
dateOnly = dateOnly,
|
||||||
|
dayOfWeek = dayOfWeek,
|
||||||
|
dateCardColor = dateCardColor,
|
||||||
|
accentGreen = accentGreen,
|
||||||
|
screenHeightDp = screenHeightDp,
|
||||||
|
onAddClick = { viewModel.showAddDialog() },
|
||||||
|
onGenerateClick = { viewModel.showGenerateDialog() },
|
||||||
|
onScheduleClick = { scheduleId ->
|
||||||
|
navController?.navigate("tasks/$scheduleId")
|
||||||
|
},
|
||||||
|
onDeleteClick = { scheduleId ->
|
||||||
|
viewModel.deleteSchedule(scheduleId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Диалог выбора задачи
|
// Диалог создания расписания
|
||||||
if (uiState.showAddDialog) {
|
if (uiState.showAddDialog) {
|
||||||
AddTaskDialog(
|
CreateScheduleDialog(
|
||||||
selectedTaskType = uiState.selectedTaskType,
|
title = uiState.newScheduleTitle,
|
||||||
onTaskTypeSelected = { viewModel.selectTaskType(it) },
|
description = uiState.newScheduleDescription,
|
||||||
onSelect = { viewModel.addTask() },
|
date = uiState.newScheduleDate,
|
||||||
|
selectedRewardId = uiState.selectedRewardId,
|
||||||
|
availableRewards = uiState.availableRewards,
|
||||||
|
onTitleChange = { viewModel.updateNewScheduleTitle(it) },
|
||||||
|
onDescriptionChange = { viewModel.updateNewScheduleDescription(it) },
|
||||||
|
onDateChange = { viewModel.updateNewScheduleDate(it) },
|
||||||
|
onRewardSelected = { viewModel.updateSelectedReward(it) },
|
||||||
|
onCreate = { viewModel.createScheduleFromDialog() },
|
||||||
onDismiss = { viewModel.hideAddDialog() },
|
onDismiss = { viewModel.hideAddDialog() },
|
||||||
accentGreen = accentGreen,
|
accentGreen = accentGreen,
|
||||||
screenHeightDp = screenHeightDp
|
screenHeightDp = screenHeightDp,
|
||||||
|
isLoading = uiState.isLoading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог генерации расписания через ИИ
|
||||||
|
if (uiState.showGenerateDialog) {
|
||||||
|
GenerateScheduleDialog(
|
||||||
|
childAge = uiState.generateChildAge,
|
||||||
|
preferences = uiState.generatePreferences,
|
||||||
|
date = uiState.generateDate,
|
||||||
|
description = uiState.generateDescription,
|
||||||
|
onChildAgeChange = { viewModel.updateGenerateChildAge(it) },
|
||||||
|
onPreferencesChange = { viewModel.updateGeneratePreferences(it) },
|
||||||
|
onDateChange = { viewModel.updateGenerateDate(it) },
|
||||||
|
onDescriptionChange = { viewModel.updateGenerateDescription(it) },
|
||||||
|
onGenerate = { viewModel.generateScheduleWithAI() },
|
||||||
|
onDismiss = { viewModel.hideGenerateDialog() },
|
||||||
|
accentGreen = accentGreen,
|
||||||
|
screenHeightDp = screenHeightDp,
|
||||||
|
isLoading = uiState.isLoading
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,19 +314,23 @@ fun NavItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DateSection(
|
fun SchedulesListSection(
|
||||||
date: String,
|
schedules: List<com.novayaplaneta.domain.model.Schedule>,
|
||||||
|
availableRewards: List<com.novayaplaneta.domain.model.Reward>,
|
||||||
|
dateOnly: String,
|
||||||
dayOfWeek: String,
|
dayOfWeek: String,
|
||||||
dateCardColor: Color,
|
dateCardColor: Color,
|
||||||
accentGreen: Color,
|
accentGreen: Color,
|
||||||
screenHeightDp: Int,
|
screenHeightDp: Int,
|
||||||
tasks: List<TaskType>,
|
onAddClick: () -> Unit,
|
||||||
onAddClick: () -> Unit
|
onGenerateClick: () -> Unit,
|
||||||
|
onScheduleClick: (String) -> Unit,
|
||||||
|
onDeleteClick: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// Дата и кнопка + в одной строке
|
// Дата и кнопки в одной строке
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
@@ -267,7 +349,7 @@ fun DateSection(
|
|||||||
horizontalAlignment = Alignment.Start
|
horizontalAlignment = Alignment.Start
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = date,
|
text = dateOnly,
|
||||||
fontSize = dateTextSize,
|
fontSize = dateTextSize,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = Color.Black
|
color = Color.Black
|
||||||
@@ -281,6 +363,20 @@ fun DateSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Кнопка генерации через ИИ (синяя круглая)
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = onGenerateClick,
|
||||||
|
modifier = Modifier.size(80.dp),
|
||||||
|
containerColor = Color(0xFF2196F3),
|
||||||
|
contentColor = Color.White
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SmartToy,
|
||||||
|
contentDescription = "Сгенерировать через ИИ",
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Кнопка добавления (зеленая круглая с плюсом)
|
// Кнопка добавления (зеленая круглая с плюсом)
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = onAddClick,
|
onClick = onAddClick,
|
||||||
@@ -296,16 +392,37 @@ fun DateSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Задачи в ряд
|
// Список расписаний
|
||||||
if (tasks.isNotEmpty()) {
|
if (schedules.isEmpty()) {
|
||||||
LazyRow(
|
Box(
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 48.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Нет расписаний",
|
||||||
|
fontSize = 20.sp,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
items(tasks) { task ->
|
items(schedules) { schedule ->
|
||||||
TaskCard(
|
val reward = schedule.rewardId?.let { rewardId ->
|
||||||
taskType = task,
|
availableRewards.find { it.id == rewardId }
|
||||||
screenHeightDp = screenHeightDp
|
}
|
||||||
|
ScheduleCard(
|
||||||
|
schedule = schedule,
|
||||||
|
reward = reward,
|
||||||
|
screenHeightDp = screenHeightDp,
|
||||||
|
accentGreen = accentGreen,
|
||||||
|
dateCardColor = dateCardColor,
|
||||||
|
onClick = { onScheduleClick(schedule.id) },
|
||||||
|
onDeleteClick = { onDeleteClick(schedule.id) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,66 +431,131 @@ fun DateSection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TaskCard(
|
fun ScheduleCard(
|
||||||
taskType: TaskType,
|
schedule: com.novayaplaneta.domain.model.Schedule,
|
||||||
screenHeightDp: Int
|
reward: com.novayaplaneta.domain.model.Reward?,
|
||||||
|
screenHeightDp: Int,
|
||||||
|
accentGreen: Color,
|
||||||
|
dateCardColor: Color,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onDeleteClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val cardWidth = 200.dp
|
val dateFormatter = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||||
val cardHeight = 180.dp
|
val scheduleDate = schedule.date.toLocalDate().format(dateFormatter)
|
||||||
|
|
||||||
Box(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(cardWidth)
|
.fillMaxWidth()
|
||||||
.height(cardHeight)
|
.clickable { onClick() },
|
||||||
.clip(RoundedCornerShape(16.dp))
|
shape = RoundedCornerShape(16.dp),
|
||||||
.background(color = Color.LightGray)
|
colors = CardDefaults.cardColors(containerColor = dateCardColor)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
// Верхняя часть (для изображения)
|
Row(
|
||||||
Box(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
.fillMaxWidth()
|
verticalAlignment = Alignment.CenterVertically
|
||||||
.weight(0.6f)
|
|
||||||
.background(color = Color.White.copy(alpha = 0.5f))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Нижняя часть (для текста)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(0.4f)
|
|
||||||
.background(color = Color.LightGray)
|
|
||||||
.padding(12.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = schedule.title,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = scheduleDate,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = accentGreen,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDeleteClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = "Удалить",
|
||||||
|
tint = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schedule.description.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = taskType.title,
|
text = schedule.description ?: "",
|
||||||
fontSize = textSize,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
color = Color.Black
|
||||||
color = Color.Black,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (schedule.tasks.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Задач: ${schedule.tasks.size}",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображение награды
|
||||||
|
reward?.let {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color(0xFFFFD700).copy(alpha = 0.3f)) // Золотистый цвет для награды
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Star,
|
||||||
|
contentDescription = "Награда",
|
||||||
|
tint = Color(0xFFFFB300), // Не сильно оранжевый, золотистый
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = it.title,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFFFF8C00) // Не сильно оранжевый
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AddTaskDialog(
|
fun CreateScheduleDialog(
|
||||||
selectedTaskType: TaskType?,
|
title: String,
|
||||||
onTaskTypeSelected: (TaskType) -> Unit,
|
description: String,
|
||||||
onSelect: () -> Unit,
|
date: java.time.LocalDate?,
|
||||||
|
selectedRewardId: String?,
|
||||||
|
availableRewards: List<com.novayaplaneta.domain.model.Reward>,
|
||||||
|
onTitleChange: (String) -> Unit,
|
||||||
|
onDescriptionChange: (String) -> Unit,
|
||||||
|
onDateChange: (java.time.LocalDate) -> Unit,
|
||||||
|
onRewardSelected: (String?) -> Unit,
|
||||||
|
onCreate: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
accentGreen: Color,
|
accentGreen: Color,
|
||||||
screenHeightDp: Int
|
screenHeightDp: Int,
|
||||||
|
isLoading: Boolean
|
||||||
) {
|
) {
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(0.8f)
|
.fillMaxWidth(0.9f)
|
||||||
.wrapContentHeight(),
|
.wrapContentHeight(),
|
||||||
shape = RoundedCornerShape(24.dp),
|
shape = RoundedCornerShape(24.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
@@ -385,39 +567,104 @@ fun AddTaskDialog(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// Заголовок
|
// Заголовок
|
||||||
val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp
|
val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp
|
||||||
Text(
|
Text(
|
||||||
text = "Выберите задачу",
|
text = "Создать расписание",
|
||||||
fontSize = titleSize,
|
fontSize = titleSize,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = Color.Black
|
color = Color.Black
|
||||||
)
|
)
|
||||||
|
|
||||||
// Опции выбора
|
// Поле названия
|
||||||
Column(
|
OutlinedTextField(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
value = title,
|
||||||
modifier = Modifier.fillMaxWidth()
|
onValueChange = onTitleChange,
|
||||||
) {
|
label = { Text("Название") },
|
||||||
// Подарок
|
modifier = Modifier.fillMaxWidth(),
|
||||||
TaskOption(
|
enabled = !isLoading
|
||||||
taskType = TaskType.Gift,
|
)
|
||||||
isSelected = selectedTaskType == TaskType.Gift,
|
|
||||||
onClick = { onTaskTypeSelected(TaskType.Gift) },
|
|
||||||
accentGreen = accentGreen,
|
|
||||||
screenHeightDp = screenHeightDp
|
|
||||||
)
|
|
||||||
|
|
||||||
// Кушать ложкой
|
// Поле описания
|
||||||
TaskOption(
|
OutlinedTextField(
|
||||||
taskType = TaskType.EatWithSpoon,
|
value = description,
|
||||||
isSelected = selectedTaskType == TaskType.EatWithSpoon,
|
onValueChange = onDescriptionChange,
|
||||||
onClick = { onTaskTypeSelected(TaskType.EatWithSpoon) },
|
label = { Text("Описание") },
|
||||||
accentGreen = accentGreen,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
screenHeightDp = screenHeightDp
|
maxLines = 3,
|
||||||
)
|
enabled = !isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
// Выбор награды
|
||||||
|
if (availableRewards.isNotEmpty()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Награда (опционально)",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
|
||||||
|
// Выпадающий список наград
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = !expanded },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = availableRewards.find { it.id == selectedRewardId }?.title ?: "Не выбрано",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor()
|
||||||
|
.fillMaxWidth(),
|
||||||
|
enabled = !isLoading,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
focusedLabelColor = accentGreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
// Опция "Не выбрано"
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Не выбрано") },
|
||||||
|
onClick = {
|
||||||
|
onRewardSelected(null)
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
availableRewards.forEach { reward ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(reward.title, fontWeight = FontWeight.Bold)
|
||||||
|
reward.description?.let {
|
||||||
|
Text(it, fontSize = 12.sp, color = Color.Gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onRewardSelected(reward.id)
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кнопки внизу
|
// Кнопки внизу
|
||||||
@@ -425,7 +672,7 @@ fun AddTaskDialog(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// Кнопка "Назад"
|
// Кнопка "Отмена"
|
||||||
Button(
|
Button(
|
||||||
onClick = onDismiss,
|
onClick = onDismiss,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -435,23 +682,24 @@ fun AddTaskDialog(
|
|||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = Color.LightGray,
|
containerColor = Color.LightGray,
|
||||||
contentColor = Color.Black
|
contentColor = Color.Black
|
||||||
)
|
),
|
||||||
|
enabled = !isLoading
|
||||||
) {
|
) {
|
||||||
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
||||||
Text(
|
Text(
|
||||||
text = "Назад",
|
text = "Отмена",
|
||||||
fontSize = buttonTextSize,
|
fontSize = buttonTextSize,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кнопка "Выбрать"
|
// Кнопка "Создать"
|
||||||
Button(
|
Button(
|
||||||
onClick = onSelect,
|
onClick = onCreate,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(56.dp),
|
.height(56.dp),
|
||||||
enabled = selectedTaskType != null,
|
enabled = title.isNotBlank() && !isLoading,
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = accentGreen,
|
containerColor = accentGreen,
|
||||||
@@ -460,12 +708,19 @@ fun AddTaskDialog(
|
|||||||
disabledContentColor = Color.Gray
|
disabledContentColor = Color.Gray
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
if (isLoading) {
|
||||||
Text(
|
CircularProgressIndicator(
|
||||||
text = "Выбрать",
|
modifier = Modifier.size(24.dp),
|
||||||
fontSize = buttonTextSize,
|
color = Color.White
|
||||||
fontWeight = FontWeight.Bold
|
)
|
||||||
)
|
} else {
|
||||||
|
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
||||||
|
Text(
|
||||||
|
text = "Создать",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -473,34 +728,4 @@ fun AddTaskDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TaskOption(
|
|
||||||
taskType: TaskType,
|
|
||||||
isSelected: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
accentGreen: Color,
|
|
||||||
screenHeightDp: Int
|
|
||||||
) {
|
|
||||||
val borderWidth = if (isSelected) 4.dp else 2.dp
|
|
||||||
val borderColor = if (isSelected) accentGreen else Color.Gray
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(120.dp)
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.border(borderWidth, borderColor, RoundedCornerShape(16.dp))
|
|
||||||
.background(color = if (isSelected) accentGreen.copy(alpha = 0.1f) else Color.Transparent)
|
|
||||||
.clickable { onClick() },
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
val textSize = (screenHeightDp * 0.025f).toInt().coerceIn(20, 28).sp
|
|
||||||
Text(
|
|
||||||
text = taskType.title,
|
|
||||||
fontSize = textSize,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = if (isSelected) accentGreen else Color.Black
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,22 @@ package com.novayaplaneta.ui.screens.schedule
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.novayaplaneta.domain.model.Reward
|
||||||
|
import com.novayaplaneta.domain.model.Schedule
|
||||||
|
import com.novayaplaneta.domain.repository.AuthRepository
|
||||||
|
import com.novayaplaneta.domain.usecase.CreateScheduleUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.DeleteScheduleUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.GenerateScheduleUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.GetRewardsUseCase
|
||||||
import com.novayaplaneta.domain.usecase.GetSchedulesUseCase
|
import com.novayaplaneta.domain.usecase.GetSchedulesUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
sealed class TaskType(val title: String) {
|
sealed class TaskType(val title: String) {
|
||||||
@@ -17,52 +27,277 @@ sealed class TaskType(val title: String) {
|
|||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ScheduleViewModel @Inject constructor(
|
class ScheduleViewModel @Inject constructor(
|
||||||
private val getSchedulesUseCase: GetSchedulesUseCase
|
private val getSchedulesUseCase: GetSchedulesUseCase,
|
||||||
|
private val createScheduleUseCase: CreateScheduleUseCase,
|
||||||
|
private val deleteScheduleUseCase: DeleteScheduleUseCase,
|
||||||
|
private val generateScheduleUseCase: GenerateScheduleUseCase,
|
||||||
|
private val getRewardsUseCase: GetRewardsUseCase,
|
||||||
|
private val authRepository: AuthRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ScheduleUiState())
|
private val _uiState = MutableStateFlow(ScheduleUiState())
|
||||||
val uiState: StateFlow<ScheduleUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ScheduleUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
fun loadSchedules(userId: String) {
|
init {
|
||||||
|
loadSchedules()
|
||||||
|
loadRewards()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadRewards() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
try {
|
||||||
getSchedulesUseCase(userId).collect { schedules ->
|
val user = authRepository.getCurrentUser().first()
|
||||||
|
val userId = user?.id ?: return@launch
|
||||||
|
|
||||||
|
val loadResult = getRewardsUseCase.loadRewards()
|
||||||
|
if (loadResult.isSuccess) {
|
||||||
|
getRewardsUseCase(userId).collect { rewards ->
|
||||||
|
_uiState.value = _uiState.value.copy(availableRewards = rewards)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Игнорируем ошибки загрузки наград
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadSchedules(scheduleDate: String? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Получаем текущего пользователя
|
||||||
|
val user = authRepository.getCurrentUser().first()
|
||||||
|
val userId = user?.id ?: ""
|
||||||
|
|
||||||
|
// Загружаем расписания с сервера
|
||||||
|
val loadResult = getSchedulesUseCase.loadSchedules(scheduleDate)
|
||||||
|
if (loadResult.isFailure) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = loadResult.exceptionOrNull()?.message ?: "Ошибка загрузки расписаний"
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем актуальный список расписаний из Flow
|
||||||
|
val schedules = getSchedulesUseCase(userId).first()
|
||||||
|
|
||||||
|
// Фильтруем расписания по дате, если указана
|
||||||
|
val filteredSchedules = if (scheduleDate != null) {
|
||||||
|
schedules.filter { schedule ->
|
||||||
|
val scheduleDateStr = schedule.date.toLocalDate().format(DateTimeFormatter.ISO_DATE)
|
||||||
|
scheduleDateStr == scheduleDate
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
schedules
|
||||||
|
}
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
schedules = schedules,
|
schedules = filteredSchedules,
|
||||||
isLoading = false
|
isLoading = false,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = e.message ?: "Ошибка загрузки расписаний"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showAddDialog() {
|
fun showAddDialog() {
|
||||||
_uiState.value = _uiState.value.copy(showAddDialog = true, selectedTaskType = null)
|
_uiState.value = _uiState.value.copy(
|
||||||
|
showAddDialog = true,
|
||||||
|
newScheduleTitle = "",
|
||||||
|
newScheduleDescription = "",
|
||||||
|
newScheduleDate = LocalDate.now(),
|
||||||
|
selectedRewardId = null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hideAddDialog() {
|
fun hideAddDialog() {
|
||||||
_uiState.value = _uiState.value.copy(showAddDialog = false, selectedTaskType = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectTaskType(taskType: TaskType) {
|
|
||||||
_uiState.value = _uiState.value.copy(selectedTaskType = taskType)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addTask() {
|
|
||||||
val selected = _uiState.value.selectedTaskType ?: return
|
|
||||||
val newTasks = _uiState.value.tasks + selected
|
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
tasks = newTasks,
|
|
||||||
showAddDialog = false,
|
showAddDialog = false,
|
||||||
selectedTaskType = null
|
newScheduleTitle = "",
|
||||||
|
newScheduleDescription = "",
|
||||||
|
newScheduleDate = null,
|
||||||
|
selectedRewardId = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateSelectedReward(rewardId: String?) {
|
||||||
|
_uiState.value = _uiState.value.copy(selectedRewardId = rewardId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewScheduleTitle(title: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newScheduleTitle = title)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewScheduleDescription(description: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newScheduleDescription = description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewScheduleDate(date: LocalDate) {
|
||||||
|
_uiState.value = _uiState.value.copy(newScheduleDate = date)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createScheduleFromDialog() {
|
||||||
|
val state = _uiState.value
|
||||||
|
if (state.newScheduleTitle.isNotBlank() && state.newScheduleDate != null) {
|
||||||
|
createSchedule(
|
||||||
|
title = state.newScheduleTitle,
|
||||||
|
description = state.newScheduleDescription,
|
||||||
|
date = state.newScheduleDate,
|
||||||
|
rewardId = state.selectedRewardId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createSchedule(title: String, description: String, date: LocalDate, rewardId: String? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val user = authRepository.getCurrentUser().first()
|
||||||
|
val userId = user?.id ?: ""
|
||||||
|
|
||||||
|
val schedule = Schedule(
|
||||||
|
id = "", // Будет присвоен сервером
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
tasks = emptyList(),
|
||||||
|
date = date.atStartOfDay(),
|
||||||
|
createdAt = java.time.LocalDateTime.now(),
|
||||||
|
userId = userId,
|
||||||
|
rewardId = rewardId
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = createScheduleUseCase(schedule)
|
||||||
|
result.onSuccess {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
showAddDialog = false
|
||||||
|
)
|
||||||
|
// Перезагружаем расписания
|
||||||
|
loadSchedules()
|
||||||
|
}.onFailure { exception ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = exception.message ?: "Ошибка создания расписания"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = e.message ?: "Ошибка создания расписания"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSchedule(scheduleId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
val result = deleteScheduleUseCase(scheduleId)
|
||||||
|
result.onSuccess {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
// Перезагружаем расписания
|
||||||
|
loadSchedules()
|
||||||
|
}.onFailure { exception ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = exception.message ?: "Ошибка удаления расписания"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showGenerateDialog() {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
showGenerateDialog = true,
|
||||||
|
generateChildAge = 5,
|
||||||
|
generatePreferences = emptyList(),
|
||||||
|
generateDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE),
|
||||||
|
generateDescription = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideGenerateDialog() {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
showGenerateDialog = false,
|
||||||
|
generateChildAge = 5,
|
||||||
|
generatePreferences = emptyList(),
|
||||||
|
generateDate = "",
|
||||||
|
generateDescription = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateGenerateChildAge(age: Int) {
|
||||||
|
_uiState.value = _uiState.value.copy(generateChildAge = age)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateGeneratePreferences(preferences: List<String>) {
|
||||||
|
_uiState.value = _uiState.value.copy(generatePreferences = preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateGenerateDate(date: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(generateDate = date)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateGenerateDescription(description: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(generateDescription = description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateScheduleWithAI() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val state = _uiState.value
|
||||||
|
if (state.generateDate.isNotBlank()) {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
val result = generateScheduleUseCase(
|
||||||
|
childAge = state.generateChildAge,
|
||||||
|
preferences = state.generatePreferences,
|
||||||
|
date = state.generateDate,
|
||||||
|
description = state.generateDescription
|
||||||
|
)
|
||||||
|
|
||||||
|
result.onSuccess { generateResult ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
showGenerateDialog = false,
|
||||||
|
generatedScheduleId = generateResult.scheduleId
|
||||||
|
)
|
||||||
|
// Перезагружаем расписания, чтобы показать новое
|
||||||
|
loadSchedules()
|
||||||
|
}.onFailure { exception ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = exception.message ?: "Ошибка генерации расписания"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ScheduleUiState(
|
data class ScheduleUiState(
|
||||||
val schedules: List<com.novayaplaneta.domain.model.Schedule> = emptyList(),
|
val schedules: List<Schedule> = emptyList(),
|
||||||
val tasks: List<TaskType> = emptyList(),
|
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
val showAddDialog: Boolean = false,
|
val showAddDialog: Boolean = false,
|
||||||
val selectedTaskType: TaskType? = null
|
val newScheduleTitle: String = "",
|
||||||
|
val newScheduleDescription: String = "",
|
||||||
|
val newScheduleDate: LocalDate? = null,
|
||||||
|
val selectedRewardId: String? = null,
|
||||||
|
val availableRewards: List<Reward> = emptyList(),
|
||||||
|
val showGenerateDialog: Boolean = false,
|
||||||
|
val generateChildAge: Int = 5,
|
||||||
|
val generatePreferences: List<String> = emptyList(),
|
||||||
|
val generateDate: String = "",
|
||||||
|
val generateDescription: String = "",
|
||||||
|
val generatedScheduleId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -81,14 +81,14 @@ fun SettingsScreen(
|
|||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.padding(vertical = 20.dp, horizontal = 16.dp)
|
.padding(vertical = 20.dp, horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
// Логотип над панелью навигации (увеличен)
|
// Логотип над панелью навигации
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(bottom = 16.dp),
|
.padding(bottom = 16.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
val logoSize = (screenHeightDp * 0.18f).toInt().coerceIn(120, 200).dp
|
val logoSize = (screenHeightDp * 0.22f).toInt().coerceIn(140, 240).dp
|
||||||
NovayaPlanetaLogo(size = logoSize)
|
NovayaPlanetaLogo(size = logoSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,12 +210,12 @@ fun SettingsScreen(
|
|||||||
val textSize = (screenHeightDp * 0.025f).toInt().coerceIn(20, 28).sp
|
val textSize = (screenHeightDp * 0.025f).toInt().coerceIn(20, 28).sp
|
||||||
UserInfoRow(
|
UserInfoRow(
|
||||||
label = "Имя:",
|
label = "Имя:",
|
||||||
value = user.name,
|
value = user.fullName,
|
||||||
textSize = textSize
|
textSize = textSize
|
||||||
)
|
)
|
||||||
UserInfoRow(
|
UserInfoRow(
|
||||||
label = "Логин:",
|
label = "Логин:",
|
||||||
value = "${user.name}12", // Используем имя + число как логин
|
value = "${user.fullName}12", // Используем имя + число как логин
|
||||||
textSize = textSize
|
textSize = textSize
|
||||||
)
|
)
|
||||||
UserInfoRow(
|
UserInfoRow(
|
||||||
|
|||||||
@@ -48,10 +48,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
// Заглушка с тестовыми данными
|
// Заглушка с тестовыми данными
|
||||||
return User(
|
return User(
|
||||||
id = "user_123",
|
id = "user_123",
|
||||||
name = "Коля",
|
fullName = "Коля",
|
||||||
email = "kolya12@mail.ru",
|
email = "kolya12@mail.ru",
|
||||||
role = UserRole.CHILD,
|
role = UserRole.CHILD
|
||||||
token = null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +1,443 @@
|
|||||||
package com.novayaplaneta.ui.screens.task
|
package com.novayaplaneta.ui.screens.task
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.Canvas
|
||||||
|
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.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.novayaplaneta.ui.theme.LoginBackgroundTurquoise
|
||||||
|
import com.novayaplaneta.ui.theme.LoginGreenAccent
|
||||||
|
import com.novayaplaneta.ui.theme.LoginInputLightBlue
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TaskScreen(
|
fun TaskScreen(
|
||||||
|
scheduleId: String? = null,
|
||||||
|
navController: NavController? = null,
|
||||||
viewModel: TaskViewModel = hiltViewModel(),
|
viewModel: TaskViewModel = hiltViewModel(),
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val screenHeightDp = configuration.screenHeightDp
|
||||||
|
|
||||||
Scaffold(
|
// Цвета из autism-friendly палитры
|
||||||
topBar = {
|
val backgroundColor = LoginBackgroundTurquoise
|
||||||
TopAppBar(
|
val accentGreen = LoginGreenAccent
|
||||||
title = { Text("Задания") }
|
val dateCardColor = LoginInputLightBlue
|
||||||
)
|
|
||||||
|
// Загружаем задачи при открытии экрана
|
||||||
|
LaunchedEffect(scheduleId) {
|
||||||
|
scheduleId?.let {
|
||||||
|
viewModel.loadTasks(it)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(color = backgroundColor)
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(24.dp)
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
// Верхняя панель с кнопкой назад и кнопкой добавления
|
||||||
CircularProgressIndicator(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
.wrapContentWidth()
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Кнопка назад
|
||||||
|
if (navController != null) {
|
||||||
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.ArrowBack,
|
||||||
|
contentDescription = "Назад",
|
||||||
|
tint = accentGreen,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.size(48.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заголовок
|
||||||
|
Text(
|
||||||
|
text = "Задания",
|
||||||
|
fontSize = (screenHeightDp * 0.04f).toInt().coerceIn(32, 48).sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = accentGreen
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Кнопка добавления
|
||||||
|
if (scheduleId != null) {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { viewModel.showAddDialog() },
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
containerColor = accentGreen,
|
||||||
|
contentColor = Color.White
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Add,
|
||||||
|
contentDescription = "Добавить задачу",
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.size(56.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// Контент
|
||||||
|
if (uiState.isLoading && uiState.tasks.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = accentGreen)
|
||||||
|
}
|
||||||
|
} else if (uiState.tasks.isEmpty() && !uiState.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Нет задач",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
color = Color.Gray,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
items(uiState.tasks) { task ->
|
items(uiState.tasks) { task ->
|
||||||
Card(
|
// Получаем состояние таймера из Flow
|
||||||
modifier = Modifier.fillMaxWidth()
|
val timerStates by viewModel.timerStates.collectAsState()
|
||||||
) {
|
val timerState = timerStates[task.id]
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
TaskCard(
|
||||||
.fillMaxWidth()
|
task = task,
|
||||||
.padding(16.dp),
|
timerState = timerState,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
accentGreen = accentGreen,
|
||||||
) {
|
dateCardColor = dateCardColor,
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
screenHeightDp = screenHeightDp,
|
||||||
Text(
|
onStartTimer = { duration ->
|
||||||
text = task.title,
|
viewModel.startTimer(task.id, duration)
|
||||||
style = MaterialTheme.typography.titleMedium
|
},
|
||||||
)
|
onPauseTimer = {
|
||||||
task.description?.let {
|
viewModel.pauseTimer(task.id)
|
||||||
Text(
|
},
|
||||||
text = it,
|
onResumeTimer = {
|
||||||
style = MaterialTheme.typography.bodySmall
|
viewModel.resumeTimer(task.id)
|
||||||
)
|
},
|
||||||
}
|
onStopTimer = {
|
||||||
}
|
viewModel.stopTimer(task.id)
|
||||||
Checkbox(
|
},
|
||||||
checked = task.completed,
|
onDelete = {
|
||||||
onCheckedChange = { viewModel.completeTask(task.id) }
|
viewModel.deleteTask(task.id)
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог создания задачи/награды
|
||||||
|
if (uiState.showAddDialog) {
|
||||||
|
CreateTaskOrRewardDialog(
|
||||||
|
dialogMode = uiState.dialogMode,
|
||||||
|
taskTitle = uiState.newTaskTitle,
|
||||||
|
taskDescription = uiState.newTaskDescription,
|
||||||
|
taskDuration = uiState.newTaskDuration,
|
||||||
|
rewardTitle = uiState.newRewardTitle,
|
||||||
|
rewardDescription = uiState.newRewardDescription,
|
||||||
|
rewardPoints = uiState.newRewardPoints,
|
||||||
|
onModeChange = { viewModel.setDialogMode(it) },
|
||||||
|
onTaskTitleChange = { viewModel.updateNewTaskTitle(it) },
|
||||||
|
onTaskDescriptionChange = { viewModel.updateNewTaskDescription(it) },
|
||||||
|
onTaskDurationChange = { viewModel.updateNewTaskDuration(it) },
|
||||||
|
onRewardTitleChange = { viewModel.updateNewRewardTitle(it) },
|
||||||
|
onRewardDescriptionChange = { viewModel.updateNewRewardDescription(it) },
|
||||||
|
onRewardPointsChange = { viewModel.updateNewRewardPoints(it) },
|
||||||
|
onCreate = {
|
||||||
|
when (uiState.dialogMode) {
|
||||||
|
DialogMode.TASK -> {
|
||||||
|
scheduleId?.let { id ->
|
||||||
|
viewModel.createTask(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DialogMode.REWARD -> {
|
||||||
|
viewModel.createReward()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { viewModel.hideAddDialog() },
|
||||||
|
accentGreen = accentGreen,
|
||||||
|
screenHeightDp = screenHeightDp,
|
||||||
|
isLoading = uiState.isLoading,
|
||||||
|
scheduleId = scheduleId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snackbar для ошибок
|
||||||
|
uiState.errorMessage?.let { error ->
|
||||||
|
LaunchedEffect(error) {
|
||||||
|
// Можно добавить SnackbarHost если нужно
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TaskCard(
|
||||||
|
task: com.novayaplaneta.domain.model.Task,
|
||||||
|
timerState: TimerState?,
|
||||||
|
accentGreen: Color,
|
||||||
|
dateCardColor: Color,
|
||||||
|
screenHeightDp: Int,
|
||||||
|
onStartTimer: (Int) -> Unit,
|
||||||
|
onPauseTimer: () -> Unit,
|
||||||
|
onResumeTimer: () -> Unit,
|
||||||
|
onStopTimer: () -> Unit,
|
||||||
|
onDelete: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = dateCardColor)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = task.title,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
|
||||||
|
task.description?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
task.duration?.let {
|
||||||
|
Text(
|
||||||
|
text = "Длительность: $it мин",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.completed) {
|
||||||
|
Text(
|
||||||
|
text = "✓ Выполнено",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = accentGreen,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Таймер или кнопка удаления
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
if (task.completed) {
|
||||||
|
// Показываем только иконку выполнения
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = "Выполнено",
|
||||||
|
tint = accentGreen,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
} else if (timerState != null) {
|
||||||
|
// Показываем визуальный таймер
|
||||||
|
TaskTimer(
|
||||||
|
timerState = timerState,
|
||||||
|
accentGreen = accentGreen,
|
||||||
|
onPause = onPauseTimer,
|
||||||
|
onResume = onResumeTimer,
|
||||||
|
onStop = onStopTimer
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Показываем кнопку запуска таймера
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
task.duration?.let { onStartTimer(it) }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = "Запустить таймер",
|
||||||
|
tint = accentGreen,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Delete,
|
||||||
|
contentDescription = "Удалить",
|
||||||
|
tint = Color.Red,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TaskTimer(
|
||||||
|
timerState: TimerState,
|
||||||
|
accentGreen: Color,
|
||||||
|
onPause: () -> Unit,
|
||||||
|
onResume: () -> Unit,
|
||||||
|
onStop: () -> Unit
|
||||||
|
) {
|
||||||
|
val totalSeconds = timerState.durationMinutes * 60
|
||||||
|
val progress = if (totalSeconds > 0) {
|
||||||
|
timerState.remainingSeconds.toFloat() / totalSeconds
|
||||||
|
} else 0f
|
||||||
|
|
||||||
|
val timeText = formatTime(timerState.remainingSeconds)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(100.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val strokeWidth = 10.dp.toPx()
|
||||||
|
val radius = size.minDimension / 2 - strokeWidth / 2
|
||||||
|
|
||||||
|
// Фоновый круг
|
||||||
|
drawCircle(
|
||||||
|
color = Color.Gray.copy(alpha = 0.2f),
|
||||||
|
radius = radius,
|
||||||
|
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Прогресс таймера (обратный - показываем оставшееся время)
|
||||||
|
val sweepAngle = 360f * progress
|
||||||
|
|
||||||
|
drawArc(
|
||||||
|
color = accentGreen,
|
||||||
|
startAngle = -90f,
|
||||||
|
sweepAngle = sweepAngle,
|
||||||
|
useCenter = false,
|
||||||
|
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = timeText,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
|
) {
|
||||||
|
if (timerState.isRunning) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onPause,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Pause,
|
||||||
|
contentDescription = "Пауза",
|
||||||
|
tint = accentGreen,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(
|
||||||
|
onClick = onResume,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = "Продолжить",
|
||||||
|
tint = accentGreen,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,3 +445,264 @@ fun TaskScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatTime(seconds: Int): String {
|
||||||
|
val minutes = seconds / 60
|
||||||
|
val remainingSeconds = seconds % 60
|
||||||
|
return String.format("%02d:%02d", minutes, remainingSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CreateTaskOrRewardDialog(
|
||||||
|
dialogMode: DialogMode,
|
||||||
|
taskTitle: String,
|
||||||
|
taskDescription: String,
|
||||||
|
taskDuration: String,
|
||||||
|
rewardTitle: String,
|
||||||
|
rewardDescription: String,
|
||||||
|
rewardPoints: Int,
|
||||||
|
onModeChange: (DialogMode) -> Unit,
|
||||||
|
onTaskTitleChange: (String) -> Unit,
|
||||||
|
onTaskDescriptionChange: (String) -> Unit,
|
||||||
|
onTaskDurationChange: (String) -> Unit,
|
||||||
|
onRewardTitleChange: (String) -> Unit,
|
||||||
|
onRewardDescriptionChange: (String) -> Unit,
|
||||||
|
onRewardPointsChange: (Int) -> Unit,
|
||||||
|
onCreate: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
accentGreen: Color,
|
||||||
|
screenHeightDp: Int,
|
||||||
|
isLoading: Boolean,
|
||||||
|
scheduleId: String?
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.9f)
|
||||||
|
.wrapContentHeight(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color.White
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Переключатель режимов
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
// Кнопка "Задача"
|
||||||
|
Button(
|
||||||
|
onClick = { onModeChange(DialogMode.TASK) },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(48.dp),
|
||||||
|
enabled = scheduleId != null && !isLoading,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = if (dialogMode == DialogMode.TASK) accentGreen else Color.LightGray,
|
||||||
|
contentColor = if (dialogMode == DialogMode.TASK) Color.White else Color.Black,
|
||||||
|
disabledContainerColor = Color.LightGray,
|
||||||
|
disabledContentColor = Color.Gray
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Задача",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Награда"
|
||||||
|
Button(
|
||||||
|
onClick = { onModeChange(DialogMode.REWARD) },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(48.dp),
|
||||||
|
enabled = !isLoading,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = if (dialogMode == DialogMode.REWARD) accentGreen else Color.LightGray,
|
||||||
|
contentColor = if (dialogMode == DialogMode.REWARD) Color.White else Color.Black
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Награда",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заголовок
|
||||||
|
val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp
|
||||||
|
Text(
|
||||||
|
text = if (dialogMode == DialogMode.TASK) "Создать задачу" else "Создать награду",
|
||||||
|
fontSize = titleSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
|
||||||
|
when (dialogMode) {
|
||||||
|
DialogMode.TASK -> {
|
||||||
|
// Поле названия задачи
|
||||||
|
OutlinedTextField(
|
||||||
|
value = taskTitle,
|
||||||
|
onValueChange = onTaskTitleChange,
|
||||||
|
label = { Text("Название") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
focusedLabelColor = accentGreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле описания задачи
|
||||||
|
OutlinedTextField(
|
||||||
|
value = taskDescription,
|
||||||
|
onValueChange = onTaskDescriptionChange,
|
||||||
|
label = { Text("Описание") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 3,
|
||||||
|
enabled = !isLoading,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
focusedLabelColor = accentGreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле длительности
|
||||||
|
OutlinedTextField(
|
||||||
|
value = taskDuration,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
if (newValue.all { it.isDigit() }) {
|
||||||
|
onTaskDurationChange(newValue)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Длительность (минуты)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
focusedLabelColor = accentGreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogMode.REWARD -> {
|
||||||
|
// Поле названия награды
|
||||||
|
OutlinedTextField(
|
||||||
|
value = rewardTitle,
|
||||||
|
onValueChange = onRewardTitleChange,
|
||||||
|
label = { Text("Название") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
focusedLabelColor = accentGreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле описания награды
|
||||||
|
OutlinedTextField(
|
||||||
|
value = rewardDescription,
|
||||||
|
onValueChange = onRewardDescriptionChange,
|
||||||
|
label = { Text("Описание") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 3,
|
||||||
|
enabled = !isLoading,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
focusedLabelColor = accentGreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле баллов
|
||||||
|
OutlinedTextField(
|
||||||
|
value = rewardPoints.toString(),
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
if (newValue.all { it.isDigit() }) {
|
||||||
|
onRewardPointsChange(newValue.toIntOrNull() ?: 10)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Баллы") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
focusedLabelColor = accentGreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопки внизу
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Кнопка "Отмена"
|
||||||
|
Button(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color.LightGray,
|
||||||
|
contentColor = Color.Black
|
||||||
|
),
|
||||||
|
enabled = !isLoading
|
||||||
|
) {
|
||||||
|
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
||||||
|
Text(
|
||||||
|
text = "Отмена",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Создать"
|
||||||
|
Button(
|
||||||
|
onClick = onCreate,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(56.dp),
|
||||||
|
enabled = when (dialogMode) {
|
||||||
|
DialogMode.TASK -> taskTitle.isNotBlank() && !isLoading
|
||||||
|
DialogMode.REWARD -> rewardTitle.isNotBlank() && !isLoading
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = accentGreen,
|
||||||
|
contentColor = Color.White,
|
||||||
|
disabledContainerColor = Color.LightGray,
|
||||||
|
disabledContentColor = Color.Gray
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val buttonTextSize =
|
||||||
|
(screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
||||||
|
Text(
|
||||||
|
text = "Создать",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,43 +2,501 @@ package com.novayaplaneta.ui.screens.task
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.novayaplaneta.domain.repository.TaskRepository
|
import com.novayaplaneta.data.local.TimerStateManager
|
||||||
|
import com.novayaplaneta.data.local.SavedTimerState
|
||||||
|
import com.novayaplaneta.domain.model.Reward
|
||||||
|
import com.novayaplaneta.domain.model.Task
|
||||||
|
import com.novayaplaneta.domain.repository.AuthRepository
|
||||||
|
import com.novayaplaneta.domain.usecase.ClaimRewardUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.CompleteTaskUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.CreateRewardUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.CreateTaskUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.DeleteTaskUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.GetSchedulesUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.GetTaskByIdUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.GetTasksUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class TaskViewModel @Inject constructor(
|
class TaskViewModel @Inject constructor(
|
||||||
private val taskRepository: TaskRepository
|
private val getTasksUseCase: GetTasksUseCase,
|
||||||
|
private val getTaskByIdUseCase: GetTaskByIdUseCase,
|
||||||
|
private val createTaskUseCase: CreateTaskUseCase,
|
||||||
|
private val createRewardUseCase: CreateRewardUseCase,
|
||||||
|
private val deleteTaskUseCase: DeleteTaskUseCase,
|
||||||
|
private val completeTaskUseCase: CompleteTaskUseCase,
|
||||||
|
private val claimRewardUseCase: ClaimRewardUseCase,
|
||||||
|
private val getSchedulesUseCase: GetSchedulesUseCase,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val timerStateManager: TimerStateManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(TaskUiState())
|
private val _uiState = MutableStateFlow(TaskUiState())
|
||||||
val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _timerStates = MutableStateFlow<Map<String, TimerState>>(emptyMap())
|
||||||
|
val timerStates: StateFlow<Map<String, TimerState>> = _timerStates.asStateFlow()
|
||||||
|
|
||||||
|
private var currentScheduleId: String? = null
|
||||||
|
private val timerJobs = mutableMapOf<String, Job>()
|
||||||
|
|
||||||
fun loadTasks(scheduleId: String) {
|
fun loadTasks(scheduleId: String) {
|
||||||
|
currentScheduleId = scheduleId
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
taskRepository.getTasks(scheduleId).collect { tasks ->
|
|
||||||
|
try {
|
||||||
|
// Загружаем сохраненные состояния таймеров
|
||||||
|
val savedStates = timerStateManager.getAllTimerStates()
|
||||||
|
savedStates.forEach { (taskId, savedState) ->
|
||||||
|
val timerState = TimerState(
|
||||||
|
isRunning = savedState.isRunning,
|
||||||
|
durationMinutes = savedState.durationMinutes,
|
||||||
|
remainingSeconds = savedState.remainingSeconds
|
||||||
|
)
|
||||||
|
val currentStates = _timerStates.value.toMutableMap()
|
||||||
|
currentStates[taskId] = timerState
|
||||||
|
_timerStates.value = currentStates
|
||||||
|
|
||||||
|
// Перезапускаем таймер если он был запущен
|
||||||
|
if (savedState.isRunning && savedState.remainingSeconds > 0) {
|
||||||
|
resumeTimer(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val loadResult = getTasksUseCase.loadTasks(scheduleId)
|
||||||
|
if (loadResult.isFailure) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = loadResult.exceptionOrNull()?.message ?: "Ошибка загрузки задач"
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подписываемся на Flow для обновлений
|
||||||
|
getTasksUseCase(scheduleId).collect { tasks ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
tasks = tasks.sortedBy { it.order },
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
tasks = tasks,
|
isLoading = false,
|
||||||
isLoading = false
|
errorMessage = e.message ?: "Ошибка загрузки задач"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun completeTask(taskId: String) {
|
fun loadTaskById(taskId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
taskRepository.completeTask(taskId)
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
val result = getTaskByIdUseCase(taskId)
|
||||||
|
result.onSuccess { task ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
selectedTask = task,
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
}.onFailure { exception ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = exception.message ?: "Ошибка загрузки задачи"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createTask(scheduleId: String) {
|
||||||
|
val title = _uiState.value.newTaskTitle
|
||||||
|
if (title.isBlank()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
val task = Task(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
title = title,
|
||||||
|
description = _uiState.value.newTaskDescription.takeIf { it.isNotBlank() },
|
||||||
|
imageUrl = null,
|
||||||
|
completed = false,
|
||||||
|
scheduledTime = null,
|
||||||
|
duration = _uiState.value.newTaskDuration.toIntOrNull(),
|
||||||
|
scheduleId = scheduleId,
|
||||||
|
order = _uiState.value.tasks.size,
|
||||||
|
category = null,
|
||||||
|
createdAt = LocalDateTime.now(),
|
||||||
|
updatedAt = LocalDateTime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = createTaskUseCase(task)
|
||||||
|
result.onSuccess {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = null,
|
||||||
|
showAddDialog = false,
|
||||||
|
newTaskTitle = "",
|
||||||
|
newTaskDescription = "",
|
||||||
|
newTaskDuration = ""
|
||||||
|
)
|
||||||
|
// Список задач обновится автоматически через Flow
|
||||||
|
}.onFailure { exception ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = exception.message ?: "Ошибка создания задачи"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createReward() {
|
||||||
|
val title = _uiState.value.newRewardTitle
|
||||||
|
if (title.isBlank()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
val userId = authRepository.getCurrentUser().first()?.id
|
||||||
|
if (userId == null) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = "Пользователь не найден"
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val reward = Reward(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
title = title,
|
||||||
|
description = _uiState.value.newRewardDescription.takeIf { it.isNotBlank() },
|
||||||
|
imageUrl = null,
|
||||||
|
pointsRequired = _uiState.value.newRewardPoints,
|
||||||
|
isClaimed = false,
|
||||||
|
earnedAt = null,
|
||||||
|
userId = userId,
|
||||||
|
createdAt = LocalDateTime.now(),
|
||||||
|
updatedAt = LocalDateTime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = createRewardUseCase(reward)
|
||||||
|
result.onSuccess {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = null,
|
||||||
|
showAddDialog = false,
|
||||||
|
newRewardTitle = "",
|
||||||
|
newRewardDescription = "",
|
||||||
|
newRewardPoints = 10
|
||||||
|
)
|
||||||
|
}.onFailure { exception ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = exception.message ?: "Ошибка создания награды"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showAddDialog() {
|
||||||
|
_uiState.value = _uiState.value.copy(showAddDialog = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideAddDialog() {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
showAddDialog = false,
|
||||||
|
newTaskTitle = "",
|
||||||
|
newTaskDescription = "",
|
||||||
|
newTaskDuration = "",
|
||||||
|
newRewardTitle = "",
|
||||||
|
newRewardDescription = "",
|
||||||
|
newRewardPoints = 10
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDialogMode(mode: DialogMode) {
|
||||||
|
_uiState.value = _uiState.value.copy(dialogMode = mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewTaskTitle(title: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newTaskTitle = title)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewTaskDescription(description: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newTaskDescription = description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewTaskDuration(duration: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newTaskDuration = duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewRewardTitle(title: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newRewardTitle = title)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewRewardDescription(description: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newRewardDescription = description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewRewardPoints(points: Int) {
|
||||||
|
_uiState.value = _uiState.value.copy(newRewardPoints = points)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteTask(taskId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
val result = deleteTaskUseCase(taskId)
|
||||||
|
result.onSuccess {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
// Список задач обновится автоматически через Flow
|
||||||
|
}.onFailure { exception ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = exception.message ?: "Ошибка удаления задачи"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun completeTask(taskId: String, completed: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Останавливаем таймер если задача завершена
|
||||||
|
if (completed) {
|
||||||
|
stopTimer(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Оптимистичное обновление UI
|
||||||
|
val currentTasks = _uiState.value.tasks.toMutableList()
|
||||||
|
val taskIndex = currentTasks.indexOfFirst { it.id == taskId }
|
||||||
|
if (taskIndex >= 0) {
|
||||||
|
val oldTask = currentTasks[taskIndex]
|
||||||
|
currentTasks[taskIndex] = oldTask.copy(completed = completed)
|
||||||
|
_uiState.value = _uiState.value.copy(tasks = currentTasks.sortedBy { it.order })
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = completeTaskUseCase(taskId, completed)
|
||||||
|
result.onSuccess {
|
||||||
|
// Проверяем, все ли задачи выполнены, и выдаем награду если нужно
|
||||||
|
if (completed && currentScheduleId != null) {
|
||||||
|
checkAndAwardReward(currentScheduleId!!)
|
||||||
|
}
|
||||||
|
}.onFailure { exception ->
|
||||||
|
// Откатываем изменение в случае ошибки
|
||||||
|
if (taskIndex >= 0) {
|
||||||
|
val oldTask = currentTasks[taskIndex]
|
||||||
|
currentTasks[taskIndex] = oldTask.copy(completed = !completed)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
tasks = currentTasks.sortedBy { it.order },
|
||||||
|
errorMessage = exception.message ?: "Ошибка обновления задачи"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkAndAwardReward(scheduleId: String) {
|
||||||
|
try {
|
||||||
|
val user = authRepository.getCurrentUser().first()
|
||||||
|
val userId = user?.id ?: return
|
||||||
|
|
||||||
|
// Получаем расписание
|
||||||
|
val schedules = getSchedulesUseCase(userId).first()
|
||||||
|
val schedule = schedules.find { it.id == scheduleId } ?: return
|
||||||
|
|
||||||
|
// Проверяем, есть ли награда в расписании
|
||||||
|
val rewardId = schedule.rewardId ?: return
|
||||||
|
|
||||||
|
// Проверяем, все ли задачи выполнены
|
||||||
|
val allTasksCompleted = schedule.tasks.isNotEmpty() &&
|
||||||
|
schedule.tasks.all { it.completed }
|
||||||
|
|
||||||
|
if (allTasksCompleted) {
|
||||||
|
// Выдаем награду
|
||||||
|
claimRewardUseCase(rewardId)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Игнорируем ошибки при проверке награды
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startTimer(taskId: String, durationMinutes: Int) {
|
||||||
|
// Останавливаем предыдущий таймер для этой задачи если есть
|
||||||
|
stopTimer(taskId)
|
||||||
|
|
||||||
|
val newState = TimerState(
|
||||||
|
isRunning = true,
|
||||||
|
durationMinutes = durationMinutes,
|
||||||
|
remainingSeconds = durationMinutes * 60
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentStates = _timerStates.value.toMutableMap()
|
||||||
|
currentStates[taskId] = newState
|
||||||
|
_timerStates.value = currentStates
|
||||||
|
|
||||||
|
timerJobs[taskId] = viewModelScope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(1000)
|
||||||
|
val currentStates = _timerStates.value
|
||||||
|
val state = currentStates[taskId] ?: break
|
||||||
|
|
||||||
|
if (!state.isRunning) continue
|
||||||
|
|
||||||
|
if (state.remainingSeconds <= 0) {
|
||||||
|
// Таймер истек, завершаем задачу
|
||||||
|
completeTask(taskId, true)
|
||||||
|
val updatedStates = currentStates.toMutableMap()
|
||||||
|
updatedStates.remove(taskId)
|
||||||
|
_timerStates.value = updatedStates
|
||||||
|
timerJobs.remove(taskId)
|
||||||
|
timerStateManager.removeTimerState(taskId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
val newRemaining = state.remainingSeconds - 1
|
||||||
|
val updatedState = state.copy(remainingSeconds = newRemaining)
|
||||||
|
val updatedStates = currentStates.toMutableMap()
|
||||||
|
updatedStates[taskId] = updatedState
|
||||||
|
_timerStates.value = updatedStates
|
||||||
|
|
||||||
|
// Сохраняем состояние таймера
|
||||||
|
timerStateManager.saveTimerState(
|
||||||
|
taskId,
|
||||||
|
SavedTimerState(
|
||||||
|
isRunning = updatedState.isRunning,
|
||||||
|
durationMinutes = updatedState.durationMinutes,
|
||||||
|
remainingSeconds = updatedState.remainingSeconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pauseTimer(taskId: String) {
|
||||||
|
val currentStates = _timerStates.value.toMutableMap()
|
||||||
|
currentStates[taskId]?.let { state ->
|
||||||
|
val pausedState = state.copy(isRunning = false)
|
||||||
|
currentStates[taskId] = pausedState
|
||||||
|
_timerStates.value = currentStates
|
||||||
|
|
||||||
|
// Сохраняем состояние таймера
|
||||||
|
viewModelScope.launch {
|
||||||
|
timerStateManager.saveTimerState(
|
||||||
|
taskId,
|
||||||
|
SavedTimerState(
|
||||||
|
isRunning = pausedState.isRunning,
|
||||||
|
durationMinutes = pausedState.durationMinutes,
|
||||||
|
remainingSeconds = pausedState.remainingSeconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resumeTimer(taskId: String) {
|
||||||
|
val currentStates = _timerStates.value.toMutableMap()
|
||||||
|
currentStates[taskId]?.let { state ->
|
||||||
|
if (state.remainingSeconds > 0) {
|
||||||
|
currentStates[taskId] = state.copy(isRunning = true)
|
||||||
|
_timerStates.value = currentStates
|
||||||
|
|
||||||
|
// Перезапускаем таймер
|
||||||
|
timerJobs[taskId]?.cancel()
|
||||||
|
timerJobs[taskId] = viewModelScope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(1000)
|
||||||
|
val currentStates = _timerStates.value
|
||||||
|
val currentState = currentStates[taskId] ?: break
|
||||||
|
|
||||||
|
if (!currentState.isRunning) continue
|
||||||
|
|
||||||
|
if (currentState.remainingSeconds <= 0) {
|
||||||
|
completeTask(taskId, true)
|
||||||
|
val updatedStates = currentStates.toMutableMap()
|
||||||
|
updatedStates.remove(taskId)
|
||||||
|
_timerStates.value = updatedStates
|
||||||
|
timerJobs.remove(taskId)
|
||||||
|
timerStateManager.removeTimerState(taskId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
val newRemaining = currentState.remainingSeconds - 1
|
||||||
|
val updatedState = currentState.copy(remainingSeconds = newRemaining)
|
||||||
|
val updatedStates = currentStates.toMutableMap()
|
||||||
|
updatedStates[taskId] = updatedState
|
||||||
|
_timerStates.value = updatedStates
|
||||||
|
|
||||||
|
// Сохраняем состояние таймера
|
||||||
|
timerStateManager.saveTimerState(
|
||||||
|
taskId,
|
||||||
|
SavedTimerState(
|
||||||
|
isRunning = updatedState.isRunning,
|
||||||
|
durationMinutes = updatedState.durationMinutes,
|
||||||
|
remainingSeconds = updatedState.remainingSeconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopTimer(taskId: String) {
|
||||||
|
timerJobs[taskId]?.cancel()
|
||||||
|
timerJobs.remove(taskId)
|
||||||
|
val currentStates = _timerStates.value.toMutableMap()
|
||||||
|
currentStates.remove(taskId)
|
||||||
|
_timerStates.value = currentStates
|
||||||
|
|
||||||
|
// Удаляем сохраненное состояние
|
||||||
|
viewModelScope.launch {
|
||||||
|
timerStateManager.removeTimerState(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTimerState(taskId: String): TimerState? {
|
||||||
|
return _timerStates.value[taskId]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(errorMessage = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class DialogMode {
|
||||||
|
TASK, REWARD
|
||||||
}
|
}
|
||||||
|
|
||||||
data class TaskUiState(
|
data class TaskUiState(
|
||||||
val tasks: List<com.novayaplaneta.domain.model.Task> = emptyList(),
|
val tasks: List<Task> = emptyList(),
|
||||||
val isLoading: Boolean = false
|
val selectedTask: Task? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val showAddDialog: Boolean = false,
|
||||||
|
val dialogMode: DialogMode = DialogMode.TASK,
|
||||||
|
val newTaskTitle: String = "",
|
||||||
|
val newTaskDescription: String = "",
|
||||||
|
val newTaskDuration: String = "",
|
||||||
|
val newRewardTitle: String = "",
|
||||||
|
val newRewardDescription: String = "",
|
||||||
|
val newRewardPoints: Int = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TimerState(
|
||||||
|
val isRunning: Boolean = false,
|
||||||
|
val durationMinutes: Int = 0,
|
||||||
|
val remainingSeconds: Int = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,22 +14,22 @@ val SurfaceDark = Color(0xFF1A1A1A)
|
|||||||
val OnBackgroundDark = Color(0xFFFFFFFF)
|
val OnBackgroundDark = Color(0xFFFFFFFF)
|
||||||
val OnSurfaceDark = Color(0xFFFFFFFF)
|
val OnSurfaceDark = Color(0xFFFFFFFF)
|
||||||
|
|
||||||
// Accent Colors
|
// Accent Colors - очень мягкие пастельные тона для максимального комфорта детей с РАС
|
||||||
val AccentGreen = Color(0xFF4CAF50)
|
val AccentGreen = Color(0xFFA8D5BA) // Очень мягкий мятно-зеленый (низкая насыщенность)
|
||||||
val AccentOrange = Color(0xFFFF6B35)
|
val AccentOrange = Color(0xFFFFD4B3) // Очень мягкий персиковый (теплый, успокаивающий)
|
||||||
val AccentGold = Color(0xFFFFD700)
|
val AccentGold = Color(0xFFFFEEC7) // Очень мягкий бежево-желтый (кремовый оттенок)
|
||||||
|
|
||||||
// Status Colors
|
// Status Colors - очень приглушенные для комфорта
|
||||||
val SuccessColor = Color(0xFF4CAF50)
|
val SuccessColor = Color(0xFFA8D5BA) // Очень мягкий зеленый
|
||||||
val WarningColor = Color(0xFFFF6B35)
|
val WarningColor = Color(0xFFFFD4B3) // Очень мягкий персиковый
|
||||||
val ErrorColor = Color(0xFFE53935)
|
val ErrorColor = Color(0xFFFFC8C8) // Очень мягкий розовый (вместо яркого красного)
|
||||||
|
|
||||||
// Цвета для экрана авторизации (благоприятные для РАС из PDF)
|
// Цвета для экрана авторизации (оптимизированные для РАС - очень мягкие тона)
|
||||||
val LoginBackgroundTurquoise = Color(0xFFDAE7E9) // Мягкий голубой фон
|
val LoginBackgroundTurquoise = Color(0xFFE8F4F5) // Очень мягкий мятный фон (еще мягче)
|
||||||
val LoginCardLightBlue = Color(0xFFBCDAEC) // Спокойный светло-голубой
|
val LoginCardLightBlue = Color(0xFFD4E8F0) // Очень мягкий голубой для карточек
|
||||||
val LoginInputLightBlue = Color(0xFFBCDAEC) // Для полей ввода
|
val LoginInputLightBlue = Color(0xFFD4E8F0) // Очень мягкий голубой для полей ввода
|
||||||
val LoginButtonBlue = Color(0xFFBCDAEC) // Для кнопки
|
val LoginButtonBlue = Color(0xFFD4E8F0) // Очень мягкий голубой для кнопок
|
||||||
val LoginGreenAccent = Color(0xFF80EF80) // Пастельно-зелёный акцент
|
val LoginGreenAccent = Color(0xFF95D5A3) // Очень мягкий зеленый акцент (приглушенный)
|
||||||
val LoginGreenSoft = Color(0xFFC5E6C5) // Мягкий пастельно-зелёный
|
val LoginGreenSoft = Color(0xFFD1E8D7) // Очень мягкий светло-зеленый
|
||||||
val LoginGreenDark = Color(0xFF80EF80) // Пастельно-зелёный темнее
|
val LoginGreenDark = Color(0xFF95D5A3) // Очень мягкий зеленый для темных элементов
|
||||||
|
|
||||||
|
|||||||
7
app/src/main/res/xml/network_security_config.xml
Normal file
7
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||||
|
<domain includeSubdomains="true">localhost</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -21,6 +21,7 @@ coil = "2.7.0"
|
|||||||
lottie = "6.1.0"
|
lottie = "6.1.0"
|
||||||
coroutines = "1.9.0"
|
coroutines = "1.9.0"
|
||||||
ksp = "2.1.0-1.0.28"
|
ksp = "2.1.0-1.0.28"
|
||||||
|
datastore = "1.1.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -71,6 +72,9 @@ lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", versio
|
|||||||
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
|
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
|
||||||
|
# DataStore
|
||||||
|
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
|||||||
Reference in New Issue
Block a user