Compare commits

...

10 Commits

97 changed files with 5114 additions and 857 deletions

13
.idea/deviceManager.xml generated Normal file
View 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>

View File

@@ -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)

View File

@@ -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"

View File

@@ -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() {

View File

@@ -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
}
}

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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?
)

View File

@@ -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()
)
}

View File

@@ -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
)
}

View File

@@ -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()
)
}

View File

@@ -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
)
}

View 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>
}

View 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>
}

View File

@@ -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
}
}

View File

@@ -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>
}

View File

@@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class ChatRequest(
val message: String
val message: String,
val conversation_id: String? = null
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -0,0 +1,10 @@
package com.novayaplaneta.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class RefreshRequest(
val refresh_token: String
)

View File

@@ -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
)

View File

@@ -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,
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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

View 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
)

View File

@@ -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
)

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -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))
if (response.isSuccessful && response.body() != null) {
val aiResponse = response.body()!!.message
// Send request to API
val request = ChatRequest(message = message, conversation_id = conversationId)
val response = aiApi.chat(request)
// Save AI response
val aiMessage = ChatMessage(
id = UUID.randomUUID().toString(),
message = aiResponse,
isFromAI = true,
timestamp = LocalDateTime.now(),
userId = userId
if (response.isSuccessful && response.body() != null) {
val chatResponse = response.body()!!
// Save AI response
val aiMessage = ChatMessage(
id = UUID.randomUUID().toString(),
message = chatResponse.response,
isFromAI = true,
timestamp = LocalDateTime.now(),
userId = userId
)
chatMessageDao.insertMessage(aiMessage.toEntity())
Result.success(
ChatResponse(
response = chatResponse.response,
conversationId = chatResponse.conversation_id
)
chatMessageDao.insertMessage(aiMessage.toEntity())
Result.success(aiResponse)
} else {
Result.failure(Exception("Failed to get AI response"))
}
)
} else {
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()
}
}
}
}

View File

@@ -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 = me.id,
fullName = me.full_name,
email = me.email,
role = UserRole.valueOf(me.role),
createdAt = me.created_at,
updatedAt = me.updated_at
)
saveUser(user)
Result.success(user)
} else {
val errorCode = meResponse.code()
val errorBody = meResponse.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to get user info: HTTP $errorCode - $errorBody"))
}
} else {
val errorCode = response.code()
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Login failed: HTTP $errorCode - $errorBody"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun refresh(): Result<Unit> {
return try {
val refreshToken = tokenManager.getRefreshToken()
if (refreshToken == null) {
return Result.failure(Exception("No refresh token"))
}
val response = authApi.refresh(RefreshRequest(refreshToken))
if (response.isSuccessful && response.body() != null) {
val tokenResponse = response.body()!!
tokenManager.saveTokens(tokenResponse.access_token, tokenResponse.refresh_token)
Result.success(Unit)
} else {
tokenManager.clearTokens()
Result.failure(Exception("Refresh failed"))
}
} catch (e: Exception) {
tokenManager.clearTokens()
Result.failure(e)
}
}
override suspend fun getMe(): Result<User> {
return try {
val response = authApi.getMe()
if (response.isSuccessful && response.body() != null) {
val me = response.body()!!
val user = User(
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"))
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?> {

View File

@@ -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
}
// Сохраняем в локальную БД
rewards.forEach { reward ->
rewardDao.insertReward(reward.toEntity())
}
Result.success(Unit)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to load rewards: $errorBody (code: ${response.code()})"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getRewardById(id: String): Reward? {
return rewardDao.getRewardById(id)?.toDomain()
override fun getRewards(userId: String): Flow<List<Reward>> {
return _rewardsCache.asStateFlow()
}
override suspend fun createReward(reward: Reward) {
rewardDao.insertReward(reward.toEntity())
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) {
rewardDao.deleteReward(id)
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)
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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 loadTasks(scheduleId: String): Result<Unit> {
return try {
val response = backendApi.getTasksByScheduleId(scheduleId)
if (response.isSuccessful && response.body() != null) {
val tasks = response.body()!!.map { it.toDomain() }.sortedBy { it.order }
// Обновляем кэш
val cache = _tasksCache.getOrPut(scheduleId) { MutableStateFlow(emptyList()) }
cache.value = tasks
// Сохраняем в локальную БД
tasks.forEach { task ->
taskDao.insertTask(task.toEntity())
}
Result.success(Unit)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to load tasks: $errorBody (code: ${response.code()})"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getTaskById(id: String): Task? {
return taskDao.getTaskById(id)?.toDomain()
override fun getTasks(scheduleId: String): Flow<List<Task>> {
val cache = _tasksCache.getOrPut(scheduleId) { MutableStateFlow(emptyList()) }
return cache.asStateFlow()
}
override suspend fun createTask(task: Task) {
taskDao.insertTask(task.toEntity())
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
}
}
// Удаляем из локальной БД
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) {
taskDao.completeTask(id)
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)
}
}
}

View File

@@ -26,7 +26,9 @@ object DatabaseModule {
context,
NewPlanetDatabase::class.java,
"newplanet_database"
).build()
)
.fallbackToDestructiveMigration() // Пересоздает БД при изменении схемы (для разработки)
.build()
}
@Provides

View File

@@ -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)
}
}

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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 {

View File

@@ -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>>
)

View File

@@ -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)

View File

@@ -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>
}

View File

@@ -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)
}

View File

@@ -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>
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -7,8 +7,17 @@ import javax.inject.Inject
class CreateScheduleUseCase @Inject constructor(
private val repository: ScheduleRepository
) {
suspend operator fun invoke(schedule: Schedule) {
repository.createSchedule(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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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(
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
}
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()
.background(color = backgroundColor)
) {
Row(
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
// Левая панель навигации с логотипом над ней
Column(
modifier = Modifier
.weight(1f)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
.width((screenWidthDp * 0.22f).dp.coerceIn(160.dp, 240.dp))
.fillMaxHeight()
.padding(vertical = 20.dp, horizontal = 16.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
)
}
// Логотип над панелью навигации
Box(
modifier = Modifier
.fillMaxWidth()
.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 = { }
)
}
}
if (uiState.isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
// Основная область с AI агентом и чатом
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
.weight(1f)
.fillMaxHeight()
.padding(24.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
OutlinedTextField(
value = messageText,
onValueChange = { messageText = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Введите сообщение...") }
)
Button(
onClick = {
if (messageText.isNotBlank()) {
viewModel.sendMessage("userId", messageText)
messageText = ""
// Левая часть: Анимированная картинка 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(
text = "Напишите сообщение...",
color = Color.Gray.copy(alpha = 0.7f)
)
},
singleLine = false,
maxLines = 3,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedBorderColor = accentGreen,
unfocusedBorderColor = Color.Gray
),
shape = RoundedCornerShape(20.dp)
)
Button(
onClick = {
if (messageText.isNotBlank()) {
viewModel.sendMessage(messageText)
messageText = ""
}
},
modifier = Modifier
.height(56.dp)
.width(56.dp),
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentGreen,
contentColor = Color.White
),
enabled = messageText.isNotBlank() && !uiState.isLoading
) {
Icon(
imageVector = Icons.Filled.Send,
contentDescription = "Отправить",
modifier = Modifier.size(24.dp)
)
}
}
) {
Text("Отправить")
}
}
}
// Snackbar для отображения ошибок
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
@Composable
fun AnimatedAIAgent(
isSpeaking: Boolean,
screenHeightDp: Int
) {
val infiniteTransition = rememberInfiniteTransition(label = "ai_agent_animation")
// Анимация покачивания Земли - более быстрое когда говорит
val bounceFloat by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 2f * kotlin.math.PI.toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = if (isSpeaking) 400 else 2000,
easing = LinearEasing
),
repeatMode = RepeatMode.Reverse
),
label = "bounce_float"
)
// Интенсивное покачивание когда говорит, легкое когда молчит
val offsetY = if (isSpeaking) {
sin(bounceFloat) * 20f // Активное прыгание при ответе
} else {
sin(bounceFloat) * 8f // Легкое покачивание в покое
}
val earthSize = (screenHeightDp * 0.4f).dp.coerceIn(200.dp, 400.dp)
Box(
modifier = Modifier
.size(earthSize),
contentAlignment = Alignment.Center
) {
// Изображение Земли с покачиванием
androidx.compose.foundation.Image(
painter = painterResource(id = R.drawable.logo_earth),
contentDescription = "AI Агент Земля",
modifier = Modifier
.fillMaxSize()
.offset(y = offsetY.dp),
contentScale = ContentScale.Fit
)
}
}
@Composable
fun ChatBubble(
message: String,
isFromAI: Boolean,
screenHeightDp: Int,
accentGreen: Color
) {
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 20).sp
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isFromAI) Arrangement.Start else Arrangement.End
) {
Card(
modifier = Modifier.widthIn(max = (screenHeightDp * 0.4f).dp.coerceIn(200.dp, 400.dp)),
shape = RoundedCornerShape(
topStart = 20.dp,
topEnd = 20.dp,
bottomStart = if (isFromAI) 4.dp else 20.dp,
bottomEnd = if (isFromAI) 20.dp else 4.dp
),
colors = CardDefaults.cardColors(
containerColor = if (isFromAI) {
LoginGreenSoft // Мягкий зеленый для AI
} else {
LoginGreenAccent // Зеленый акцент для пользователя
}
)
) {
Text(
text = message,
modifier = Modifier.padding(16.dp),
fontSize = textSize,
color = Color.Black,
fontWeight = FontWeight.Medium
)
}
}
}
@Composable
fun NavItem(
icon: androidx.compose.ui.graphics.vector.ImageVector,
text: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val iconSize = 40.dp
val textSize = 18.sp
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.clickable { onClick() }
.background(
color = if (isSelected) Color.White.copy(alpha = 0.3f) else Color.Transparent
)
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = icon,
contentDescription = text,
modifier = Modifier.size(iconSize),
tint = LoginGreenAccent
)
Text(
text = text,
fontSize = textSize,
fontWeight = FontWeight.Bold,
color = Color.Black
)
}
}

View File

@@ -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(

View File

@@ -10,3 +10,6 @@ object EmailValidator {
}

View File

@@ -76,39 +76,33 @@ fun ForgotPasswordScreen(
val horizontalPadding = (screenWidthDp * 0.04f).toInt().coerceIn(24, 48).dp
val verticalPadding = (screenHeightDp * 0.03f).toInt().coerceIn(16, 32).dp
Column(
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalAlignment = Alignment.CenterHorizontally
.padding(horizontal = horizontalPadding, vertical = verticalPadding)
) {
// Логотип вверху слева
val logoSizeByHeight = (screenHeightDp * 0.15f).toInt()
val logoSizeByWidth = screenWidthDp / 5
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(100, 160).dp
// Логотип вверху слева - увеличенный размер
val logoSizeByHeight = (screenHeightDp * 0.28f).toInt()
val logoSizeByWidth = screenWidthDp / 3
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(180, 280).dp
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
NovayaPlanetaLogo(
modifier = Modifier
.padding(bottom = 4.dp)
.clickable {
navController.navigate("login") {
popUpTo("login") { inclusive = false }
}
},
size = logoSize
)
}
Spacer(modifier = Modifier.weight(0.05f))
NovayaPlanetaLogo(
modifier = Modifier
.align(Alignment.TopStart)
.padding(bottom = 4.dp)
.clickable {
navController.navigate("login") {
popUpTo("login") { inclusive = false }
}
},
size = logoSize
)
// Центрированный контент - адаптивная ширина (50-70% экрана)
val contentWidthRatio = if (isLandscape) 0.5f else 0.7f
Column(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(contentWidthRatio),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
@@ -370,8 +364,6 @@ fun ForgotPasswordScreen(
}
}
}
Spacer(modifier = Modifier.weight(0.1f))
}
}
}

View File

@@ -76,33 +76,28 @@ fun LoginScreen(
val horizontalPadding = (screenWidthDp * 0.04f).toInt().coerceIn(24, 48).dp
val verticalPadding = (screenHeightDp * 0.03f).toInt().coerceIn(16, 32).dp
Column(
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalAlignment = Alignment.CenterHorizontally
.padding(horizontal = horizontalPadding, vertical = verticalPadding)
) {
// Логотип вверху слева - уменьшенный размер
val logoSizeByHeight = (screenHeightDp * 0.15f).toInt()
val logoSizeByWidth = screenWidthDp / 5
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(100, 160).dp
// Логотип вверху слева - большой размер
val logoSizeByHeight = (screenHeightDp * 0.28f).toInt()
val logoSizeByWidth = screenWidthDp / 3
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(180, 280).dp
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
NovayaPlanetaLogo(
modifier = Modifier.padding(bottom = 4.dp),
size = logoSize
)
}
Spacer(modifier = Modifier.weight(0.05f))
NovayaPlanetaLogo(
modifier = Modifier
.align(Alignment.TopStart)
.padding(bottom = 4.dp),
size = logoSize
)
// Центрированный контент - адаптивная ширина (50-70% экрана)
val contentWidthRatio = if (isLandscape) 0.5f else 0.7f
Column(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(contentWidthRatio),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
@@ -127,44 +122,64 @@ fun LoginScreen(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Поле логина
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = inputHeight)
.background(
color = LoginInputLightBlue,
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
// Поле email
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TextField(
value = uiState.login,
onValueChange = { viewModel.onLoginChange(it) },
placeholder = {
Text(
text = "Введи логин",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
Box(
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.Text)
)
.height(height = inputHeight)
.background(
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.email,
onValueChange = { viewModel.onEmailChange(it) },
placeholder = {
Text(
text = "Введи email",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
isError = uiState.errorMessage != null && uiState.email.isNotBlank() && !EmailValidator.isValid(uiState.email)
)
}
// Сообщение об ошибке email
if (uiState.errorMessage != null && uiState.email.isNotBlank() && !EmailValidator.isValid(uiState.email)) {
Text(
text = "Введите корректный email",
fontSize = (inputTextSizeValue * 0.8f).toInt().sp,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
// Поле пароля
@@ -213,12 +228,12 @@ fun LoginScreen(
Button(
onClick = {
if (uiState.isFormValid) {
// Переход на экран расписания при заполненных полях
navController.navigate("schedule") {
popUpTo(0) { inclusive = true }
viewModel.login {
// Переход на экран расписания после успешного входа
navController.navigate("schedule") {
popUpTo(0) { inclusive = true }
}
}
// Также вызываем логин для проверки через API (в фоне)
viewModel.login { }
}
},
modifier = Modifier
@@ -228,7 +243,7 @@ fun LoginScreen(
enabled = uiState.isFormValid && !uiState.isLoading,
colors = if (uiState.isFormValid) {
ButtonDefaults.buttonColors(
containerColor = LoginButtonBlue,
containerColor = LoginGreenAccent,
contentColor = Color.White
)
} else {
@@ -261,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")
}
@@ -276,15 +291,13 @@ fun LoginScreen(
text = "Не помнишь пароль?",
fontSize = linkTextSize,
color = LoginGreenAccent,
fontWeight = FontWeight.Medium,
fontWeight = FontWeight.Bold,
modifier = Modifier.clickable {
navController.navigate("forgot_password")
}
)
}
}
Spacer(modifier = Modifier.weight(0.1f))
}
}
}

View File

@@ -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,

View File

@@ -76,39 +76,33 @@ fun RegistrationScreen(
val horizontalPadding = (screenWidthDp * 0.04f).toInt().coerceIn(24, 48).dp
val verticalPadding = (screenHeightDp * 0.03f).toInt().coerceIn(16, 32).dp
Column(
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalAlignment = Alignment.CenterHorizontally
.padding(horizontal = horizontalPadding, vertical = verticalPadding)
) {
// Логотип вверху слева
val logoSizeByHeight = (screenHeightDp * 0.15f).toInt()
val logoSizeByWidth = screenWidthDp / 5
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(100, 160).dp
// Логотип вверху слева - увеличенный размер
val logoSizeByHeight = (screenHeightDp * 0.28f).toInt()
val logoSizeByWidth = screenWidthDp / 3
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(180, 280).dp
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
NovayaPlanetaLogo(
modifier = Modifier
.padding(bottom = 4.dp)
.clickable {
navController.navigate("login") {
popUpTo("login") { inclusive = false }
}
},
size = logoSize
)
}
Spacer(modifier = Modifier.weight(0.05f))
NovayaPlanetaLogo(
modifier = Modifier
.align(Alignment.TopStart)
.padding(bottom = 4.dp)
.clickable {
navController.navigate("login") {
popUpTo("login") { inclusive = false }
}
},
size = logoSize
)
// Центрированный контент - адаптивная ширина (50-70% экрана)
val contentWidthRatio = if (isLandscape) 0.5f else 0.7f
Column(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(contentWidthRatio),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
@@ -133,7 +127,7 @@ fun RegistrationScreen(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Поле логина
// Поле полного имени
Box(
modifier = Modifier
.fillMaxWidth()
@@ -145,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)
)
@@ -174,85 +168,114 @@ fun RegistrationScreen(
}
// Поле пароля
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = inputHeight)
.background(
color = LoginInputLightBlue,
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TextField(
value = uiState.password,
onValueChange = { viewModel.onPasswordChange(it) },
placeholder = {
Text(
text = "Введи пароль",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
visualTransformation = PasswordVisualTransformation(),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
.height(height = inputHeight)
.background(
color = if (uiState.passwordError != null) {
Color(0xFFFFEBEE)
} else {
LoginInputLightBlue
},
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
) {
TextField(
value = uiState.password,
onValueChange = { viewModel.onPasswordChange(it) },
placeholder = {
Text(
text = "Введи пароль",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
visualTransformation = PasswordVisualTransformation(),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = uiState.passwordError != null
)
}
if (uiState.passwordError != null) {
Text(
text = uiState.passwordError!!,
fontSize = (inputTextSizeValue * 0.8f).toInt().sp,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
// Поле повторения пароля
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = inputHeight)
.background(
color = LoginInputLightBlue,
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TextField(
value = uiState.confirmPassword,
onValueChange = { viewModel.onConfirmPasswordChange(it) },
placeholder = {
Text(
text = "Повтори пароль",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
visualTransformation = PasswordVisualTransformation(),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
.height(height = inputHeight)
.background(
color = if (uiState.passwordError != null) {
Color(0xFFFFEBEE)
} else {
LoginInputLightBlue
},
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
) {
TextField(
value = uiState.confirmPassword,
onValueChange = { viewModel.onConfirmPasswordChange(it) },
placeholder = {
Text(
text = "Повтори пароль",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
visualTransformation = PasswordVisualTransformation(),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = uiState.passwordError != null
)
}
}
// Поле email
@@ -315,76 +338,17 @@ fun RegistrationScreen(
}
}
// Поле кода (показывается после нажатия "Готово!" когда все 4 поля заполнены)
if (uiState.showCodeField) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = inputHeight)
.background(
color = LoginInputLightBlue,
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
) {
TextField(
value = uiState.code,
onValueChange = { viewModel.onCodeChange(it) },
placeholder = {
Text(
text = "Введи код из письма на почте",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
}
}
// Кнопка "Готово!" - адаптивный размер
// Активна когда заполнены все 4 поля (до показа кода) или когда заполнен код
val isButtonEnabled = if (!uiState.showCodeField) {
uiState.isBasicFormValid && !uiState.isLoading
} else {
uiState.isFormValid && !uiState.isLoading
}
val buttonColor = if (isButtonEnabled) {
ButtonDefaults.buttonColors(
containerColor = LoginGreenAccent,
contentColor = Color.White
)
} else {
ButtonDefaults.buttonColors(
containerColor = LoginInputLightBlue,
contentColor = Color.Gray,
disabledContainerColor = LoginInputLightBlue,
disabledContentColor = Color.Gray
)
}
// Кнопка "Зарегистрироваться" - адаптивный размер
Button(
onClick = {
viewModel.onReadyClick {
navController.navigate("login") {
popUpTo("login") { inclusive = false }
if (uiState.isFormValid) {
viewModel.register {
// Переход на экран расписания после успешной регистрации
navController.navigate("schedule") {
popUpTo(0) { inclusive = true }
}
}
}
},
@@ -392,8 +356,20 @@ fun RegistrationScreen(
.fillMaxWidth()
.height(height = inputHeight),
shape = RoundedCornerShape(20.dp),
enabled = isButtonEnabled,
colors = buttonColor
enabled = uiState.isFormValid && !uiState.isLoading,
colors = if (uiState.isFormValid) {
ButtonDefaults.buttonColors(
containerColor = LoginGreenAccent,
contentColor = Color.White
)
} else {
ButtonDefaults.buttonColors(
containerColor = LoginInputLightBlue,
contentColor = Color.Gray,
disabledContainerColor = LoginInputLightBlue,
disabledContentColor = Color.Gray
)
}
) {
if (uiState.isLoading) {
CircularProgressIndicator(
@@ -403,15 +379,13 @@ fun RegistrationScreen(
} else {
val buttonTextSize = (screenHeightDp * 0.027f).toInt().coerceIn(22, 34).sp
Text(
text = "Готово!",
text = "Зарегистрироваться",
fontSize = buttonTextSize,
fontWeight = FontWeight.Bold
)
}
}
}
Spacer(modifier = Modifier.weight(0.1f))
}
}
}

View File

@@ -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
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
// Если поле кода еще не показано, но все 4 поля заполнены - показываем поле кода
if (!currentState.showCodeField && currentState.isBasicFormValid) {
_uiState.value = currentState.copy(showCodeField = true)
return
}
val result = registerUseCase(
email = _uiState.value.email,
fullName = _uiState.value.fullName,
password = _uiState.value.password,
role = "CHILD"
)
// Если форма полностью валидна (включая код), выполняем регистрацию
if (currentState.isFormValid) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
// TODO: Реализовать вызов API для регистрации
// Пока что просто эмулируем успешную регистрацию
delay(1000)
_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
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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.*
@@ -76,14 +80,14 @@ fun RewardsScreen(
.fillMaxHeight()
.padding(vertical = 20.dp, horizontal = 16.dp)
) {
// Логотип над панелью навигации (увеличен)
// Логотип над панелью навигации
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
contentAlignment = Alignment.Center
) {
val logoSize = (screenHeightDp * 0.18f).toInt().coerceIn(120, 200).dp
val logoSize = (screenHeightDp * 0.22f).toInt().coerceIn(140, 240).dp
NovayaPlanetaLogo(size = logoSize)
}
@@ -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,18 +335,250 @@ 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
) {
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp
Text(
text = reward.title,
fontSize = textSize,
fontWeight = FontWeight.Bold,
color = Color.Black,
textAlign = TextAlign.Center
)
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp
Text(
text = reward.title,
fontSize = textSize,
fontWeight = FontWeight.Bold,
color = Color.Black,
textAlign = TextAlign.Center
)
Text(
text = "${reward.pointsRequired} очков",
fontSize = textSize * 0.7f,
color = Color.Black.copy(alpha = 0.7f),
textAlign = TextAlign.Center
)
if (isClaimed) {
Text(
text = "Получено",
fontSize = textSize * 0.6f,
color = accentGreen,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
} else {
Button(
onClick = onClaimClick,
modifier = Modifier.padding(top = 4.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentGreen,
contentColor = Color.White
)
) {
Text("Получить", fontSize = 10.sp)
}
}
}
}
}
}
}
@Composable
fun AddRewardButton(
onClick: () -> Unit,
screenHeightDp: Int,
accentGreen: Color
) {
val cardHeight = 180.dp
Box(
modifier = Modifier
.fillMaxWidth()
.height(cardHeight)
.clip(RoundedCornerShape(16.dp))
.background(color = accentGreen)
.clickable(onClick = onClick)
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Добавить награду",
modifier = Modifier.size(60.dp),
tint = Color.White
)
}
}
}
@Composable
fun AddRewardDialog(
rewardTitle: String,
rewardDescription: String,
rewardPoints: Int,
onTitleChange: (String) -> Unit,
onDescriptionChange: (String) -> Unit,
onPointsChange: (Int) -> Unit,
onAdd: () -> Unit,
onDismiss: () -> Unit,
accentGreen: Color,
screenHeightDp: Int,
isLoading: Boolean = false
) {
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth(0.8f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = Color.White
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// Заголовок
val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp
Text(
text = "Добавить награду",
fontSize = titleSize,
fontWeight = FontWeight.Bold,
color = Color.Black
)
// Поле ввода названия награды
val inputTextSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 22).sp
OutlinedTextField(
value = rewardTitle,
onValueChange = onTitleChange,
label = { Text("Название") },
placeholder = {
Text(
text = "Введите название награды",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isLoading,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedBorderColor = accentGreen,
unfocusedBorderColor = Color.Gray
),
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = inputTextSize
)
)
// Поле ввода описания
OutlinedTextField(
value = rewardDescription,
onValueChange = onDescriptionChange,
label = { Text("Описание") },
modifier = Modifier.fillMaxWidth(),
maxLines = 2,
enabled = !isLoading,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedBorderColor = accentGreen,
unfocusedBorderColor = Color.Gray
),
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = inputTextSize
)
)
// Поле ввода очков
OutlinedTextField(
value = rewardPoints.toString(),
onValueChange = {
it.toIntOrNull()?.let { points ->
if (points > 0) onPointsChange(points)
}
},
label = { Text("Очки") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedBorderColor = accentGreen,
unfocusedBorderColor = Color.Gray
),
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = inputTextSize
)
)
// Кнопки внизу
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Кнопка "Назад"
Button(
onClick = onDismiss,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.LightGray,
contentColor = Color.Black
)
) {
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
Text(
text = "Назад",
fontSize = buttonTextSize,
fontWeight = FontWeight.Bold
)
}
// Кнопка "Добавить"
Button(
onClick = onAdd,
modifier = Modifier
.weight(1f)
.height(56.dp),
enabled = rewardTitle.isNotBlank() && !isLoading,
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentGreen,
contentColor = Color.White,
disabledContainerColor = Color.LightGray,
disabledContentColor = Color.Gray
)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White
)
} else {
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
Text(
text = "Добавить",
fontSize = buttonTextSize,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}

View File

@@ -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
)

View File

@@ -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
)
}
}
}
}
}
}
}

View File

@@ -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
@@ -80,14 +86,14 @@ fun ScheduleScreen(
.fillMaxHeight()
.padding(vertical = 20.dp, horizontal = 16.dp)
) {
// Логотип над панелью навигации (увеличен)
// Логотип над панелью навигации
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
contentAlignment = Alignment.Center
) {
val logoSize = (screenHeightDp * 0.18f).toInt().coerceIn(120, 200).dp
val logoSize = (screenHeightDp * 0.22f).toInt().coerceIn(140, 240).dp
NovayaPlanetaLogo(size = logoSize)
}
@@ -171,28 +177,100 @@ fun ScheduleScreen(
Spacer(modifier = Modifier.height(48.dp))
// Основной контент: только сегодняшняя дата (опущена ниже)
DateSection(
date = dateOnly,
dayOfWeek = dayOfWeek,
dateCardColor = dateCardColor,
accentGreen = accentGreen,
screenHeightDp = screenHeightDp,
tasks = uiState.tasks,
onAddClick = { viewModel.showAddDialog() }
)
// Основной контент: список расписаний
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,
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,16 +392,37 @@ fun DateSection(
}
}
// Задачи в ряд
if (tasks.isNotEmpty()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
// Список расписаний
if (schedules.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 48.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Нет расписаний",
fontSize = 20.sp,
color = Color.Gray
)
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
items(tasks) { task ->
TaskCard(
taskType = task,
screenHeightDp = screenHeightDp
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) }
)
}
}
@@ -314,66 +431,131 @@ fun DateSection(
}
@Composable
fun TaskCard(
taskType: TaskType,
screenHeightDp: Int
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 cardWidth = 200.dp
val cardHeight = 180.dp
val dateFormatter = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy")
val scheduleDate = schedule.date.toLocalDate().format(dateFormatter)
Box(
Card(
modifier = Modifier
.width(cardWidth)
.height(cardHeight)
.clip(RoundedCornerShape(16.dp))
.background(color = Color.LightGray)
.fillMaxWidth()
.clickable { onClick() },
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = dateCardColor)
) {
Column(
modifier = Modifier.fillMaxSize()
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Верхняя часть (для изображения)
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),
contentAlignment = Alignment.Center
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp
Column(modifier = Modifier.weight(1f)) {
Text(
text = schedule.title,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Color.Black
)
Text(
text = scheduleDate,
fontSize = 16.sp,
color = accentGreen,
fontWeight = FontWeight.Medium
)
}
IconButton(onClick = onDeleteClick) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Удалить",
tint = Color.Red
)
}
}
if (!schedule.description.isNullOrBlank()) {
Text(
text = taskType.title,
fontSize = textSize,
fontWeight = FontWeight.Bold,
color = Color.Black,
textAlign = TextAlign.Center
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 AddTaskDialog(
selectedTaskType: TaskType?,
onTaskTypeSelected: (TaskType) -> Unit,
onSelect: () -> Unit,
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,12 +708,19 @@ fun AddTaskDialog(
disabledContentColor = Color.Gray
)
) {
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
Text(
text = "Выбрать",
fontSize = buttonTextSize,
fontWeight = FontWeight.Bold
)
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
)
}
}
}
}
@@ -473,34 +728,4 @@ fun AddTaskDialog(
}
}
@Composable
fun TaskOption(
taskType: TaskType,
isSelected: Boolean,
onClick: () -> Unit,
accentGreen: Color,
screenHeightDp: Int
) {
val borderWidth = if (isSelected) 4.dp else 2.dp
val borderColor = if (isSelected) accentGreen else Color.Gray
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.clip(RoundedCornerShape(16.dp))
.border(borderWidth, borderColor, RoundedCornerShape(16.dp))
.background(color = if (isSelected) accentGreen.copy(alpha = 0.1f) else Color.Transparent)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
val textSize = (screenHeightDp * 0.025f).toInt().coerceIn(20, 28).sp
Text(
text = taskType.title,
fontSize = textSize,
fontWeight = FontWeight.Bold,
color = if (isSelected) accentGreen else Color.Black
)
}
}

View File

@@ -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(
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 = schedules,
isLoading = false
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
)

View File

@@ -81,14 +81,14 @@ fun SettingsScreen(
.fillMaxHeight()
.padding(vertical = 20.dp, horizontal = 16.dp)
) {
// Логотип над панелью навигации (увеличен)
// Логотип над панелью навигации
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
contentAlignment = Alignment.Center
) {
val logoSize = (screenHeightDp * 0.18f).toInt().coerceIn(120, 200).dp
val logoSize = (screenHeightDp * 0.22f).toInt().coerceIn(140, 240).dp
NovayaPlanetaLogo(size = logoSize)
}
@@ -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(

View File

@@ -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
)
}

View File

@@ -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 ->
}
Box(
modifier = modifier
.fillMaxSize()
.background(color = backgroundColor)
) {
Column(
modifier = modifier
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.padding(24.dp)
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth()
// Верхняя панель с кнопкой назад и кнопкой добавления
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 ->
Card(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = task.title,
style = MaterialTheme.typography.titleMedium
)
task.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall
)
}
}
Checkbox(
checked = task.completed,
onCheckedChange = { viewModel.completeTask(task.id) }
)
// Получаем состояние таймера из 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(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = dateCardColor)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = task.title,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Color.Black
)
task.description?.let {
Text(
text = it,
fontSize = 16.sp,
color = Color.Black
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
task.duration?.let {
Text(
text = "Длительность: $it мин",
fontSize = 14.sp,
color = Color.Gray
)
}
if (task.completed) {
Text(
text = "✓ Выполнено",
fontSize = 14.sp,
color = accentGreen,
fontWeight = FontWeight.Medium
)
}
}
}
// Таймер или кнопка удаления
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (task.completed) {
// Показываем только иконку выполнения
Icon(
Icons.Filled.PlayArrow,
contentDescription = "Выполнено",
tint = accentGreen,
modifier = Modifier.size(32.dp)
)
} else if (timerState != null) {
// Показываем визуальный таймер
TaskTimer(
timerState = timerState,
accentGreen = accentGreen,
onPause = onPauseTimer,
onResume = onResumeTimer,
onStop = onStopTimer
)
} else {
// Показываем кнопку запуска таймера
IconButton(
onClick = {
task.duration?.let { onStartTimer(it) }
}
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = "Запустить таймер",
tint = accentGreen,
modifier = Modifier.size(32.dp)
)
}
}
IconButton(onClick = onDelete) {
Icon(
Icons.Filled.Delete,
contentDescription = "Удалить",
tint = Color.Red,
modifier = Modifier.size(24.dp)
)
}
}
}
}
}
@Composable
fun TaskTimer(
timerState: TimerState,
accentGreen: Color,
onPause: () -> Unit,
onResume: () -> Unit,
onStop: () -> Unit
) {
val totalSeconds = timerState.durationMinutes * 60
val progress = if (totalSeconds > 0) {
timerState.remainingSeconds.toFloat() / totalSeconds
} else 0f
val timeText = formatTime(timerState.remainingSeconds)
Box(
modifier = Modifier.size(100.dp),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val strokeWidth = 10.dp.toPx()
val radius = size.minDimension / 2 - strokeWidth / 2
// Фоновый круг
drawCircle(
color = Color.Gray.copy(alpha = 0.2f),
radius = radius,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
// Прогресс таймера (обратный - показываем оставшееся время)
val sweepAngle = 360f * progress
drawArc(
color = accentGreen,
startAngle = -90f,
sweepAngle = sweepAngle,
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(8.dp)
) {
Text(
text = timeText,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.Black
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(top = 4.dp)
) {
if (timerState.isRunning) {
IconButton(
onClick = onPause,
modifier = Modifier.size(28.dp)
) {
Icon(
Icons.Filled.Pause,
contentDescription = "Пауза",
tint = accentGreen,
modifier = Modifier.size(20.dp)
)
}
} else {
IconButton(
onClick = onResume,
modifier = Modifier.size(28.dp)
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = "Продолжить",
tint = accentGreen,
modifier = Modifier.size(20.dp)
)
}
}
}
@@ -77,3 +445,264 @@ fun TaskScreen(
}
}
private fun formatTime(seconds: Int): String {
val minutes = seconds / 60
val remainingSeconds = seconds % 60
return String.format("%02d:%02d", minutes, remainingSeconds)
}
@Composable
fun CreateTaskOrRewardDialog(
dialogMode: DialogMode,
taskTitle: String,
taskDescription: String,
taskDuration: String,
rewardTitle: String,
rewardDescription: String,
rewardPoints: Int,
onModeChange: (DialogMode) -> Unit,
onTaskTitleChange: (String) -> Unit,
onTaskDescriptionChange: (String) -> Unit,
onTaskDurationChange: (String) -> Unit,
onRewardTitleChange: (String) -> Unit,
onRewardDescriptionChange: (String) -> Unit,
onRewardPointsChange: (Int) -> Unit,
onCreate: () -> Unit,
onDismiss: () -> Unit,
accentGreen: Color,
screenHeightDp: Int,
isLoading: Boolean,
scheduleId: String?
) {
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = Color.White
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Переключатель режимов
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Кнопка "Задача"
Button(
onClick = { onModeChange(DialogMode.TASK) },
modifier = Modifier
.weight(1f)
.height(48.dp),
enabled = scheduleId != null && !isLoading,
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (dialogMode == DialogMode.TASK) accentGreen else Color.LightGray,
contentColor = if (dialogMode == DialogMode.TASK) Color.White else Color.Black,
disabledContainerColor = Color.LightGray,
disabledContentColor = Color.Gray
)
) {
Text(
text = "Задача",
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
// Кнопка "Награда"
Button(
onClick = { onModeChange(DialogMode.REWARD) },
modifier = Modifier
.weight(1f)
.height(48.dp),
enabled = !isLoading,
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (dialogMode == DialogMode.REWARD) accentGreen else Color.LightGray,
contentColor = if (dialogMode == DialogMode.REWARD) Color.White else Color.Black
)
) {
Text(
text = "Награда",
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
}
// Заголовок
val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp
Text(
text = if (dialogMode == DialogMode.TASK) "Создать задачу" else "Создать награду",
fontSize = titleSize,
fontWeight = FontWeight.Bold,
color = Color.Black
)
when (dialogMode) {
DialogMode.TASK -> {
// Поле названия задачи
OutlinedTextField(
value = taskTitle,
onValueChange = onTaskTitleChange,
label = { Text("Название") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentGreen,
focusedLabelColor = accentGreen
)
)
// Поле описания задачи
OutlinedTextField(
value = taskDescription,
onValueChange = onTaskDescriptionChange,
label = { Text("Описание") },
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
enabled = !isLoading,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentGreen,
focusedLabelColor = accentGreen
)
)
// Поле длительности
OutlinedTextField(
value = taskDuration,
onValueChange = { newValue ->
if (newValue.all { it.isDigit() }) {
onTaskDurationChange(newValue)
}
},
label = { Text("Длительность (минуты)") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentGreen,
focusedLabelColor = accentGreen
)
)
}
DialogMode.REWARD -> {
// Поле названия награды
OutlinedTextField(
value = rewardTitle,
onValueChange = onRewardTitleChange,
label = { Text("Название") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentGreen,
focusedLabelColor = accentGreen
)
)
// Поле описания награды
OutlinedTextField(
value = rewardDescription,
onValueChange = onRewardDescriptionChange,
label = { Text("Описание") },
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
enabled = !isLoading,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentGreen,
focusedLabelColor = accentGreen
)
)
// Поле баллов
OutlinedTextField(
value = rewardPoints.toString(),
onValueChange = { newValue ->
if (newValue.all { it.isDigit() }) {
onRewardPointsChange(newValue.toIntOrNull() ?: 10)
}
},
label = { Text("Баллы") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentGreen,
focusedLabelColor = accentGreen
)
)
}
}
// Кнопки внизу
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Кнопка "Отмена"
Button(
onClick = onDismiss,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.LightGray,
contentColor = Color.Black
),
enabled = !isLoading
) {
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
Text(
text = "Отмена",
fontSize = buttonTextSize,
fontWeight = FontWeight.Bold
)
}
// Кнопка "Создать"
Button(
onClick = onCreate,
modifier = Modifier
.weight(1f)
.height(56.dp),
enabled = when (dialogMode) {
DialogMode.TASK -> taskTitle.isNotBlank() && !isLoading
DialogMode.REWARD -> rewardTitle.isNotBlank() && !isLoading
},
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentGreen,
contentColor = Color.White,
disabledContainerColor = Color.LightGray,
disabledContentColor = Color.Gray
)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White
)
} else {
val buttonTextSize =
(screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
Text(
text = "Создать",
fontSize = buttonTextSize,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
}

View File

@@ -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(
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(
tasks = tasks,
isLoading = false
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
)

View File

@@ -14,22 +14,22 @@ val SurfaceDark = Color(0xFF1A1A1A)
val OnBackgroundDark = Color(0xFFFFFFFF)
val OnSurfaceDark = Color(0xFFFFFFFF)
// Accent Colors
val AccentGreen = Color(0xFF4CAF50)
val AccentOrange = Color(0xFFFF6B35)
val AccentGold = Color(0xFFFFD700)
// Accent Colors - очень мягкие пастельные тона для максимального комфорта детей с РАС
val AccentGreen = Color(0xFFA8D5BA) // Очень мягкий мятно-зеленый (низкая насыщенность)
val AccentOrange = Color(0xFFFFD4B3) // Очень мягкий персиковый (теплый, успокаивающий)
val AccentGold = Color(0xFFFFEEC7) // Очень мягкий бежево-желтый (кремовый оттенок)
// Status Colors
val SuccessColor = Color(0xFF4CAF50)
val WarningColor = Color(0xFFFF6B35)
val ErrorColor = Color(0xFFE53935)
// 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) // Очень мягкий зеленый для темных элементов

View 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>

View File

@@ -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" }