Compare commits
8 Commits
c56399dde9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8deb7668c8 | |||
| 2f6c53647e | |||
| 2de916cfd9 | |||
| 4ff516d06a | |||
| d8a0237e43 | |||
| b41de4aaf5 | |||
| 5105e68970 | |||
| 59087fe6d9 |
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>
|
||||
2
.idea/gradle.xml
generated
2
.idea/gradle.xml
generated
@@ -4,6 +4,7 @@
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
@@ -12,7 +13,6 @@
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveExternalAnnotations" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
@@ -100,6 +100,9 @@ dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
|
||||
// DataStore
|
||||
implementation(libs.datastore.preferences)
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.NewPlanet">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
||||
@@ -21,7 +21,7 @@ import com.novayaplaneta.data.local.entity.UserEntity
|
||||
RewardEntity::class,
|
||||
ChatMessageEntity::class
|
||||
],
|
||||
version = 1,
|
||||
version = 4,
|
||||
exportSchema = false
|
||||
)
|
||||
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 description: String?,
|
||||
val imageUrl: String?,
|
||||
val points: Int,
|
||||
val pointsRequired: Int,
|
||||
val isClaimed: Boolean = false,
|
||||
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 date: 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 scheduledTime: Long?, // timestamp
|
||||
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(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val name: String,
|
||||
val fullName: String,
|
||||
val email: String,
|
||||
val role: String,
|
||||
val token: String?
|
||||
val createdAt: String?,
|
||||
val updatedAt: String?
|
||||
)
|
||||
|
||||
|
||||
@@ -12,11 +12,18 @@ fun RewardEntity.toDomain(): Reward {
|
||||
title = title,
|
||||
description = description,
|
||||
imageUrl = imageUrl,
|
||||
points = points,
|
||||
pointsRequired = pointsRequired,
|
||||
isClaimed = isClaimed,
|
||||
earnedAt = earnedAt?.let {
|
||||
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,
|
||||
description = description,
|
||||
imageUrl = imageUrl,
|
||||
points = points,
|
||||
pointsRequired = pointsRequired,
|
||||
isClaimed = isClaimed,
|
||||
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,
|
||||
date = LocalDateTime.ofInstant(Instant.ofEpochMilli(date), 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,
|
||||
date = date.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())
|
||||
},
|
||||
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,
|
||||
scheduledTime = scheduledTime?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(),
|
||||
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 {
|
||||
return User(
|
||||
id = id,
|
||||
name = name,
|
||||
fullName = fullName,
|
||||
email = email,
|
||||
role = UserRole.valueOf(role),
|
||||
token = token
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
fun User.toEntity(): UserEntity {
|
||||
return UserEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
fullName = fullName,
|
||||
email = email,
|
||||
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.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.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.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface BackendApi {
|
||||
@POST("api/v1/auth/login")
|
||||
@@ -21,5 +33,82 @@ interface BackendApi {
|
||||
@Header("Authorization") token: String,
|
||||
@Body request: ChatRequest
|
||||
): 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
|
||||
data class ChatRequest(
|
||||
val message: String
|
||||
val message: String,
|
||||
val conversation_id: String? = null
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
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.mapper.toDomain
|
||||
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.GenerateScheduleRequest
|
||||
import com.novayaplaneta.domain.model.ChatMessage
|
||||
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.first
|
||||
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.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
class AIRepositoryImpl @Inject constructor(
|
||||
private val chatMessageDao: ChatMessageDao,
|
||||
private val api: BackendApi,
|
||||
private val authRepository: com.novayaplaneta.domain.repository.AuthRepository
|
||||
private val aiApi: AiApi
|
||||
) : 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 {
|
||||
// Get token from current user
|
||||
val user = authRepository.getCurrentUser().first()
|
||||
val token = user?.token ?: ""
|
||||
|
||||
// Save user message
|
||||
val userMessage = ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
@@ -36,28 +36,32 @@ class AIRepositoryImpl @Inject constructor(
|
||||
)
|
||||
chatMessageDao.insertMessage(userMessage.toEntity())
|
||||
|
||||
// Send to API (only if token exists)
|
||||
if (token.isNotEmpty()) {
|
||||
val response = api.chatWithAI("Bearer $token", ChatRequest(message))
|
||||
// Send request to API
|
||||
val request = ChatRequest(message = message, conversation_id = conversationId)
|
||||
val response = aiApi.chat(request)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val aiResponse = response.body()!!.message
|
||||
val chatResponse = response.body()!!
|
||||
|
||||
// Save AI response
|
||||
val aiMessage = ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
message = aiResponse,
|
||||
message = chatResponse.response,
|
||||
isFromAI = true,
|
||||
timestamp = LocalDateTime.now(),
|
||||
userId = userId
|
||||
)
|
||||
chatMessageDao.insertMessage(aiMessage.toEntity())
|
||||
|
||||
Result.success(aiResponse)
|
||||
Result.success(
|
||||
ChatResponse(
|
||||
response = chatResponse.response,
|
||||
conversationId = chatResponse.conversation_id
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Result.failure(Exception("Failed to get AI response"))
|
||||
}
|
||||
} 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) {
|
||||
Result.failure(e)
|
||||
@@ -70,9 +74,79 @@ class AIRepositoryImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun generateSchedule(userId: String, preferences: String): Result<String> {
|
||||
// TODO: Implement schedule generation
|
||||
return Result.failure(Exception("Not implemented"))
|
||||
override suspend fun generateSchedule(
|
||||
childAge: Int,
|
||||
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
|
||||
|
||||
import com.novayaplaneta.data.local.TokenManager
|
||||
import com.novayaplaneta.data.local.dao.UserDao
|
||||
import com.novayaplaneta.data.local.mapper.toDomain
|
||||
import com.novayaplaneta.data.local.mapper.toEntity
|
||||
import com.novayaplaneta.data.remote.BackendApi
|
||||
import com.novayaplaneta.data.remote.dto.LoginRequest
|
||||
import com.novayaplaneta.data.remote.AuthApi
|
||||
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.UserRole
|
||||
import com.novayaplaneta.domain.repository.AuthRepository
|
||||
@@ -14,25 +16,123 @@ import javax.inject.Inject
|
||||
|
||||
class AuthRepositoryImpl @Inject constructor(
|
||||
private val userDao: UserDao,
|
||||
private val api: BackendApi
|
||||
private val authApi: AuthApi,
|
||||
private val tokenManager: TokenManager
|
||||
) : 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> {
|
||||
return try {
|
||||
val response = api.login(LoginRequest(email, password))
|
||||
val response = authApi.login(username = email, password = password)
|
||||
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 = loginResponse.user.id,
|
||||
name = loginResponse.user.name,
|
||||
email = loginResponse.user.email,
|
||||
role = UserRole.valueOf(loginResponse.user.role),
|
||||
token = loginResponse.token
|
||||
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 {
|
||||
Result.failure(Exception("Login failed"))
|
||||
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(
|
||||
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 {
|
||||
Result.failure(Exception("Failed to get user info"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
@@ -41,6 +141,7 @@ class AuthRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun logout() {
|
||||
userDao.deleteAllUsers()
|
||||
tokenManager.clearTokens()
|
||||
}
|
||||
|
||||
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.mapper.toDomain
|
||||
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.repository.RewardRepository
|
||||
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 java.time.LocalDateTime
|
||||
import javax.inject.Inject
|
||||
|
||||
class RewardRepositoryImpl @Inject constructor(
|
||||
private val rewardDao: RewardDao
|
||||
private val rewardDao: RewardDao,
|
||||
private val backendApi: BackendApi
|
||||
) : RewardRepository {
|
||||
|
||||
override fun getRewards(userId: String): Flow<List<Reward>> {
|
||||
return rewardDao.getRewardsByUserId(userId).map { rewards ->
|
||||
rewards.map { it.toDomain() }
|
||||
}
|
||||
// Кэш наград
|
||||
private val _rewardsCache = MutableStateFlow<List<Reward>>(emptyList())
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
override suspend fun getRewardById(id: String): Reward? {
|
||||
return rewardDao.getRewardById(id)?.toDomain()
|
||||
}
|
||||
|
||||
override suspend fun createReward(reward: Reward) {
|
||||
// Сохраняем в локальную БД
|
||||
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 fun getRewards(userId: String): Flow<List<Reward>> {
|
||||
return _rewardsCache.asStateFlow()
|
||||
}
|
||||
|
||||
override suspend fun getRewardById(id: String): Result<Reward> {
|
||||
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) {
|
||||
rewardDao.updateReward(reward.toEntity())
|
||||
}
|
||||
|
||||
override suspend fun deleteReward(id: String) {
|
||||
override suspend fun deleteReward(id: String): Result<Unit> {
|
||||
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) {
|
||||
val reward = getRewardById(rewardId)
|
||||
reward?.let {
|
||||
updateReward(it.copy(earnedAt = LocalDateTime.now()))
|
||||
override suspend fun claimReward(rewardId: String): Result<Reward> {
|
||||
return try {
|
||||
val response = backendApi.claimReward(rewardId)
|
||||
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.mapper.toDomain
|
||||
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.repository.ScheduleRepository
|
||||
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 javax.inject.Inject
|
||||
|
||||
class ScheduleRepositoryImpl @Inject constructor(
|
||||
private val scheduleDao: ScheduleDao,
|
||||
private val taskDao: TaskDao
|
||||
private val taskDao: TaskDao,
|
||||
private val backendApi: BackendApi
|
||||
) : ScheduleRepository {
|
||||
|
||||
private val _schedulesCache = MutableStateFlow<List<Schedule>>(emptyList())
|
||||
val schedulesCache: StateFlow<List<Schedule>> = _schedulesCache.asStateFlow()
|
||||
|
||||
override fun getSchedules(userId: String): Flow<List<Schedule>> {
|
||||
return scheduleDao.getSchedulesByUserId(userId).map { schedules ->
|
||||
schedules.map { scheduleEntity ->
|
||||
// Note: In production, you'd need to fetch tasks for each schedule
|
||||
scheduleEntity.toDomain(emptyList())
|
||||
}
|
||||
}
|
||||
return _schedulesCache.asStateFlow()
|
||||
}
|
||||
|
||||
override suspend fun getScheduleById(id: String): Schedule? {
|
||||
val scheduleEntity = scheduleDao.getScheduleById(id) ?: return null
|
||||
val tasks = taskDao.getTasksByScheduleId(id)
|
||||
// Simplified - would need proper Flow handling
|
||||
return scheduleEntity.toDomain(emptyList())
|
||||
return try {
|
||||
val response = backendApi.getScheduleById(id)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
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) {
|
||||
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) {
|
||||
// TODO: Implement if API supports update
|
||||
scheduleDao.updateSchedule(schedule.toEntity())
|
||||
}
|
||||
|
||||
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) {
|
||||
// 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.mapper.toDomain
|
||||
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.repository.TaskRepository
|
||||
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 javax.inject.Inject
|
||||
|
||||
class TaskRepositoryImpl @Inject constructor(
|
||||
private val taskDao: TaskDao
|
||||
private val taskDao: TaskDao,
|
||||
private val backendApi: BackendApi
|
||||
) : TaskRepository {
|
||||
|
||||
override fun getTasks(scheduleId: String): Flow<List<Task>> {
|
||||
return taskDao.getTasksByScheduleId(scheduleId).map { tasks ->
|
||||
tasks.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
// Кэш задач по scheduleId
|
||||
private val _tasksCache = mutableMapOf<String, MutableStateFlow<List<Task>>>()
|
||||
|
||||
override suspend fun getTaskById(id: String): Task? {
|
||||
return taskDao.getTaskById(id)?.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 }
|
||||
|
||||
override suspend fun createTask(task: Task) {
|
||||
// Обновляем кэш
|
||||
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 fun getTasks(scheduleId: String): Flow<List<Task>> {
|
||||
val cache = _tasksCache.getOrPut(scheduleId) { MutableStateFlow(emptyList()) }
|
||||
return cache.asStateFlow()
|
||||
}
|
||||
|
||||
override suspend fun getTaskById(id: String): Result<Task> {
|
||||
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) {
|
||||
taskDao.updateTask(task.toEntity())
|
||||
}
|
||||
|
||||
override suspend fun deleteTask(id: String) {
|
||||
taskDao.deleteTask(id)
|
||||
override suspend fun deleteTask(id: String): Result<Unit> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun completeTask(id: String) {
|
||||
taskDao.completeTask(id)
|
||||
// Удаляем из локальной БД
|
||||
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, completed: Boolean): Result<Task> {
|
||||
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,
|
||||
NewPlanetDatabase::class.java,
|
||||
"newplanet_database"
|
||||
).build()
|
||||
)
|
||||
.fallbackToDestructiveMigration() // Пересоздает БД при изменении схемы (для разработки)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.novayaplaneta.di
|
||||
|
||||
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 dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -26,26 +29,30 @@ object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(): OkHttpClient {
|
||||
fun provideOkHttpClient(
|
||||
authInterceptor: AuthInterceptor
|
||||
): OkHttpClient {
|
||||
val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
||||
fun provideRetrofit(
|
||||
okHttpClient: OkHttpClient
|
||||
): Retrofit {
|
||||
val contentType = "application/json".toMediaType()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl("https://api.novayaplaneta.ru/")
|
||||
.baseUrl("http://10.0.2.2:8000/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(json.asConverterFactory(contentType))
|
||||
.build()
|
||||
@@ -53,8 +60,26 @@ object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@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)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAiApi(
|
||||
retrofit: Retrofit
|
||||
): AiApi {
|
||||
return retrofit.create(AiApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,11 @@ data class Reward(
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val imageUrl: String?,
|
||||
val points: Int,
|
||||
val pointsRequired: Int,
|
||||
val isClaimed: Boolean = false,
|
||||
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 date: 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 scheduledTime: LocalDateTime?,
|
||||
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(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val fullName: String,
|
||||
val email: String,
|
||||
val role: UserRole,
|
||||
val token: String? = null
|
||||
val createdAt: String? = null,
|
||||
val updatedAt: String? = null
|
||||
)
|
||||
|
||||
enum class UserRole {
|
||||
|
||||
@@ -4,8 +4,24 @@ import com.novayaplaneta.domain.model.ChatMessage
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
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>>
|
||||
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
|
||||
|
||||
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 refresh(): Result<Unit>
|
||||
suspend fun getMe(): Result<User>
|
||||
suspend fun logout()
|
||||
fun getCurrentUser(): Flow<User?>
|
||||
suspend fun saveUser(user: User)
|
||||
|
||||
@@ -4,11 +4,12 @@ import com.novayaplaneta.domain.model.Reward
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface RewardRepository {
|
||||
suspend fun loadRewards(skip: Int = 0, limit: Int = 100, isClaimed: Boolean? = null): Result<Unit>
|
||||
fun getRewards(userId: String): Flow<List<Reward>>
|
||||
suspend fun getRewardById(id: String): Reward?
|
||||
suspend fun createReward(reward: Reward)
|
||||
suspend fun getRewardById(id: String): Result<Reward>
|
||||
suspend fun createReward(reward: Reward): Result<Reward>
|
||||
suspend fun updateReward(reward: Reward)
|
||||
suspend fun deleteReward(id: String)
|
||||
suspend fun earnReward(userId: String, rewardId: String)
|
||||
suspend fun deleteReward(id: String): Result<Unit>
|
||||
suspend fun claimReward(rewardId: String): Result<Reward>
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.novayaplaneta.domain.repository
|
||||
|
||||
import com.novayaplaneta.domain.model.Schedule
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface ScheduleRepository {
|
||||
fun getSchedules(userId: String): Flow<List<Schedule>>
|
||||
@@ -11,5 +10,6 @@ interface ScheduleRepository {
|
||||
suspend fun updateSchedule(schedule: Schedule)
|
||||
suspend fun deleteSchedule(id: 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
|
||||
|
||||
interface TaskRepository {
|
||||
suspend fun loadTasks(scheduleId: String): Result<Unit>
|
||||
fun getTasks(scheduleId: String): Flow<List<Task>>
|
||||
suspend fun getTaskById(id: String): Task?
|
||||
suspend fun createTask(task: Task)
|
||||
suspend fun getTaskById(id: String): Result<Task>
|
||||
suspend fun createTask(task: Task): Result<Task>
|
||||
suspend fun updateTask(task: Task)
|
||||
suspend fun deleteTask(id: String)
|
||||
suspend fun completeTask(id: String)
|
||||
suspend fun deleteTask(id: String): Result<Unit>
|
||||
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
|
||||
|
||||
import com.novayaplaneta.domain.model.Task
|
||||
import com.novayaplaneta.domain.repository.TaskRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class CompleteTaskUseCase @Inject constructor(
|
||||
private val repository: TaskRepository
|
||||
) {
|
||||
suspend operator fun invoke(taskId: String) {
|
||||
repository.completeTask(taskId)
|
||||
suspend operator fun invoke(id: String, completed: Boolean): Result<Task> {
|
||||
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(
|
||||
private val repository: ScheduleRepository
|
||||
) {
|
||||
suspend operator fun invoke(schedule: Schedule) {
|
||||
suspend operator fun invoke(schedule: Schedule): Result<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>> {
|
||||
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
|
||||
|
||||
import com.novayaplaneta.domain.repository.AIRepository
|
||||
import com.novayaplaneta.domain.repository.ChatResponse
|
||||
import javax.inject.Inject
|
||||
|
||||
class SendAIMessageUseCase @Inject constructor(
|
||||
private val repository: AIRepository
|
||||
) {
|
||||
suspend operator fun invoke(userId: String, message: String): Result<String> {
|
||||
return repository.sendMessage(userId, message)
|
||||
suspend operator fun invoke(userId: String, message: String, conversationId: String? = null): Result<ChatResponse> {
|
||||
return repository.sendMessage(userId, message, conversationId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,15 @@ package com.novayaplaneta.ui.navigation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import com.novayaplaneta.ui.screens.ai.AIScreen
|
||||
import com.novayaplaneta.ui.screens.auth.ForgotPasswordScreen
|
||||
import com.novayaplaneta.ui.screens.auth.LoginScreen
|
||||
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.schedule.ScheduleScreen
|
||||
import com.novayaplaneta.ui.screens.settings.SettingsScreen
|
||||
@@ -19,13 +22,16 @@ import com.novayaplaneta.ui.screens.timer.TimerScreen
|
||||
fun NewPlanetNavigation(
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier,
|
||||
startDestination: String = "login"
|
||||
startDestination: String = "splash"
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = modifier
|
||||
) {
|
||||
composable("splash") {
|
||||
SplashScreen(navController = navController)
|
||||
}
|
||||
composable("login") {
|
||||
LoginScreen(navController = navController)
|
||||
}
|
||||
@@ -38,8 +44,17 @@ fun NewPlanetNavigation(
|
||||
composable("schedule") {
|
||||
ScheduleScreen(navController = navController)
|
||||
}
|
||||
composable("tasks") {
|
||||
TaskScreen()
|
||||
composable(
|
||||
route = "tasks/{scheduleId}",
|
||||
arguments = listOf(
|
||||
navArgument("scheduleId") { type = NavType.StringType }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val scheduleId = backStackEntry.arguments?.getString("scheduleId")
|
||||
TaskScreen(
|
||||
scheduleId = scheduleId,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
composable("timer") {
|
||||
TimerScreen()
|
||||
@@ -48,7 +63,7 @@ fun NewPlanetNavigation(
|
||||
RewardsScreen(navController = navController)
|
||||
}
|
||||
composable("ai") {
|
||||
AIScreen()
|
||||
AIScreen(navController = navController)
|
||||
}
|
||||
composable("settings") {
|
||||
SettingsScreen(navController = navController)
|
||||
|
||||
@@ -1,94 +1,403 @@
|
||||
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.lazy.LazyColumn
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.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.sp
|
||||
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
|
||||
fun AIScreen(
|
||||
navController: androidx.navigation.NavController? = null,
|
||||
viewModel: AIViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
var messageText by remember { mutableStateOf("") }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("ИИ-помощник \"Земля\"") }
|
||||
// Показываем ошибки через Snackbar
|
||||
LaunchedEffect(uiState.error) {
|
||||
uiState.error?.let { error ->
|
||||
snackbarHostState.showSnackbar(
|
||||
message = error,
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
viewModel.clearError()
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
}
|
||||
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenWidthDp = configuration.screenWidthDp
|
||||
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()
|
||||
.padding(paddingValues)
|
||||
.background(color = backgroundColor)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(uiState.messages) { message ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (message.isFromAI) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = message.message,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.isLoading) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Левая панель навигации с логотипом над ней
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width((screenWidthDp * 0.22f).dp.coerceIn(160.dp, 240.dp))
|
||||
.fillMaxHeight()
|
||||
.padding(vertical = 20.dp, horizontal = 16.dp)
|
||||
) {
|
||||
// Логотип над панелью навигации
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
.padding(bottom = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val logoSize = (screenHeightDp * 0.22f).toInt().coerceIn(140, 240).dp
|
||||
NovayaPlanetaLogo(size = logoSize)
|
||||
}
|
||||
|
||||
// Панель навигации
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.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 = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Основная область с AI агентом и чатом
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
// Левая часть: Анимированная картинка AI агента
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width((screenWidthDp * 0.3f).dp.coerceIn(200.dp, 400.dp))
|
||||
.fillMaxHeight(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
AnimatedAIAgent(
|
||||
isSpeaking = uiState.isLoading,
|
||||
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("Введите сообщение...") }
|
||||
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("userId", messageText)
|
||||
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
|
||||
) {
|
||||
Text("Отправить")
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Send,
|
||||
contentDescription = "Отправить",
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AIViewModel @Inject constructor(
|
||||
private val aiRepository: AIRepository
|
||||
private val aiRepository: AIRepository,
|
||||
private val authRepository: com.novayaplaneta.domain.repository.AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(AIUiState())
|
||||
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 {
|
||||
aiRepository.getChatHistory(userId).collect { messages ->
|
||||
_uiState.value = _uiState.value.copy(messages = messages)
|
||||
try {
|
||||
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 {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
aiRepository.sendMessage(userId, message).fold(
|
||||
try {
|
||||
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 ->
|
||||
// Сохраняем 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)
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = error.message
|
||||
error = error.message ?: "Ошибка отправки сообщения"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(error = null)
|
||||
}
|
||||
}
|
||||
|
||||
data class AIUiState(
|
||||
|
||||
@@ -122,23 +122,31 @@ fun LoginScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Поле логина
|
||||
// Поле email
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(height = inputHeight)
|
||||
.background(
|
||||
color = LoginInputLightBlue,
|
||||
color = if (uiState.errorMessage != null && uiState.email.isNotBlank() && !EmailValidator.isValid(uiState.email)) {
|
||||
Color(0xFFFFEBEE)
|
||||
} else {
|
||||
LoginInputLightBlue
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
TextField(
|
||||
value = uiState.login,
|
||||
onValueChange = { viewModel.onLoginChange(it) },
|
||||
value = uiState.email,
|
||||
onValueChange = { viewModel.onEmailChange(it) },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "Введи логин",
|
||||
text = "Введи email",
|
||||
fontSize = inputTextSize,
|
||||
color = Color.Gray.copy(alpha = 0.7f)
|
||||
)
|
||||
@@ -158,10 +166,22 @@ fun LoginScreen(
|
||||
fontSize = inputTextSize
|
||||
),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Поле пароля
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -208,12 +228,12 @@ fun LoginScreen(
|
||||
Button(
|
||||
onClick = {
|
||||
if (uiState.isFormValid) {
|
||||
// Переход на экран расписания при заполненных полях
|
||||
viewModel.login {
|
||||
// Переход на экран расписания после успешного входа
|
||||
navController.navigate("schedule") {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
// Также вызываем логин для проверки через API (в фоне)
|
||||
viewModel.login { }
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
@@ -256,12 +276,12 @@ fun LoginScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
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 = "Нет логина и пароля?",
|
||||
fontSize = linkTextSize,
|
||||
color = LoginGreenAccent,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.clickable {
|
||||
navController.navigate("registration")
|
||||
}
|
||||
@@ -271,7 +291,7 @@ fun LoginScreen(
|
||||
text = "Не помнишь пароль?",
|
||||
fontSize = linkTextSize,
|
||||
color = LoginGreenAccent,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.clickable {
|
||||
navController.navigate("forgot_password")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.novayaplaneta.ui.screens.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novayaplaneta.domain.repository.AuthRepository
|
||||
import com.novayaplaneta.domain.usecase.LoginUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -12,23 +12,25 @@ import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository
|
||||
private val loginUseCase: LoginUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(LoginUiState())
|
||||
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun onLoginChange(login: String) {
|
||||
fun onEmailChange(email: String) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
login = login,
|
||||
isFormValid = isFormValid(login, _uiState.value.password)
|
||||
email = email,
|
||||
isFormValid = isFormValid(email, _uiState.value.password),
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
|
||||
fun onPasswordChange(password: String) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
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 {
|
||||
_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 ->
|
||||
_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 {
|
||||
return login.isNotBlank() && password.isNotBlank()
|
||||
private fun isFormValid(email: String, password: String): Boolean {
|
||||
return email.isNotBlank() && password.isNotBlank() && EmailValidator.isValid(email)
|
||||
}
|
||||
}
|
||||
|
||||
data class LoginUiState(
|
||||
val login: String = "",
|
||||
val email: String = "",
|
||||
val password: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val isFormValid: Boolean = false,
|
||||
|
||||
@@ -127,7 +127,7 @@ fun RegistrationScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Поле логина
|
||||
// Поле полного имени
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -139,11 +139,11 @@ fun RegistrationScreen(
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
TextField(
|
||||
value = uiState.login,
|
||||
onValueChange = { viewModel.onLoginChange(it) },
|
||||
value = uiState.fullName,
|
||||
onValueChange = { viewModel.onFullNameChange(it) },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "Введи логин",
|
||||
text = "Введи полное имя",
|
||||
fontSize = inputTextSize,
|
||||
color = Color.Gray.copy(alpha = 0.7f)
|
||||
)
|
||||
@@ -168,12 +168,20 @@ fun RegistrationScreen(
|
||||
}
|
||||
|
||||
// Поле пароля
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(height = inputHeight)
|
||||
.background(
|
||||
color = LoginInputLightBlue,
|
||||
color = if (uiState.passwordError != null) {
|
||||
Color(0xFFFFEBEE)
|
||||
} else {
|
||||
LoginInputLightBlue
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
@@ -204,17 +212,36 @@ fun RegistrationScreen(
|
||||
fontSize = inputTextSize
|
||||
),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Поле повторения пароля
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(height = inputHeight)
|
||||
.background(
|
||||
color = LoginInputLightBlue,
|
||||
color = if (uiState.passwordError != null) {
|
||||
Color(0xFFFFEBEE)
|
||||
} else {
|
||||
LoginInputLightBlue
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
@@ -245,9 +272,11 @@ fun RegistrationScreen(
|
||||
fontSize = inputTextSize
|
||||
),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
isError = uiState.passwordError != null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Поле email
|
||||
Column(
|
||||
@@ -309,58 +338,26 @@ 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)
|
||||
)
|
||||
}
|
||||
|
||||
// Кнопка "Зарегистрироваться" - адаптивный размер
|
||||
Button(
|
||||
onClick = {
|
||||
if (uiState.isFormValid) {
|
||||
viewModel.register {
|
||||
// Переход на экран расписания после успешной регистрации
|
||||
navController.navigate("schedule") {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
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) {
|
||||
.height(height = inputHeight),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
enabled = uiState.isFormValid && !uiState.isLoading,
|
||||
colors = if (uiState.isFormValid) {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = LoginGreenAccent,
|
||||
contentColor = Color.White
|
||||
@@ -373,21 +370,6 @@ fun RegistrationScreen(
|
||||
disabledContentColor = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.onReadyClick {
|
||||
navController.navigate("login") {
|
||||
popUpTo("login") { inclusive = false }
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(height = inputHeight),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
enabled = isButtonEnabled,
|
||||
colors = buttonColor
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
@@ -397,7 +379,7 @@ fun RegistrationScreen(
|
||||
} else {
|
||||
val buttonTextSize = (screenHeightDp * 0.027f).toInt().coerceIn(22, 34).sp
|
||||
Text(
|
||||
text = "Готово!",
|
||||
text = "Зарегистрироваться",
|
||||
fontSize = buttonTextSize,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
@@ -2,8 +2,8 @@ package com.novayaplaneta.ui.screens.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novayaplaneta.domain.usecase.RegisterUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -11,64 +11,74 @@ import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class RegistrationViewModel @Inject constructor() : ViewModel() {
|
||||
class RegistrationViewModel @Inject constructor(
|
||||
private val registerUseCase: RegisterUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(RegistrationUiState())
|
||||
val uiState: StateFlow<RegistrationUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun onLoginChange(login: String) {
|
||||
_uiState.value = _uiState.value.copy(login = login)
|
||||
fun onFullNameChange(fullName: String) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
fullName = fullName,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
|
||||
fun onPasswordChange(password: String) {
|
||||
_uiState.value = _uiState.value.copy(password = password)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
password = password,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
|
||||
fun onConfirmPasswordChange(password: String) {
|
||||
_uiState.value = _uiState.value.copy(confirmPassword = password)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
confirmPassword = password,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
|
||||
fun onEmailChange(email: String) {
|
||||
_uiState.value = _uiState.value.copy(email = email)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
email = email,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
|
||||
fun onCodeChange(code: String) {
|
||||
_uiState.value = _uiState.value.copy(code = code)
|
||||
}
|
||||
fun register(onSuccess: () -> Unit) {
|
||||
if (!_uiState.value.isFormValid) return
|
||||
|
||||
fun onReadyClick(onSuccess: () -> Unit) {
|
||||
val currentState = _uiState.value
|
||||
|
||||
// Если поле кода еще не показано, но все 4 поля заполнены - показываем поле кода
|
||||
if (!currentState.showCodeField && currentState.isBasicFormValid) {
|
||||
_uiState.value = currentState.copy(showCodeField = true)
|
||||
return
|
||||
}
|
||||
|
||||
// Если форма полностью валидна (включая код), выполняем регистрацию
|
||||
if (currentState.isFormValid) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
|
||||
// TODO: Реализовать вызов API для регистрации
|
||||
// Пока что просто эмулируем успешную регистрацию
|
||||
delay(1000)
|
||||
val result = registerUseCase(
|
||||
email = _uiState.value.email,
|
||||
fullName = _uiState.value.fullName,
|
||||
password = _uiState.value.password,
|
||||
role = "CHILD"
|
||||
)
|
||||
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
result.onSuccess { user ->
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, isRegistered = true)
|
||||
onSuccess()
|
||||
}.onFailure { exception ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = exception.message ?: "Ошибка регистрации"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RegistrationUiState(
|
||||
val login: String = "",
|
||||
val fullName: String = "",
|
||||
val password: String = "",
|
||||
val confirmPassword: String = "",
|
||||
val email: String = "",
|
||||
val code: String = "",
|
||||
val showCodeField: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val isRegistered: Boolean = false,
|
||||
val errorMessage: String? = null
|
||||
) {
|
||||
// Валидация email
|
||||
@@ -81,17 +91,19 @@ data class RegistrationUiState(
|
||||
"Введите корректный email"
|
||||
} else null
|
||||
|
||||
// Валидация первых 4 полей (без кода)
|
||||
val isBasicFormValid: Boolean
|
||||
get() = login.isNotBlank() &&
|
||||
// Валидация паролей
|
||||
val passwordError: String?
|
||||
get() = if (confirmPassword.isNotBlank() && password != confirmPassword) {
|
||||
"Пароли не совпадают"
|
||||
} else null
|
||||
|
||||
// Валидация формы
|
||||
val isFormValid: Boolean
|
||||
get() = fullName.isNotBlank() &&
|
||||
password.isNotBlank() &&
|
||||
confirmPassword.isNotBlank() &&
|
||||
isEmailValid &&
|
||||
password == confirmPassword
|
||||
|
||||
// Валидация полной формы (включая код)
|
||||
val isFormValid: Boolean
|
||||
get() = isBasicFormValid &&
|
||||
code.isNotBlank()
|
||||
password == confirmPassword &&
|
||||
password.length >= 6
|
||||
}
|
||||
|
||||
|
||||
@@ -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.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.Person
|
||||
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.platform.LocalConfiguration
|
||||
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.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.novayaplaneta.ui.components.NovayaPlanetaLogo
|
||||
import com.novayaplaneta.ui.theme.*
|
||||
@@ -177,8 +181,26 @@ fun RewardsScreen(
|
||||
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(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -195,13 +217,42 @@ fun RewardsScreen(
|
||||
items(uiState.rewards) { reward ->
|
||||
RewardCard(
|
||||
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
|
||||
fun RewardCard(
|
||||
reward: com.novayaplaneta.domain.model.Reward,
|
||||
screenHeightDp: Int
|
||||
screenHeightDp: Int,
|
||||
accentGreen: Color,
|
||||
onClaimClick: () -> Unit = {},
|
||||
onDeleteClick: () -> Unit = {}
|
||||
) {
|
||||
val cardHeight = 180.dp
|
||||
val isClaimed = reward.isClaimed
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(cardHeight)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(color = Color.LightGray)
|
||||
.background(color = if (isClaimed) accentGreen.copy(alpha = 0.3f) else Color.LightGray)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
@@ -264,14 +319,14 @@ fun RewardCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Star,
|
||||
contentDescription = reward.title,
|
||||
modifier = Modifier.size(60.dp),
|
||||
tint = AccentGold
|
||||
tint = if (isClaimed) accentGreen else AccentGold
|
||||
)
|
||||
}
|
||||
|
||||
@@ -280,9 +335,12 @@ fun RewardCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(0.4f)
|
||||
.background(color = Color.LightGray)
|
||||
.background(color = if (isClaimed) accentGreen.copy(alpha = 0.3f) else Color.LightGray)
|
||||
.padding(12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp
|
||||
Text(
|
||||
@@ -292,6 +350,235 @@ fun RewardCard(
|
||||
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.viewModelScope
|
||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
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() {
|
||||
|
||||
private val _uiState = MutableStateFlow(RewardsUiState())
|
||||
val uiState: StateFlow<RewardsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var currentUserId: String? = null
|
||||
|
||||
init {
|
||||
loadDefaultRewards()
|
||||
loadUserId()
|
||||
}
|
||||
|
||||
private fun loadDefaultRewards() {
|
||||
// Создаем список наград по умолчанию
|
||||
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) {
|
||||
private fun loadUserId() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
rewardRepository.getRewards(userId).collect { rewards ->
|
||||
try {
|
||||
val user = authRepository.getCurrentUser().first()
|
||||
currentUserId = user?.id
|
||||
if (currentUserId != null) {
|
||||
loadRewards()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
rewards = if (rewards.isEmpty()) _uiState.value.rewards else rewards,
|
||||
isLoading = false
|
||||
errorMessage = e.message ?: "Ошибка загрузки пользователя"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun earnReward(userId: String, rewardId: String) {
|
||||
fun loadRewards(isClaimed: Boolean? = null) {
|
||||
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(
|
||||
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
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
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.filled.Add
|
||||
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.Public
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
@@ -19,6 +22,9 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -171,28 +177,100 @@ fun ScheduleScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
// Основной контент: только сегодняшняя дата (опущена ниже)
|
||||
DateSection(
|
||||
date = dateOnly,
|
||||
// Основной контент: список расписаний
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = accentGreen)
|
||||
}
|
||||
} 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,
|
||||
tasks = uiState.tasks,
|
||||
onAddClick = { viewModel.showAddDialog() }
|
||||
onAddClick = { viewModel.showAddDialog() },
|
||||
onGenerateClick = { viewModel.showGenerateDialog() },
|
||||
onScheduleClick = { scheduleId ->
|
||||
navController?.navigate("tasks/$scheduleId")
|
||||
},
|
||||
onDeleteClick = { scheduleId ->
|
||||
viewModel.deleteSchedule(scheduleId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Диалог выбора задачи
|
||||
// Диалог создания расписания
|
||||
if (uiState.showAddDialog) {
|
||||
AddTaskDialog(
|
||||
selectedTaskType = uiState.selectedTaskType,
|
||||
onTaskTypeSelected = { viewModel.selectTaskType(it) },
|
||||
onSelect = { viewModel.addTask() },
|
||||
CreateScheduleDialog(
|
||||
title = uiState.newScheduleTitle,
|
||||
description = uiState.newScheduleDescription,
|
||||
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() },
|
||||
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
|
||||
fun DateSection(
|
||||
date: String,
|
||||
fun SchedulesListSection(
|
||||
schedules: List<com.novayaplaneta.domain.model.Schedule>,
|
||||
availableRewards: List<com.novayaplaneta.domain.model.Reward>,
|
||||
dateOnly: String,
|
||||
dayOfWeek: String,
|
||||
dateCardColor: Color,
|
||||
accentGreen: Color,
|
||||
screenHeightDp: Int,
|
||||
tasks: List<TaskType>,
|
||||
onAddClick: () -> Unit
|
||||
onAddClick: () -> Unit,
|
||||
onGenerateClick: () -> Unit,
|
||||
onScheduleClick: (String) -> Unit,
|
||||
onDeleteClick: (String) -> Unit
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Дата и кнопка + в одной строке
|
||||
// Дата и кнопки в одной строке
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
@@ -267,7 +349,7 @@ fun DateSection(
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = date,
|
||||
text = dateOnly,
|
||||
fontSize = dateTextSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
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(
|
||||
onClick = onAddClick,
|
||||
@@ -296,84 +392,170 @@ fun DateSection(
|
||||
}
|
||||
}
|
||||
|
||||
// Задачи в ряд
|
||||
if (tasks.isNotEmpty()) {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items(tasks) { task ->
|
||||
TaskCard(
|
||||
taskType = task,
|
||||
screenHeightDp = screenHeightDp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TaskCard(
|
||||
taskType: TaskType,
|
||||
screenHeightDp: Int
|
||||
) {
|
||||
val cardWidth = 200.dp
|
||||
val cardHeight = 180.dp
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(cardWidth)
|
||||
.height(cardHeight)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(color = Color.LightGray)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Верхняя часть (для изображения)
|
||||
// Список расписаний
|
||||
if (schedules.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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),
|
||||
.padding(vertical = 48.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp
|
||||
Text(
|
||||
text = taskType.title,
|
||||
fontSize = textSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Black,
|
||||
textAlign = TextAlign.Center
|
||||
text = "Нет расписаний",
|
||||
fontSize = 20.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items(schedules) { schedule ->
|
||||
val reward = schedule.rewardId?.let { rewardId ->
|
||||
availableRewards.find { it.id == rewardId }
|
||||
}
|
||||
ScheduleCard(
|
||||
schedule = schedule,
|
||||
reward = reward,
|
||||
screenHeightDp = screenHeightDp,
|
||||
accentGreen = accentGreen,
|
||||
dateCardColor = dateCardColor,
|
||||
onClick = { onScheduleClick(schedule.id) },
|
||||
onDeleteClick = { onDeleteClick(schedule.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddTaskDialog(
|
||||
selectedTaskType: TaskType?,
|
||||
onTaskTypeSelected: (TaskType) -> Unit,
|
||||
onSelect: () -> Unit,
|
||||
fun ScheduleCard(
|
||||
schedule: com.novayaplaneta.domain.model.Schedule,
|
||||
reward: com.novayaplaneta.domain.model.Reward?,
|
||||
screenHeightDp: Int,
|
||||
accentGreen: Color,
|
||||
dateCardColor: Color,
|
||||
onClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
val dateFormatter = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
val scheduleDate = schedule.date.toLocalDate().format(dateFormatter)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = dateCardColor)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
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 = schedule.description ?: "",
|
||||
fontSize = 16.sp,
|
||||
color = Color.Black
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
fun CreateScheduleDialog(
|
||||
title: String,
|
||||
description: String,
|
||||
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,
|
||||
accentGreen: Color,
|
||||
screenHeightDp: Int
|
||||
screenHeightDp: Int,
|
||||
isLoading: Boolean
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.8f)
|
||||
.fillMaxWidth(0.9f)
|
||||
.wrapContentHeight(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
@@ -385,39 +567,104 @@ fun AddTaskDialog(
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Заголовок
|
||||
val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp
|
||||
Text(
|
||||
text = "Выберите задачу",
|
||||
text = "Создать расписание",
|
||||
fontSize = titleSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Black
|
||||
)
|
||||
|
||||
// Опции выбора
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// Подарок
|
||||
TaskOption(
|
||||
taskType = TaskType.Gift,
|
||||
isSelected = selectedTaskType == TaskType.Gift,
|
||||
onClick = { onTaskTypeSelected(TaskType.Gift) },
|
||||
accentGreen = accentGreen,
|
||||
screenHeightDp = screenHeightDp
|
||||
// Поле названия
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = onTitleChange,
|
||||
label = { Text("Название") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
// Кушать ложкой
|
||||
TaskOption(
|
||||
taskType = TaskType.EatWithSpoon,
|
||||
isSelected = selectedTaskType == TaskType.EatWithSpoon,
|
||||
onClick = { onTaskTypeSelected(TaskType.EatWithSpoon) },
|
||||
accentGreen = accentGreen,
|
||||
screenHeightDp = screenHeightDp
|
||||
// Поле описания
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = onDescriptionChange,
|
||||
label = { Text("Описание") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Кнопка "Назад"
|
||||
// Кнопка "Отмена"
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
@@ -435,23 +682,24 @@ fun AddTaskDialog(
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.LightGray,
|
||||
contentColor = Color.Black
|
||||
)
|
||||
),
|
||||
enabled = !isLoading
|
||||
) {
|
||||
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
||||
Text(
|
||||
text = "Назад",
|
||||
text = "Отмена",
|
||||
fontSize = buttonTextSize,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
// Кнопка "Выбрать"
|
||||
// Кнопка "Создать"
|
||||
Button(
|
||||
onClick = onSelect,
|
||||
onClick = onCreate,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(56.dp),
|
||||
enabled = selectedTaskType != null,
|
||||
enabled = title.isNotBlank() && !isLoading,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentGreen,
|
||||
@@ -460,9 +708,15 @@ fun AddTaskDialog(
|
||||
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 = "Выбрать",
|
||||
text = "Создать",
|
||||
fontSize = buttonTextSize,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
@@ -471,36 +725,7 @@ 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.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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed class TaskType(val title: String) {
|
||||
@@ -17,52 +27,277 @@ sealed class TaskType(val title: String) {
|
||||
|
||||
@HiltViewModel
|
||||
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() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ScheduleUiState())
|
||||
val uiState: StateFlow<ScheduleUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadSchedules(userId: String) {
|
||||
init {
|
||||
loadSchedules()
|
||||
loadRewards()
|
||||
}
|
||||
|
||||
private fun loadRewards() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
getSchedulesUseCase(userId).collect { schedules ->
|
||||
try {
|
||||
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(
|
||||
schedules = schedules,
|
||||
isLoading = false
|
||||
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(
|
||||
schedules = filteredSchedules,
|
||||
isLoading = false,
|
||||
errorMessage = null
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = e.message ?: "Ошибка загрузки расписаний"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
_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(
|
||||
tasks = newTasks,
|
||||
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(
|
||||
val schedules: List<com.novayaplaneta.domain.model.Schedule> = emptyList(),
|
||||
val tasks: List<TaskType> = emptyList(),
|
||||
val schedules: List<Schedule> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@@ -210,12 +210,12 @@ fun SettingsScreen(
|
||||
val textSize = (screenHeightDp * 0.025f).toInt().coerceIn(20, 28).sp
|
||||
UserInfoRow(
|
||||
label = "Имя:",
|
||||
value = user.name,
|
||||
value = user.fullName,
|
||||
textSize = textSize
|
||||
)
|
||||
UserInfoRow(
|
||||
label = "Логин:",
|
||||
value = "${user.name}12", // Используем имя + число как логин
|
||||
value = "${user.fullName}12", // Используем имя + число как логин
|
||||
textSize = textSize
|
||||
)
|
||||
UserInfoRow(
|
||||
|
||||
@@ -48,10 +48,9 @@ class SettingsViewModel @Inject constructor(
|
||||
// Заглушка с тестовыми данными
|
||||
return User(
|
||||
id = "user_123",
|
||||
name = "Коля",
|
||||
fullName = "Коля",
|
||||
email = "kolya12@mail.ru",
|
||||
role = UserRole.CHILD,
|
||||
token = null
|
||||
role = UserRole.CHILD
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +1,443 @@
|
||||
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.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.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
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
|
||||
fun TaskScreen(
|
||||
scheduleId: String? = null,
|
||||
navController: NavController? = null,
|
||||
viewModel: TaskViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenHeightDp = configuration.screenHeightDp
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Задания") }
|
||||
)
|
||||
// Цвета из autism-friendly палитры
|
||||
val backgroundColor = LoginBackgroundTurquoise
|
||||
val accentGreen = LoginGreenAccent
|
||||
val dateCardColor = LoginInputLightBlue
|
||||
|
||||
// Загружаем задачи при открытии экрана
|
||||
LaunchedEffect(scheduleId) {
|
||||
scheduleId?.let {
|
||||
viewModel.loadTasks(it)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
.background(color = backgroundColor)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentWidth()
|
||||
.fillMaxSize()
|
||||
.padding(24.dp)
|
||||
) {
|
||||
// Верхняя панель с кнопкой назад и кнопкой добавления
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
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 {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items(uiState.tasks) { task ->
|
||||
// Получаем состояние таймера из Flow
|
||||
val timerStates by viewModel.timerStates.collectAsState()
|
||||
val timerState = timerStates[task.id]
|
||||
|
||||
TaskCard(
|
||||
task = task,
|
||||
timerState = timerState,
|
||||
accentGreen = accentGreen,
|
||||
dateCardColor = dateCardColor,
|
||||
screenHeightDp = screenHeightDp,
|
||||
onStartTimer = { duration ->
|
||||
viewModel.startTimer(task.id, duration)
|
||||
},
|
||||
onPauseTimer = {
|
||||
viewModel.pauseTimer(task.id)
|
||||
},
|
||||
onResumeTimer = {
|
||||
viewModel.resumeTimer(task.id)
|
||||
},
|
||||
onStopTimer = {
|
||||
viewModel.stopTimer(task.id)
|
||||
},
|
||||
onDelete = {
|
||||
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()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = dateCardColor)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = task.title,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Black
|
||||
)
|
||||
|
||||
task.description?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
Checkbox(
|
||||
checked = task.completed,
|
||||
onCheckedChange = { viewModel.completeTask(task.id) }
|
||||
}
|
||||
|
||||
// Таймер или кнопка удаления
|
||||
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.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 kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
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() {
|
||||
|
||||
private val _uiState = MutableStateFlow(TaskUiState())
|
||||
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) {
|
||||
currentScheduleId = scheduleId
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
taskRepository.getTasks(scheduleId).collect { tasks ->
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
|
||||
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(
|
||||
tasks = tasks,
|
||||
isLoading = false
|
||||
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(
|
||||
isLoading = false,
|
||||
errorMessage = e.message ?: "Ошибка загрузки задач"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun completeTask(taskId: String) {
|
||||
fun loadTaskById(taskId: String) {
|
||||
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(
|
||||
val tasks: List<com.novayaplaneta.domain.model.Task> = emptyList(),
|
||||
val isLoading: Boolean = false
|
||||
val tasks: List<Task> = emptyList(),
|
||||
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 OnSurfaceDark = Color(0xFFFFFFFF)
|
||||
|
||||
// Accent Colors - мягкие пастельные тона для комфорта детей с РАС
|
||||
val AccentGreen = Color(0xFF7FD97F) // Мягкий пастельный зеленый
|
||||
val AccentOrange = Color(0xFFFFB88C) // Мягкий пастельный персиковый
|
||||
val AccentGold = Color(0xFFFFE68C) // Мягкий пастельный желтый
|
||||
// Accent Colors - очень мягкие пастельные тона для максимального комфорта детей с РАС
|
||||
val AccentGreen = Color(0xFFA8D5BA) // Очень мягкий мятно-зеленый (низкая насыщенность)
|
||||
val AccentOrange = Color(0xFFFFD4B3) // Очень мягкий персиковый (теплый, успокаивающий)
|
||||
val AccentGold = Color(0xFFFFEEC7) // Очень мягкий бежево-желтый (кремовый оттенок)
|
||||
|
||||
// Status Colors - приглушенные для комфорта
|
||||
val SuccessColor = Color(0xFF7FD97F) // Мягкий зеленый
|
||||
val WarningColor = Color(0xFFFFB88C) // Мягкий персиковый
|
||||
val ErrorColor = Color(0xFFFF9E9E) // Мягкий розовый вместо яркого красного
|
||||
// Status Colors - очень приглушенные для комфорта
|
||||
val SuccessColor = Color(0xFFA8D5BA) // Очень мягкий зеленый
|
||||
val WarningColor = Color(0xFFFFD4B3) // Очень мягкий персиковый
|
||||
val ErrorColor = Color(0xFFFFC8C8) // Очень мягкий розовый (вместо яркого красного)
|
||||
|
||||
// Цвета для экрана авторизации (благоприятные для РАС из PDF)
|
||||
val LoginBackgroundTurquoise = Color(0xFFDAE7E9) // Мягкий голубой фон
|
||||
val LoginCardLightBlue = Color(0xFFBCDAEC) // Спокойный светло-голубой
|
||||
val LoginInputLightBlue = Color(0xFFBCDAEC) // Для полей ввода
|
||||
val LoginButtonBlue = Color(0xFFBCDAEC) // Для кнопки
|
||||
val LoginGreenAccent = Color(0xFF80EF80) // Пастельно-зелёный акцент
|
||||
val LoginGreenSoft = Color(0xFFC5E6C5) // Мягкий пастельно-зелёный
|
||||
val LoginGreenDark = Color(0xFF80EF80) // Пастельно-зелёный темнее
|
||||
// Цвета для экрана авторизации (оптимизированные для РАС - очень мягкие тона)
|
||||
val LoginBackgroundTurquoise = Color(0xFFE8F4F5) // Очень мягкий мятный фон (еще мягче)
|
||||
val LoginCardLightBlue = Color(0xFFD4E8F0) // Очень мягкий голубой для карточек
|
||||
val LoginInputLightBlue = Color(0xFFD4E8F0) // Очень мягкий голубой для полей ввода
|
||||
val LoginButtonBlue = Color(0xFFD4E8F0) // Очень мягкий голубой для кнопок
|
||||
val LoginGreenAccent = Color(0xFF95D5A3) // Очень мягкий зеленый акцент (приглушенный)
|
||||
val LoginGreenSoft = Color(0xFFD1E8D7) // Очень мягкий светло-зеленый
|
||||
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"
|
||||
coroutines = "1.9.0"
|
||||
ksp = "2.1.0-1.0.28"
|
||||
datastore = "1.1.1"
|
||||
|
||||
[libraries]
|
||||
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-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]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
|
||||
Reference in New Issue
Block a user