Compare commits

..

8 Commits

96 changed files with 5056 additions and 779 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>

2
.idea/gradle.xml generated
View File

@@ -4,6 +4,7 @@
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" /> <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules"> <option name="modules">
@@ -12,7 +13,6 @@
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
</set> </set>
</option> </option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings> </GradleProjectSettings>
</option> </option>
</component> </component>

View File

@@ -100,6 +100,9 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// DataStore
implementation(libs.datastore.preferences)
// Testing // Testing
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)

View File

@@ -14,6 +14,7 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.NewPlanet"> android:theme="@style/Theme.NewPlanet">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"

View File

@@ -21,7 +21,7 @@ import com.novayaplaneta.data.local.entity.UserEntity
RewardEntity::class, RewardEntity::class,
ChatMessageEntity::class ChatMessageEntity::class
], ],
version = 1, version = 4,
exportSchema = false exportSchema = false
) )
abstract class NewPlanetDatabase : RoomDatabase() { abstract class NewPlanetDatabase : RoomDatabase() {

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 title: String,
val description: String?, val description: String?,
val imageUrl: String?, val imageUrl: String?,
val points: Int, val pointsRequired: Int,
val isClaimed: Boolean = false,
val earnedAt: Long?, // timestamp val earnedAt: Long?, // timestamp
val userId: String val userId: String,
val createdAt: Long? = null, // timestamp
val updatedAt: Long? = null // timestamp
) )

View File

@@ -12,6 +12,7 @@ data class ScheduleEntity(
val description: String?, val description: String?,
val date: Long, // timestamp val date: Long, // timestamp
val createdAt: Long, // timestamp val createdAt: Long, // timestamp
val userId: String val userId: String,
val rewardId: String? = null
) )

View File

@@ -13,6 +13,10 @@ data class TaskEntity(
val completed: Boolean = false, val completed: Boolean = false,
val scheduledTime: Long?, // timestamp val scheduledTime: Long?, // timestamp
val duration: Int? = null, val duration: Int? = null,
val scheduleId: String val scheduleId: String,
val order: Int? = null,
val category: String? = null,
val createdAt: Long? = null, // timestamp
val updatedAt: Long? = null // timestamp
) )

View File

@@ -7,9 +7,10 @@ import androidx.room.PrimaryKey
data class UserEntity( data class UserEntity(
@PrimaryKey @PrimaryKey
val id: String, val id: String,
val name: String, val fullName: String,
val email: String, val email: String,
val role: String, val role: String,
val token: String? val createdAt: String?,
val updatedAt: String?
) )

View File

@@ -12,11 +12,18 @@ fun RewardEntity.toDomain(): Reward {
title = title, title = title,
description = description, description = description,
imageUrl = imageUrl, imageUrl = imageUrl,
points = points, pointsRequired = pointsRequired,
isClaimed = isClaimed,
earnedAt = earnedAt?.let { earnedAt = earnedAt?.let {
LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())
}, },
userId = userId userId = userId,
createdAt = createdAt?.let {
LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())
},
updatedAt = updatedAt?.let {
LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())
}
) )
} }
@@ -26,9 +33,12 @@ fun Reward.toEntity(): RewardEntity {
title = title, title = title,
description = description, description = description,
imageUrl = imageUrl, imageUrl = imageUrl,
points = points, pointsRequired = pointsRequired,
isClaimed = isClaimed,
earnedAt = earnedAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(), earnedAt = earnedAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(),
userId = userId userId = userId,
createdAt = createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(),
updatedAt = updatedAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
) )
} }

View File

@@ -15,7 +15,8 @@ fun ScheduleEntity.toDomain(tasks: List<Task> = emptyList()): Schedule {
tasks = tasks, tasks = tasks,
date = LocalDateTime.ofInstant(Instant.ofEpochMilli(date), ZoneId.systemDefault()), date = LocalDateTime.ofInstant(Instant.ofEpochMilli(date), ZoneId.systemDefault()),
createdAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(createdAt), ZoneId.systemDefault()), createdAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(createdAt), ZoneId.systemDefault()),
userId = userId userId = userId,
rewardId = rewardId
) )
} }
@@ -26,7 +27,8 @@ fun Schedule.toEntity(): ScheduleEntity {
description = description, description = description,
date = date.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(), date = date.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(),
createdAt = createdAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(), createdAt = createdAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(),
userId = userId userId = userId,
rewardId = rewardId
) )
} }

View File

@@ -17,7 +17,15 @@ fun TaskEntity.toDomain(): Task {
LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())
}, },
duration = duration, duration = duration,
scheduleId = scheduleId scheduleId = scheduleId,
order = order ?: 0,
category = category,
createdAt = createdAt?.let {
LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())
},
updatedAt = updatedAt?.let {
LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())
}
) )
} }
@@ -30,7 +38,11 @@ fun Task.toEntity(): TaskEntity {
completed = completed, completed = completed,
scheduledTime = scheduledTime?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(), scheduledTime = scheduledTime?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(),
duration = duration, duration = duration,
scheduleId = scheduleId scheduleId = scheduleId,
order = order,
category = category,
createdAt = createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(),
updatedAt = updatedAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
) )
} }

View File

@@ -7,20 +7,22 @@ import com.novayaplaneta.domain.model.UserRole
fun UserEntity.toDomain(): User { fun UserEntity.toDomain(): User {
return User( return User(
id = id, id = id,
name = name, fullName = fullName,
email = email, email = email,
role = UserRole.valueOf(role), role = UserRole.valueOf(role),
token = token createdAt = createdAt,
updatedAt = updatedAt
) )
} }
fun User.toEntity(): UserEntity { fun User.toEntity(): UserEntity {
return UserEntity( return UserEntity(
id = id, id = id,
name = name, fullName = fullName,
email = email, email = email,
role = role.name, role = role.name,
token = token createdAt = createdAt,
updatedAt = updatedAt
) )
} }

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.ChatRequest
import com.novayaplaneta.data.remote.dto.ChatResponse import com.novayaplaneta.data.remote.dto.ChatResponse
import com.novayaplaneta.data.remote.dto.ClaimRewardResponse
import com.novayaplaneta.data.remote.dto.CompleteTaskResponse
import com.novayaplaneta.data.remote.dto.CreateRewardRequest
import com.novayaplaneta.data.remote.dto.CreateScheduleRequest
import com.novayaplaneta.data.remote.dto.CreateTaskRequest
import com.novayaplaneta.data.remote.dto.LoginRequest import com.novayaplaneta.data.remote.dto.LoginRequest
import com.novayaplaneta.data.remote.dto.LoginResponse import com.novayaplaneta.data.remote.dto.LoginResponse
import com.novayaplaneta.data.remote.dto.RewardDto
import com.novayaplaneta.data.remote.dto.ScheduleDto
import com.novayaplaneta.data.remote.dto.TaskDto
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.PATCH
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface BackendApi { interface BackendApi {
@POST("api/v1/auth/login") @POST("api/v1/auth/login")
@@ -21,5 +33,82 @@ interface BackendApi {
@Header("Authorization") token: String, @Header("Authorization") token: String,
@Body request: ChatRequest @Body request: ChatRequest
): Response<ChatResponse> ): Response<ChatResponse>
@GET("api/v1/schedules")
suspend fun getSchedules(
@Query("skip") skip: Int = 0,
@Query("limit") limit: Int = 100,
@Query("schedule_date") scheduleDate: String? = null
): Response<List<ScheduleDto>>
@POST("api/v1/schedules")
suspend fun createSchedule(
@Body request: CreateScheduleRequest
): Response<ScheduleDto>
@GET("api/v1/schedules/{schedule_id}")
suspend fun getScheduleById(
@Path("schedule_id") scheduleId: String
): Response<ScheduleDto>
@DELETE("api/v1/schedules/{schedule_id}")
suspend fun deleteSchedule(
@Path("schedule_id") scheduleId: String
): Response<Unit>
// Tasks endpoints
@GET("api/v1/tasks/schedule/{schedule_id}")
suspend fun getTasksByScheduleId(
@Path("schedule_id") scheduleId: String
): Response<List<TaskDto>>
@GET("api/v1/tasks/{task_id}")
suspend fun getTaskById(
@Path("task_id") taskId: String
): Response<TaskDto>
@POST("api/v1/tasks")
suspend fun createTask(
@Body request: CreateTaskRequest
): Response<TaskDto>
@DELETE("api/v1/tasks/{task_id}")
suspend fun deleteTask(
@Path("task_id") taskId: String
): Response<Unit>
@PATCH("api/v1/tasks/{task_id}/complete")
suspend fun completeTask(
@Path("task_id") taskId: String,
@Query("completed") completed: Boolean
): Response<CompleteTaskResponse>
// Rewards endpoints
@GET("api/v1/rewards")
suspend fun getRewards(
@Query("skip") skip: Int = 0,
@Query("limit") limit: Int = 100,
@Query("is_claimed") isClaimed: Boolean? = null
): Response<List<RewardDto>>
@GET("api/v1/rewards/{reward_id}")
suspend fun getRewardById(
@Path("reward_id") rewardId: String
): Response<RewardDto>
@POST("api/v1/rewards")
suspend fun createReward(
@Body request: CreateRewardRequest
): Response<RewardDto>
@DELETE("api/v1/rewards/{reward_id}")
suspend fun deleteReward(
@Path("reward_id") rewardId: String
): Response<Unit>
@POST("api/v1/rewards/{reward_id}/claim")
suspend fun claimReward(
@Path("reward_id") rewardId: String
): Response<ClaimRewardResponse>
} }

View File

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

View File

@@ -4,6 +4,9 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ChatResponse( data class ChatResponse(
val message: String val response: String,
val conversation_id: String,
val tokens_used: Int? = null,
val model: String
) )

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.dao.ChatMessageDao
import com.novayaplaneta.data.local.mapper.toDomain import com.novayaplaneta.data.local.mapper.toDomain
import com.novayaplaneta.data.local.mapper.toEntity import com.novayaplaneta.data.local.mapper.toEntity
import com.novayaplaneta.data.remote.BackendApi import com.novayaplaneta.data.remote.AiApi
import com.novayaplaneta.data.remote.dto.ChatRequest import com.novayaplaneta.data.remote.dto.ChatRequest
import com.novayaplaneta.data.remote.dto.GenerateScheduleRequest
import com.novayaplaneta.domain.model.ChatMessage import com.novayaplaneta.domain.model.ChatMessage
import com.novayaplaneta.domain.repository.AIRepository import com.novayaplaneta.domain.repository.AIRepository
import com.novayaplaneta.domain.repository.ChatResponse
import com.novayaplaneta.domain.repository.GenerateScheduleResult
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
class AIRepositoryImpl @Inject constructor( class AIRepositoryImpl @Inject constructor(
private val chatMessageDao: ChatMessageDao, private val chatMessageDao: ChatMessageDao,
private val api: BackendApi, private val aiApi: AiApi
private val authRepository: com.novayaplaneta.domain.repository.AuthRepository
) : AIRepository { ) : AIRepository {
override suspend fun sendMessage(userId: String, message: String): Result<String> { override suspend fun sendMessage(userId: String, message: String, conversationId: String?): Result<ChatResponse> {
return try { return try {
// Get token from current user
val user = authRepository.getCurrentUser().first()
val token = user?.token ?: ""
// Save user message // Save user message
val userMessage = ChatMessage( val userMessage = ChatMessage(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
@@ -36,28 +36,32 @@ class AIRepositoryImpl @Inject constructor(
) )
chatMessageDao.insertMessage(userMessage.toEntity()) chatMessageDao.insertMessage(userMessage.toEntity())
// Send to API (only if token exists) // Send request to API
if (token.isNotEmpty()) { val request = ChatRequest(message = message, conversation_id = conversationId)
val response = api.chatWithAI("Bearer $token", ChatRequest(message)) val response = aiApi.chat(request)
if (response.isSuccessful && response.body() != null) {
val aiResponse = response.body()!!.message if (response.isSuccessful && response.body() != null) {
val chatResponse = response.body()!!
// Save AI response
val aiMessage = ChatMessage( // Save AI response
id = UUID.randomUUID().toString(), val aiMessage = ChatMessage(
message = aiResponse, id = UUID.randomUUID().toString(),
isFromAI = true, message = chatResponse.response,
timestamp = LocalDateTime.now(), isFromAI = true,
userId = userId timestamp = LocalDateTime.now(),
userId = userId
)
chatMessageDao.insertMessage(aiMessage.toEntity())
Result.success(
ChatResponse(
response = chatResponse.response,
conversationId = chatResponse.conversation_id
) )
chatMessageDao.insertMessage(aiMessage.toEntity()) )
Result.success(aiResponse)
} else {
Result.failure(Exception("Failed to get AI response"))
}
} else { } else {
Result.failure(Exception("User not authenticated")) val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to send message: $errorBody (code: ${response.code()})"))
} }
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
@@ -70,9 +74,79 @@ class AIRepositoryImpl @Inject constructor(
} }
} }
override suspend fun generateSchedule(userId: String, preferences: String): Result<String> { override suspend fun generateSchedule(
// TODO: Implement schedule generation childAge: Int,
return Result.failure(Exception("Not implemented")) preferences: List<String>,
date: String,
description: String
): Result<GenerateScheduleResult> {
return try {
val request = GenerateScheduleRequest(
child_age = childAge,
preferences = preferences,
date = date,
description = description
)
val response = aiApi.generateSchedule(request)
if (response.isSuccessful && response.body() != null) {
val generateResponse = response.body()!!
// Конвертируем JsonObject в Map<String, Any>
val tasksAsMap = generateResponse.tasks.map { jsonObject ->
jsonObject.toMap()
}
Result.success(
GenerateScheduleResult(
scheduleId = generateResponse.schedule_id,
title = generateResponse.title,
tasks = tasksAsMap
)
)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to generate schedule: $errorBody (code: ${response.code()})"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
private fun JsonObject.toMap(): Map<String, Any> {
return entries.associate { (key, value) ->
key to when (value) {
is JsonPrimitive -> {
when {
value.isString -> value.content
else -> {
// Пытаемся парсить как число или boolean
val content = value.content
when {
content == "true" || content == "false" -> content.toBoolean()
content.toLongOrNull() != null -> content.toLong()
content.toDoubleOrNull() != null -> content.toDouble()
else -> content
}
}
}
}
is JsonObject -> value.toMap()
is JsonArray -> value.map {
if (it is JsonObject) it.toMap()
else if (it is JsonPrimitive) {
val content = it.content
when {
content == "true" || content == "false" -> content.toBoolean()
content.toLongOrNull() != null -> content.toLong()
content.toDoubleOrNull() != null -> content.toDouble()
else -> content
}
} else {
it.toString()
}
}
else -> value.toString()
}
}
} }
} }

View File

@@ -1,10 +1,12 @@
package com.novayaplaneta.data.repository package com.novayaplaneta.data.repository
import com.novayaplaneta.data.local.TokenManager
import com.novayaplaneta.data.local.dao.UserDao import com.novayaplaneta.data.local.dao.UserDao
import com.novayaplaneta.data.local.mapper.toDomain import com.novayaplaneta.data.local.mapper.toDomain
import com.novayaplaneta.data.local.mapper.toEntity import com.novayaplaneta.data.local.mapper.toEntity
import com.novayaplaneta.data.remote.BackendApi import com.novayaplaneta.data.remote.AuthApi
import com.novayaplaneta.data.remote.dto.LoginRequest import com.novayaplaneta.data.remote.dto.RefreshRequest
import com.novayaplaneta.data.remote.dto.RegisterRequest
import com.novayaplaneta.domain.model.User import com.novayaplaneta.domain.model.User
import com.novayaplaneta.domain.model.UserRole import com.novayaplaneta.domain.model.UserRole
import com.novayaplaneta.domain.repository.AuthRepository import com.novayaplaneta.domain.repository.AuthRepository
@@ -14,25 +16,123 @@ import javax.inject.Inject
class AuthRepositoryImpl @Inject constructor( class AuthRepositoryImpl @Inject constructor(
private val userDao: UserDao, private val userDao: UserDao,
private val api: BackendApi private val authApi: AuthApi,
private val tokenManager: TokenManager
) : AuthRepository { ) : AuthRepository {
override suspend fun register(email: String, fullName: String, password: String, role: String): Result<User> {
return try {
val response = authApi.register(RegisterRequest(email, fullName, role, password))
if (response.isSuccessful && response.body() != null) {
val registerResponse = response.body()!!
val user = User(
id = registerResponse.id,
fullName = registerResponse.full_name,
email = registerResponse.email,
role = UserRole.valueOf(registerResponse.role),
createdAt = registerResponse.created_at,
updatedAt = registerResponse.updated_at
)
// После регистрации нужно залогиниться, чтобы получить токены
val loginResult = login(email, password)
if (loginResult.isSuccess) {
saveUser(user)
Result.success(user)
} else {
Result.failure(Exception("Registration successful but login failed"))
}
} else {
val errorBody = response.errorBody()?.string() ?: "Registration failed"
Result.failure(Exception(errorBody))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun login(email: String, password: String): Result<User> { override suspend fun login(email: String, password: String): Result<User> {
return try { return try {
val response = api.login(LoginRequest(email, password)) val response = authApi.login(username = email, password = password)
if (response.isSuccessful && response.body() != null) { if (response.isSuccessful && response.body() != null) {
val loginResponse = response.body()!! val tokenResponse = response.body()!!
// Сохраняем токены
tokenManager.saveTokens(tokenResponse.access_token, tokenResponse.refresh_token)
// Убеждаемся, что токен действительно сохранен перед вызовом getMe()
// Это гарантирует, что DataStore завершил сохранение
val savedToken = tokenManager.getAccessToken()
if (savedToken == null) {
return Result.failure(Exception("Failed to save access token"))
}
// Получаем информацию о пользователе
val meResponse = authApi.getMe()
if (meResponse.isSuccessful && meResponse.body() != null) {
val me = meResponse.body()!!
val user = User(
id = me.id,
fullName = me.full_name,
email = me.email,
role = UserRole.valueOf(me.role),
createdAt = me.created_at,
updatedAt = me.updated_at
)
saveUser(user)
Result.success(user)
} else {
val errorCode = meResponse.code()
val errorBody = meResponse.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to get user info: HTTP $errorCode - $errorBody"))
}
} else {
val errorCode = response.code()
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Login failed: HTTP $errorCode - $errorBody"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun refresh(): Result<Unit> {
return try {
val refreshToken = tokenManager.getRefreshToken()
if (refreshToken == null) {
return Result.failure(Exception("No refresh token"))
}
val response = authApi.refresh(RefreshRequest(refreshToken))
if (response.isSuccessful && response.body() != null) {
val tokenResponse = response.body()!!
tokenManager.saveTokens(tokenResponse.access_token, tokenResponse.refresh_token)
Result.success(Unit)
} else {
tokenManager.clearTokens()
Result.failure(Exception("Refresh failed"))
}
} catch (e: Exception) {
tokenManager.clearTokens()
Result.failure(e)
}
}
override suspend fun getMe(): Result<User> {
return try {
val response = authApi.getMe()
if (response.isSuccessful && response.body() != null) {
val me = response.body()!!
val user = User( val user = User(
id = loginResponse.user.id, id = me.id,
name = loginResponse.user.name, fullName = me.full_name,
email = loginResponse.user.email, email = me.email,
role = UserRole.valueOf(loginResponse.user.role), role = UserRole.valueOf(me.role),
token = loginResponse.token createdAt = me.created_at,
updatedAt = me.updated_at
) )
saveUser(user) saveUser(user)
Result.success(user) Result.success(user)
} else { } else {
Result.failure(Exception("Login failed")) Result.failure(Exception("Failed to get user info"))
} }
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
@@ -41,6 +141,7 @@ class AuthRepositoryImpl @Inject constructor(
override suspend fun logout() { override suspend fun logout() {
userDao.deleteAllUsers() userDao.deleteAllUsers()
tokenManager.clearTokens()
} }
override fun getCurrentUser(): Flow<User?> { override fun getCurrentUser(): Flow<User?> {

View File

@@ -3,43 +3,165 @@ package com.novayaplaneta.data.repository
import com.novayaplaneta.data.local.dao.RewardDao import com.novayaplaneta.data.local.dao.RewardDao
import com.novayaplaneta.data.local.mapper.toDomain import com.novayaplaneta.data.local.mapper.toDomain
import com.novayaplaneta.data.local.mapper.toEntity import com.novayaplaneta.data.local.mapper.toEntity
import com.novayaplaneta.data.remote.BackendApi
import com.novayaplaneta.data.remote.dto.CreateRewardRequest
import com.novayaplaneta.data.remote.mapper.toCreateRequest
import com.novayaplaneta.data.remote.mapper.toDomain
import com.novayaplaneta.domain.model.Reward import com.novayaplaneta.domain.model.Reward
import com.novayaplaneta.domain.repository.RewardRepository import com.novayaplaneta.domain.repository.RewardRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.time.LocalDateTime
import javax.inject.Inject import javax.inject.Inject
class RewardRepositoryImpl @Inject constructor( class RewardRepositoryImpl @Inject constructor(
private val rewardDao: RewardDao private val rewardDao: RewardDao,
private val backendApi: BackendApi
) : RewardRepository { ) : RewardRepository {
override fun getRewards(userId: String): Flow<List<Reward>> { // Кэш наград
return rewardDao.getRewardsByUserId(userId).map { rewards -> private val _rewardsCache = MutableStateFlow<List<Reward>>(emptyList())
rewards.map { it.toDomain() }
override suspend fun loadRewards(skip: Int, limit: Int, isClaimed: Boolean?): Result<Unit> {
return try {
val response = backendApi.getRewards(skip = skip, limit = limit, isClaimed = isClaimed)
if (response.isSuccessful && response.body() != null) {
val rewards = response.body()!!.map { it.toDomain() }
// Обновляем кэш
if (skip == 0) {
_rewardsCache.value = rewards
} else {
val currentRewards = _rewardsCache.value.toMutableList()
currentRewards.addAll(rewards)
_rewardsCache.value = currentRewards
}
// Сохраняем в локальную БД
rewards.forEach { reward ->
rewardDao.insertReward(reward.toEntity())
}
Result.success(Unit)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to load rewards: $errorBody (code: ${response.code()})"))
}
} catch (e: Exception) {
Result.failure(e)
} }
} }
override suspend fun getRewardById(id: String): Reward? { override fun getRewards(userId: String): Flow<List<Reward>> {
return rewardDao.getRewardById(id)?.toDomain() return _rewardsCache.asStateFlow()
} }
override suspend fun createReward(reward: Reward) { override suspend fun getRewardById(id: String): Result<Reward> {
rewardDao.insertReward(reward.toEntity()) return try {
val response = backendApi.getRewardById(id)
if (response.isSuccessful && response.body() != null) {
val reward = response.body()!!.toDomain()
// Обновляем в кэше
val currentRewards = _rewardsCache.value.toMutableList()
val index = currentRewards.indexOfFirst { it.id == id }
if (index >= 0) {
currentRewards[index] = reward
} else {
currentRewards.add(reward)
}
_rewardsCache.value = currentRewards
// Сохраняем в локальную БД
rewardDao.insertReward(reward.toEntity())
Result.success(reward)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to get reward: $errorBody (code: ${response.code()})"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun createReward(reward: Reward): Result<Reward> {
return try {
val request = reward.toCreateRequest()
val response = backendApi.createReward(request)
if (response.isSuccessful && response.body() != null) {
val createdReward = response.body()!!.toDomain()
// Обновляем кэш
val currentRewards = _rewardsCache.value.toMutableList()
currentRewards.add(createdReward)
_rewardsCache.value = currentRewards
// Сохраняем в локальную БД
rewardDao.insertReward(createdReward.toEntity())
Result.success(createdReward)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to create reward: $errorBody (code: ${response.code()})"))
}
} catch (e: Exception) {
Result.failure(e)
}
} }
override suspend fun updateReward(reward: Reward) { override suspend fun updateReward(reward: Reward) {
rewardDao.updateReward(reward.toEntity()) rewardDao.updateReward(reward.toEntity())
} }
override suspend fun deleteReward(id: String) { override suspend fun deleteReward(id: String): Result<Unit> {
rewardDao.deleteReward(id) return try {
val response = backendApi.deleteReward(id)
if (response.isSuccessful) {
// Удаляем из кэша
val currentRewards = _rewardsCache.value.toMutableList()
currentRewards.removeAll { it.id == id }
_rewardsCache.value = currentRewards
// Удаляем из локальной БД
rewardDao.deleteReward(id)
Result.success(Unit)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to delete reward: $errorBody (code: ${response.code()})"))
}
} catch (e: Exception) {
Result.failure(e)
}
} }
override suspend fun earnReward(userId: String, rewardId: String) { override suspend fun claimReward(rewardId: String): Result<Reward> {
val reward = getRewardById(rewardId) return try {
reward?.let { val response = backendApi.claimReward(rewardId)
updateReward(it.copy(earnedAt = LocalDateTime.now())) if (response.isSuccessful && response.body() != null) {
val claimedReward = response.body()!!.toDomain()
// Обновляем в кэше
val currentRewards = _rewardsCache.value.toMutableList()
val index = currentRewards.indexOfFirst { it.id == rewardId }
if (index >= 0) {
currentRewards[index] = claimedReward
_rewardsCache.value = currentRewards
}
// Сохраняем в локальную БД
rewardDao.insertReward(claimedReward.toEntity())
Result.success(claimedReward)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to claim reward: $errorBody (code: ${response.code()})"))
}
} catch (e: Exception) {
Result.failure(e)
} }
} }
} }

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.dao.TaskDao
import com.novayaplaneta.data.local.mapper.toDomain import com.novayaplaneta.data.local.mapper.toDomain
import com.novayaplaneta.data.local.mapper.toEntity import com.novayaplaneta.data.local.mapper.toEntity
import com.novayaplaneta.data.remote.BackendApi
import com.novayaplaneta.data.remote.mapper.toCreateRequest
import com.novayaplaneta.data.remote.mapper.toDomain
import com.novayaplaneta.domain.model.Schedule import com.novayaplaneta.domain.model.Schedule
import com.novayaplaneta.domain.repository.ScheduleRepository import com.novayaplaneta.domain.repository.ScheduleRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
class ScheduleRepositoryImpl @Inject constructor( class ScheduleRepositoryImpl @Inject constructor(
private val scheduleDao: ScheduleDao, private val scheduleDao: ScheduleDao,
private val taskDao: TaskDao private val taskDao: TaskDao,
private val backendApi: BackendApi
) : ScheduleRepository { ) : ScheduleRepository {
private val _schedulesCache = MutableStateFlow<List<Schedule>>(emptyList())
val schedulesCache: StateFlow<List<Schedule>> = _schedulesCache.asStateFlow()
override fun getSchedules(userId: String): Flow<List<Schedule>> { override fun getSchedules(userId: String): Flow<List<Schedule>> {
return scheduleDao.getSchedulesByUserId(userId).map { schedules -> return _schedulesCache.asStateFlow()
schedules.map { scheduleEntity ->
// Note: In production, you'd need to fetch tasks for each schedule
scheduleEntity.toDomain(emptyList())
}
}
} }
override suspend fun getScheduleById(id: String): Schedule? { override suspend fun getScheduleById(id: String): Schedule? {
val scheduleEntity = scheduleDao.getScheduleById(id) ?: return null return try {
val tasks = taskDao.getTasksByScheduleId(id) val response = backendApi.getScheduleById(id)
// Simplified - would need proper Flow handling if (response.isSuccessful && response.body() != null) {
return scheduleEntity.toDomain(emptyList()) val schedule = response.body()!!.toDomain()
// Обновляем кэш, если расписание уже есть, или добавляем новое
val currentSchedules = _schedulesCache.value.toMutableList()
val index = currentSchedules.indexOfFirst { it.id == id }
if (index >= 0) {
currentSchedules[index] = schedule
} else {
currentSchedules.add(schedule)
}
_schedulesCache.value = currentSchedules
schedule
} else {
null
}
} catch (e: Exception) {
null
}
} }
override suspend fun createSchedule(schedule: Schedule) { override suspend fun createSchedule(schedule: Schedule) {
scheduleDao.insertSchedule(schedule.toEntity()) try {
val request = schedule.toCreateRequest()
val response = backendApi.createSchedule(request)
if (response.isSuccessful && response.body() != null) {
val createdSchedule = response.body()!!.toDomain()
// Обновляем кэш
val currentSchedules = _schedulesCache.value.toMutableList()
currentSchedules.add(createdSchedule)
_schedulesCache.value = currentSchedules
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
throw Exception("Failed to create schedule: $errorBody (code: ${response.code()})")
}
} catch (e: Exception) {
throw e
}
} }
override suspend fun updateSchedule(schedule: Schedule) { override suspend fun updateSchedule(schedule: Schedule) {
// TODO: Implement if API supports update
scheduleDao.updateSchedule(schedule.toEntity()) scheduleDao.updateSchedule(schedule.toEntity())
} }
override suspend fun deleteSchedule(id: String) { override suspend fun deleteSchedule(id: String) {
scheduleDao.deleteSchedule(id) try {
val response = backendApi.deleteSchedule(id)
if (response.isSuccessful) {
// Удаляем из кэша
val currentSchedules = _schedulesCache.value.toMutableList()
currentSchedules.removeAll { it.id == id }
_schedulesCache.value = currentSchedules
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
throw Exception("Failed to delete schedule: $errorBody (code: ${response.code()})")
}
} catch (e: Exception) {
throw e
}
} }
override suspend fun syncSchedules(userId: String) { override suspend fun syncSchedules(userId: String) {
// TODO: Implement sync with backend try {
val response = backendApi.getSchedules(skip = 0, limit = 100)
if (response.isSuccessful && response.body() != null) {
val schedules = response.body()!!.map { it.toDomain() }
_schedulesCache.value = schedules
} else {
throw Exception("Failed to sync schedules: ${response.errorBody()?.string()}")
}
} catch (e: Exception) {
throw e
}
}
override suspend fun loadSchedules(scheduleDate: String?) {
try {
val response = backendApi.getSchedules(
skip = 0,
limit = 100,
scheduleDate = scheduleDate
)
if (response.isSuccessful && response.body() != null) {
val schedules = response.body()!!.map { it.toDomain() }
_schedulesCache.value = schedules
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
throw Exception("Failed to load schedules: $errorBody (code: ${response.code()})")
}
} catch (e: Exception) {
throw e
}
} }
} }

View File

@@ -3,40 +3,182 @@ package com.novayaplaneta.data.repository
import com.novayaplaneta.data.local.dao.TaskDao import com.novayaplaneta.data.local.dao.TaskDao
import com.novayaplaneta.data.local.mapper.toDomain import com.novayaplaneta.data.local.mapper.toDomain
import com.novayaplaneta.data.local.mapper.toEntity import com.novayaplaneta.data.local.mapper.toEntity
import com.novayaplaneta.data.remote.BackendApi
import com.novayaplaneta.data.remote.dto.CreateTaskRequest
import com.novayaplaneta.data.remote.mapper.toCreateRequest
import com.novayaplaneta.data.remote.mapper.toDomain
import com.novayaplaneta.domain.model.Task import com.novayaplaneta.domain.model.Task
import com.novayaplaneta.domain.repository.TaskRepository import com.novayaplaneta.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
class TaskRepositoryImpl @Inject constructor( class TaskRepositoryImpl @Inject constructor(
private val taskDao: TaskDao private val taskDao: TaskDao,
private val backendApi: BackendApi
) : TaskRepository { ) : TaskRepository {
override fun getTasks(scheduleId: String): Flow<List<Task>> { // Кэш задач по scheduleId
return taskDao.getTasksByScheduleId(scheduleId).map { tasks -> private val _tasksCache = mutableMapOf<String, MutableStateFlow<List<Task>>>()
tasks.map { it.toDomain() }
override suspend fun loadTasks(scheduleId: String): Result<Unit> {
return try {
val response = backendApi.getTasksByScheduleId(scheduleId)
if (response.isSuccessful && response.body() != null) {
val tasks = response.body()!!.map { it.toDomain() }.sortedBy { it.order }
// Обновляем кэш
val cache = _tasksCache.getOrPut(scheduleId) { MutableStateFlow(emptyList()) }
cache.value = tasks
// Сохраняем в локальную БД
tasks.forEach { task ->
taskDao.insertTask(task.toEntity())
}
Result.success(Unit)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to load tasks: $errorBody (code: ${response.code()})"))
}
} catch (e: Exception) {
Result.failure(e)
} }
} }
override suspend fun getTaskById(id: String): Task? { override fun getTasks(scheduleId: String): Flow<List<Task>> {
return taskDao.getTaskById(id)?.toDomain() val cache = _tasksCache.getOrPut(scheduleId) { MutableStateFlow(emptyList()) }
return cache.asStateFlow()
} }
override suspend fun createTask(task: Task) { override suspend fun getTaskById(id: String): Result<Task> {
taskDao.insertTask(task.toEntity()) return try {
val response = backendApi.getTaskById(id)
if (response.isSuccessful && response.body() != null) {
val task = response.body()!!.toDomain()
// Обновляем в кэше
val scheduleId = task.scheduleId
val cache = _tasksCache[scheduleId]
if (cache != null) {
val currentTasks = cache.value.toMutableList()
val index = currentTasks.indexOfFirst { it.id == id }
if (index >= 0) {
currentTasks[index] = task
} else {
currentTasks.add(task)
}
cache.value = currentTasks.sortedBy { it.order }
}
// Сохраняем в локальную БД
taskDao.insertTask(task.toEntity())
Result.success(task)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to get task: $errorBody (code: ${response.code()})"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun createTask(task: Task): Result<Task> {
return try {
val request = task.toCreateRequest()
val response = backendApi.createTask(request)
if (response.isSuccessful && response.body() != null) {
val createdTask = response.body()!!.toDomain()
// Обновляем кэш
val cache = _tasksCache[task.scheduleId]
if (cache != null) {
val currentTasks = cache.value.toMutableList()
currentTasks.add(createdTask)
cache.value = currentTasks.sortedBy { it.order }
}
// Сохраняем в локальную БД
taskDao.insertTask(createdTask.toEntity())
Result.success(createdTask)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to create task: $errorBody (code: ${response.code()})"))
}
} catch (e: Exception) {
Result.failure(e)
}
} }
override suspend fun updateTask(task: Task) { override suspend fun updateTask(task: Task) {
taskDao.updateTask(task.toEntity()) taskDao.updateTask(task.toEntity())
} }
override suspend fun deleteTask(id: String) { override suspend fun deleteTask(id: String): Result<Unit> {
taskDao.deleteTask(id) return try {
// Сначала получаем задачу для определения scheduleId
val task = taskDao.getTaskById(id)?.toDomain()
val scheduleId = task?.scheduleId
val response = backendApi.deleteTask(id)
if (response.isSuccessful) {
// Удаляем из кэша
if (scheduleId != null) {
val cache = _tasksCache[scheduleId]
if (cache != null) {
val currentTasks = cache.value.toMutableList()
currentTasks.removeAll { it.id == id }
cache.value = currentTasks
}
}
// Удаляем из локальной БД
taskDao.deleteTask(id)
Result.success(Unit)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to delete task: $errorBody (code: ${response.code()})"))
}
} catch (e: Exception) {
Result.failure(e)
}
} }
override suspend fun completeTask(id: String) { override suspend fun completeTask(id: String, completed: Boolean): Result<Task> {
taskDao.completeTask(id) return try {
val response = backendApi.completeTask(id, completed)
if (response.isSuccessful && response.body() != null) {
val updatedTask = response.body()!!.toDomain()
// Обновляем в кэше
val scheduleId = updatedTask.scheduleId
val cache = _tasksCache[scheduleId]
if (cache != null) {
val currentTasks = cache.value.toMutableList()
val index = currentTasks.indexOfFirst { it.id == id }
if (index >= 0) {
currentTasks[index] = updatedTask
cache.value = currentTasks.sortedBy { it.order }
}
}
// Сохраняем в локальную БД
taskDao.insertTask(updatedTask.toEntity())
Result.success(updatedTask)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to complete task: $errorBody (code: ${response.code()})"))
}
} catch (e: Exception) {
Result.failure(e)
}
} }
} }

View File

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

View File

@@ -1,6 +1,9 @@
package com.novayaplaneta.di package com.novayaplaneta.di
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.novayaplaneta.data.remote.AiApi
import com.novayaplaneta.data.remote.AuthApi
import com.novayaplaneta.data.remote.AuthInterceptor
import com.novayaplaneta.data.remote.BackendApi import com.novayaplaneta.data.remote.BackendApi
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@@ -17,44 +20,66 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object NetworkModule { object NetworkModule {
private val json = Json { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
isLenient = true isLenient = true
encodeDefaults = false encodeDefaults = false
} }
@Provides @Provides
@Singleton @Singleton
fun provideOkHttpClient(): OkHttpClient { fun provideOkHttpClient(
authInterceptor: AuthInterceptor
): OkHttpClient {
val loggingInterceptor = HttpLoggingInterceptor().apply { val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY level = HttpLoggingInterceptor.Level.BODY
} }
return OkHttpClient.Builder() return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor) .addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS)
.build() .build()
} }
@Provides @Provides
@Singleton @Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { fun provideRetrofit(
okHttpClient: OkHttpClient
): Retrofit {
val contentType = "application/json".toMediaType() val contentType = "application/json".toMediaType()
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl("https://api.novayaplaneta.ru/") .baseUrl("http://10.0.2.2:8000/")
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(json.asConverterFactory(contentType)) .addConverterFactory(json.asConverterFactory(contentType))
.build() .build()
} }
@Provides
@Singleton
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 @Provides
@Singleton @Singleton
fun provideBackendApi(retrofit: Retrofit): BackendApi { fun provideAiApi(
return retrofit.create(BackendApi::class.java) retrofit: Retrofit
): AiApi {
return retrofit.create(AiApi::class.java)
} }
} }

View File

@@ -7,8 +7,11 @@ data class Reward(
val title: String, val title: String,
val description: String?, val description: String?,
val imageUrl: String?, val imageUrl: String?,
val points: Int, val pointsRequired: Int,
val isClaimed: Boolean = false,
val earnedAt: LocalDateTime? = null, val earnedAt: LocalDateTime? = null,
val userId: String val userId: String,
val createdAt: LocalDateTime? = null,
val updatedAt: LocalDateTime? = null
) )

View File

@@ -9,6 +9,7 @@ data class Schedule(
val tasks: List<Task>, val tasks: List<Task>,
val date: LocalDateTime, val date: LocalDateTime,
val createdAt: LocalDateTime, val createdAt: LocalDateTime,
val userId: String val userId: String,
val rewardId: String? = null
) )

View File

@@ -10,6 +10,10 @@ data class Task(
val completed: Boolean = false, val completed: Boolean = false,
val scheduledTime: LocalDateTime?, val scheduledTime: LocalDateTime?,
val duration: Int? = null, // in minutes val duration: Int? = null, // in minutes
val scheduleId: String val scheduleId: String,
val order: Int = 0,
val category: String? = null,
val createdAt: LocalDateTime? = null,
val updatedAt: LocalDateTime? = null
) )

View File

@@ -2,10 +2,11 @@ package com.novayaplaneta.domain.model
data class User( data class User(
val id: String, val id: String,
val name: String, val fullName: String,
val email: String, val email: String,
val role: UserRole, val role: UserRole,
val token: String? = null val createdAt: String? = null,
val updatedAt: String? = null
) )
enum class UserRole { enum class UserRole {

View File

@@ -4,8 +4,24 @@ import com.novayaplaneta.domain.model.ChatMessage
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface AIRepository { interface AIRepository {
suspend fun sendMessage(userId: String, message: String): Result<String> suspend fun sendMessage(userId: String, message: String, conversationId: String?): Result<ChatResponse>
fun getChatHistory(userId: String): Flow<List<ChatMessage>> fun getChatHistory(userId: String): Flow<List<ChatMessage>>
suspend fun generateSchedule(userId: String, preferences: String): Result<String> suspend fun generateSchedule(
childAge: Int,
preferences: List<String>,
date: String,
description: String
): Result<GenerateScheduleResult>
} }
data class ChatResponse(
val response: String,
val conversationId: String
)
data class GenerateScheduleResult(
val scheduleId: String,
val title: String,
val tasks: List<Map<String, Any>>
)

View File

@@ -4,7 +4,10 @@ import com.novayaplaneta.domain.model.User
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface AuthRepository { interface AuthRepository {
suspend fun register(email: String, fullName: String, password: String, role: String = "CHILD"): Result<User>
suspend fun login(email: String, password: String): Result<User> suspend fun login(email: String, password: String): Result<User>
suspend fun refresh(): Result<Unit>
suspend fun getMe(): Result<User>
suspend fun logout() suspend fun logout()
fun getCurrentUser(): Flow<User?> fun getCurrentUser(): Flow<User?>
suspend fun saveUser(user: User) suspend fun saveUser(user: User)

View File

@@ -4,11 +4,12 @@ import com.novayaplaneta.domain.model.Reward
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface RewardRepository { interface RewardRepository {
suspend fun loadRewards(skip: Int = 0, limit: Int = 100, isClaimed: Boolean? = null): Result<Unit>
fun getRewards(userId: String): Flow<List<Reward>> fun getRewards(userId: String): Flow<List<Reward>>
suspend fun getRewardById(id: String): Reward? suspend fun getRewardById(id: String): Result<Reward>
suspend fun createReward(reward: Reward) suspend fun createReward(reward: Reward): Result<Reward>
suspend fun updateReward(reward: Reward) suspend fun updateReward(reward: Reward)
suspend fun deleteReward(id: String) suspend fun deleteReward(id: String): Result<Unit>
suspend fun earnReward(userId: String, rewardId: String) suspend fun claimReward(rewardId: String): Result<Reward>
} }

View File

@@ -2,7 +2,6 @@ package com.novayaplaneta.domain.repository
import com.novayaplaneta.domain.model.Schedule import com.novayaplaneta.domain.model.Schedule
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import java.time.LocalDateTime
interface ScheduleRepository { interface ScheduleRepository {
fun getSchedules(userId: String): Flow<List<Schedule>> fun getSchedules(userId: String): Flow<List<Schedule>>
@@ -11,5 +10,6 @@ interface ScheduleRepository {
suspend fun updateSchedule(schedule: Schedule) suspend fun updateSchedule(schedule: Schedule)
suspend fun deleteSchedule(id: String) suspend fun deleteSchedule(id: String)
suspend fun syncSchedules(userId: String) suspend fun syncSchedules(userId: String)
suspend fun loadSchedules(scheduleDate: String? = null)
} }

View File

@@ -4,11 +4,12 @@ import com.novayaplaneta.domain.model.Task
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface TaskRepository { interface TaskRepository {
suspend fun loadTasks(scheduleId: String): Result<Unit>
fun getTasks(scheduleId: String): Flow<List<Task>> fun getTasks(scheduleId: String): Flow<List<Task>>
suspend fun getTaskById(id: String): Task? suspend fun getTaskById(id: String): Result<Task>
suspend fun createTask(task: Task) suspend fun createTask(task: Task): Result<Task>
suspend fun updateTask(task: Task) suspend fun updateTask(task: Task)
suspend fun deleteTask(id: String) suspend fun deleteTask(id: String): Result<Unit>
suspend fun completeTask(id: String) suspend fun completeTask(id: String, completed: Boolean): Result<Task>
} }

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 package com.novayaplaneta.domain.usecase
import com.novayaplaneta.domain.model.Task
import com.novayaplaneta.domain.repository.TaskRepository import com.novayaplaneta.domain.repository.TaskRepository
import javax.inject.Inject import javax.inject.Inject
class CompleteTaskUseCase @Inject constructor( class CompleteTaskUseCase @Inject constructor(
private val repository: TaskRepository private val repository: TaskRepository
) { ) {
suspend operator fun invoke(taskId: String) { suspend operator fun invoke(id: String, completed: Boolean): Result<Task> {
repository.completeTask(taskId) return repository.completeTask(id, completed)
} }
} }

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( class CreateScheduleUseCase @Inject constructor(
private val repository: ScheduleRepository private val repository: ScheduleRepository
) { ) {
suspend operator fun invoke(schedule: Schedule) { suspend operator fun invoke(schedule: Schedule): Result<Schedule> {
repository.createSchedule(schedule) return try {
repository.createSchedule(schedule)
// После создания перезагружаем список, чтобы получить актуальные данные
repository.loadSchedules()
// Возвращаем успешный результат
// Созданное расписание уже добавлено в кэш через createSchedule
Result.success(schedule)
} catch (e: Exception) {
Result.failure(e)
}
} }
} }

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>> { operator fun invoke(userId: String): Flow<List<Schedule>> {
return repository.getSchedules(userId) return repository.getSchedules(userId)
} }
suspend fun loadSchedules(scheduleDate: String? = null): Result<Unit> {
return try {
repository.loadSchedules(scheduleDate)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
} }

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 package com.novayaplaneta.domain.usecase
import com.novayaplaneta.domain.repository.AIRepository import com.novayaplaneta.domain.repository.AIRepository
import com.novayaplaneta.domain.repository.ChatResponse
import javax.inject.Inject import javax.inject.Inject
class SendAIMessageUseCase @Inject constructor( class SendAIMessageUseCase @Inject constructor(
private val repository: AIRepository private val repository: AIRepository
) { ) {
suspend operator fun invoke(userId: String, message: String): Result<String> { suspend operator fun invoke(userId: String, message: String, conversationId: String? = null): Result<ChatResponse> {
return repository.sendMessage(userId, message) return repository.sendMessage(userId, message, conversationId)
} }
} }

View File

@@ -3,12 +3,15 @@ package com.novayaplaneta.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.novayaplaneta.ui.screens.ai.AIScreen import com.novayaplaneta.ui.screens.ai.AIScreen
import com.novayaplaneta.ui.screens.auth.ForgotPasswordScreen import com.novayaplaneta.ui.screens.auth.ForgotPasswordScreen
import com.novayaplaneta.ui.screens.auth.LoginScreen import com.novayaplaneta.ui.screens.auth.LoginScreen
import com.novayaplaneta.ui.screens.auth.RegistrationScreen import com.novayaplaneta.ui.screens.auth.RegistrationScreen
import com.novayaplaneta.ui.screens.auth.SplashScreen
import com.novayaplaneta.ui.screens.rewards.RewardsScreen import com.novayaplaneta.ui.screens.rewards.RewardsScreen
import com.novayaplaneta.ui.screens.schedule.ScheduleScreen import com.novayaplaneta.ui.screens.schedule.ScheduleScreen
import com.novayaplaneta.ui.screens.settings.SettingsScreen import com.novayaplaneta.ui.screens.settings.SettingsScreen
@@ -19,13 +22,16 @@ import com.novayaplaneta.ui.screens.timer.TimerScreen
fun NewPlanetNavigation( fun NewPlanetNavigation(
navController: NavHostController, navController: NavHostController,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startDestination: String = "login" startDestination: String = "splash"
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = startDestination,
modifier = modifier modifier = modifier
) { ) {
composable("splash") {
SplashScreen(navController = navController)
}
composable("login") { composable("login") {
LoginScreen(navController = navController) LoginScreen(navController = navController)
} }
@@ -38,8 +44,17 @@ fun NewPlanetNavigation(
composable("schedule") { composable("schedule") {
ScheduleScreen(navController = navController) ScheduleScreen(navController = navController)
} }
composable("tasks") { composable(
TaskScreen() route = "tasks/{scheduleId}",
arguments = listOf(
navArgument("scheduleId") { type = NavType.StringType }
)
) { backStackEntry ->
val scheduleId = backStackEntry.arguments?.getString("scheduleId")
TaskScreen(
scheduleId = scheduleId,
navController = navController
)
} }
composable("timer") { composable("timer") {
TimerScreen() TimerScreen()
@@ -48,7 +63,7 @@ fun NewPlanetNavigation(
RewardsScreen(navController = navController) RewardsScreen(navController = navController)
} }
composable("ai") { composable("ai") {
AIScreen() AIScreen(navController = navController)
} }
composable("settings") { composable("settings") {
SettingsScreen(navController = navController) SettingsScreen(navController = navController)

View File

@@ -1,94 +1,403 @@
package com.novayaplaneta.ui.screens.ai package com.novayaplaneta.ui.screens.ai
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarDuration
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import kotlin.math.sin
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.novayaplaneta.R
import com.novayaplaneta.ui.components.NovayaPlanetaLogo
import com.novayaplaneta.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AIScreen( fun AIScreen(
navController: androidx.navigation.NavController? = null,
viewModel: AIViewModel = hiltViewModel(), viewModel: AIViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
var messageText by remember { mutableStateOf("") } var messageText by remember { mutableStateOf("") }
val snackbarHostState = remember { SnackbarHostState() }
Scaffold( // Показываем ошибки через Snackbar
topBar = { LaunchedEffect(uiState.error) {
TopAppBar( uiState.error?.let { error ->
title = { Text("ИИ-помощник \"Земля\"") } snackbarHostState.showSnackbar(
message = error,
duration = SnackbarDuration.Long
) )
viewModel.clearError()
} }
) { paddingValues -> }
Column(
modifier = modifier val configuration = LocalConfiguration.current
.fillMaxSize() val screenWidthDp = configuration.screenWidthDp
.padding(paddingValues) val screenHeightDp = configuration.screenHeightDp
// Цвета из autism-friendly палитры
val backgroundColor = LoginBackgroundTurquoise
val navPanelColor = LoginInputLightBlue
val accentGreen = LoginGreenAccent
// Состояние прокрутки чата
val listState = rememberLazyListState()
// Автопрокрутка вниз при новых сообщениях
LaunchedEffect(uiState.messages.size) {
if (uiState.messages.isNotEmpty()) {
listState.animateScrollToItem(uiState.messages.size - 1)
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(color = backgroundColor)
) {
Row(
modifier = Modifier.fillMaxSize()
) { ) {
LazyColumn( // Левая панель навигации с логотипом над ней
Column(
modifier = Modifier modifier = Modifier
.weight(1f) .width((screenWidthDp * 0.22f).dp.coerceIn(160.dp, 240.dp))
.padding(16.dp), .fillMaxHeight()
verticalArrangement = Arrangement.spacedBy(8.dp) .padding(vertical = 20.dp, horizontal = 16.dp)
) { ) {
items(uiState.messages) { message -> // Логотип над панелью навигации
Card( Box(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
colors = CardDefaults.cardColors( .fillMaxWidth()
containerColor = if (message.isFromAI) { .padding(bottom = 16.dp),
MaterialTheme.colorScheme.primaryContainer contentAlignment = Alignment.Center
} else { ) {
MaterialTheme.colorScheme.surfaceVariant val logoSize = (screenHeightDp * 0.22f).toInt().coerceIn(140, 240).dp
} NovayaPlanetaLogo(size = logoSize)
) }
) {
Text( // Панель навигации
text = message.message, Column(
modifier = Modifier.padding(16.dp), modifier = Modifier
style = MaterialTheme.typography.bodyLarge .fillMaxWidth()
) .weight(1f)
} .clip(RoundedCornerShape(32.dp))
.background(color = navPanelColor)
.padding(vertical = 24.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(32.dp)
) {
NavItem(
icon = Icons.Filled.CalendarToday,
text = "Расписание",
isSelected = false,
onClick = { navController?.navigate("schedule") }
)
NavItem(
icon = Icons.Filled.Star,
text = "Награды",
isSelected = false,
onClick = { navController?.navigate("rewards") }
)
NavItem(
icon = Icons.Filled.Person,
text = "Профиль",
isSelected = false,
onClick = { navController?.navigate("settings") }
)
NavItem(
icon = Icons.Filled.Public,
text = "Земля",
isSelected = true,
onClick = { }
)
} }
} }
if (uiState.isLoading) { // Основная область с AI агентом и чатом
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .weight(1f)
.padding(16.dp), .fillMaxHeight()
horizontalArrangement = Arrangement.spacedBy(8.dp) .padding(24.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) { ) {
OutlinedTextField( // Левая часть: Анимированная картинка AI агента
value = messageText, Column(
onValueChange = { messageText = it }, modifier = Modifier
modifier = Modifier.weight(1f), .width((screenWidthDp * 0.3f).dp.coerceIn(200.dp, 400.dp))
placeholder = { Text("Введите сообщение...") } .fillMaxHeight(),
) horizontalAlignment = Alignment.CenterHorizontally,
Button( verticalArrangement = Arrangement.Center
onClick = { ) {
if (messageText.isNotBlank()) { AnimatedAIAgent(
viewModel.sendMessage("userId", messageText) isSpeaking = uiState.isLoading,
messageText = "" screenHeightDp = screenHeightDp
)
}
// Правая часть: Чат
Column(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clip(RoundedCornerShape(24.dp))
.background(color = LoginCardLightBlue)
.padding(24.dp)
) {
// Заголовок чата
val titleSize = (screenHeightDp * 0.04f).toInt().coerceIn(32, 52).sp
Text(
text = "Помощник Земля",
fontSize = titleSize,
fontWeight = FontWeight.Bold,
color = Color.Black,
modifier = Modifier.padding(bottom = 24.dp)
)
// Список сообщений
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(uiState.messages) { message ->
ChatBubble(
message = message.message,
isFromAI = message.isFromAI,
screenHeightDp = screenHeightDp,
accentGreen = accentGreen
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Поле ввода и кнопка отправки
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Bottom
) {
OutlinedTextField(
value = messageText,
onValueChange = { messageText = it },
modifier = Modifier.weight(1f),
placeholder = {
Text(
text = "Напишите сообщение...",
color = Color.Gray.copy(alpha = 0.7f)
)
},
singleLine = false,
maxLines = 3,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedBorderColor = accentGreen,
unfocusedBorderColor = Color.Gray
),
shape = RoundedCornerShape(20.dp)
)
Button(
onClick = {
if (messageText.isNotBlank()) {
viewModel.sendMessage(messageText)
messageText = ""
}
},
modifier = Modifier
.height(56.dp)
.width(56.dp),
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentGreen,
contentColor = Color.White
),
enabled = messageText.isNotBlank() && !uiState.isLoading
) {
Icon(
imageVector = Icons.Filled.Send,
contentDescription = "Отправить",
modifier = Modifier.size(24.dp)
)
} }
} }
) {
Text("Отправить")
} }
} }
} }
// Snackbar для отображения ошибок
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
@Composable
fun AnimatedAIAgent(
isSpeaking: Boolean,
screenHeightDp: Int
) {
val infiniteTransition = rememberInfiniteTransition(label = "ai_agent_animation")
// Анимация покачивания Земли - более быстрое когда говорит
val bounceFloat by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 2f * kotlin.math.PI.toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = if (isSpeaking) 400 else 2000,
easing = LinearEasing
),
repeatMode = RepeatMode.Reverse
),
label = "bounce_float"
)
// Интенсивное покачивание когда говорит, легкое когда молчит
val offsetY = if (isSpeaking) {
sin(bounceFloat) * 20f // Активное прыгание при ответе
} else {
sin(bounceFloat) * 8f // Легкое покачивание в покое
}
val earthSize = (screenHeightDp * 0.4f).dp.coerceIn(200.dp, 400.dp)
Box(
modifier = Modifier
.size(earthSize),
contentAlignment = Alignment.Center
) {
// Изображение Земли с покачиванием
androidx.compose.foundation.Image(
painter = painterResource(id = R.drawable.logo_earth),
contentDescription = "AI Агент Земля",
modifier = Modifier
.fillMaxSize()
.offset(y = offsetY.dp),
contentScale = ContentScale.Fit
)
}
}
@Composable
fun ChatBubble(
message: String,
isFromAI: Boolean,
screenHeightDp: Int,
accentGreen: Color
) {
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 20).sp
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isFromAI) Arrangement.Start else Arrangement.End
) {
Card(
modifier = Modifier.widthIn(max = (screenHeightDp * 0.4f).dp.coerceIn(200.dp, 400.dp)),
shape = RoundedCornerShape(
topStart = 20.dp,
topEnd = 20.dp,
bottomStart = if (isFromAI) 4.dp else 20.dp,
bottomEnd = if (isFromAI) 20.dp else 4.dp
),
colors = CardDefaults.cardColors(
containerColor = if (isFromAI) {
LoginGreenSoft // Мягкий зеленый для AI
} else {
LoginGreenAccent // Зеленый акцент для пользователя
}
)
) {
Text(
text = message,
modifier = Modifier.padding(16.dp),
fontSize = textSize,
color = Color.Black,
fontWeight = FontWeight.Medium
)
}
} }
} }
@Composable
fun NavItem(
icon: androidx.compose.ui.graphics.vector.ImageVector,
text: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val iconSize = 40.dp
val textSize = 18.sp
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.clickable { onClick() }
.background(
color = if (isSelected) Color.White.copy(alpha = 0.3f) else Color.Transparent
)
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = icon,
contentDescription = text,
modifier = Modifier.size(iconSize),
tint = LoginGreenAccent
)
Text(
text = text,
fontSize = textSize,
fontWeight = FontWeight.Bold,
color = Color.Black
)
}
}

View File

@@ -7,42 +7,117 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AIViewModel @Inject constructor( class AIViewModel @Inject constructor(
private val aiRepository: AIRepository private val aiRepository: AIRepository,
private val authRepository: com.novayaplaneta.domain.repository.AuthRepository
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(AIUiState()) private val _uiState = MutableStateFlow(AIUiState())
val uiState: StateFlow<AIUiState> = _uiState.asStateFlow() val uiState: StateFlow<AIUiState> = _uiState.asStateFlow()
fun loadChatHistory(userId: String) { private var conversationId: String? = null
private var currentUserId: String? = null
private var sendMessageJob: kotlinx.coroutines.Job? = null
init {
loadUserId()
}
private fun loadUserId() {
viewModelScope.launch { viewModelScope.launch {
aiRepository.getChatHistory(userId).collect { messages -> try {
_uiState.value = _uiState.value.copy(messages = messages) val user = authRepository.getCurrentUser().first()
currentUserId = user?.id
if (currentUserId != null) {
loadChatHistory(currentUserId!!)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = e.message ?: "Ошибка загрузки пользователя"
)
} }
} }
} }
fun sendMessage(userId: String, message: String) { fun loadChatHistory(userId: String? = null) {
val targetUserId = userId ?: currentUserId ?: return
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true) try {
aiRepository.sendMessage(userId, message).fold( val messages = aiRepository.getChatHistory(targetUserId).first()
_uiState.value = _uiState.value.copy(messages = messages)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = e.message ?: "Ошибка загрузки истории чата"
)
}
}
}
fun sendMessage(message: String) {
val userId = currentUserId ?: return
// Предотвращаем повторные отправки
if (_uiState.value.isLoading || sendMessageJob?.isActive == true) {
return
}
sendMessageJob = viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
aiRepository.sendMessage(userId, message, conversationId).fold(
onSuccess = { response -> onSuccess = { response ->
// Сохраняем conversation_id для следующих сообщений
conversationId = response.conversationId
// Перезагружаем историю чата
loadChatHistory(userId)
_uiState.value = _uiState.value.copy(isLoading = false)
},
onFailure = { error ->
val errorMessage = error.message ?: "Ошибка отправки сообщения"
// Если ошибка связана с дубликатом conversation_id, сбрасываем его
if (errorMessage.contains("duplicate key", ignoreCase = true) ||
errorMessage.contains("conversation_id", ignoreCase = true)) {
conversationId = null
// Пытаемся отправить сообщение снова без conversation_id
retrySendMessage(userId, message)
} else {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = errorMessage
)
}
}
)
}
}
private fun retrySendMessage(userId: String, message: String) {
viewModelScope.launch {
aiRepository.sendMessage(userId, message, null).fold(
onSuccess = { response ->
conversationId = response.conversationId
loadChatHistory(userId) loadChatHistory(userId)
_uiState.value = _uiState.value.copy(isLoading = false) _uiState.value = _uiState.value.copy(isLoading = false)
}, },
onFailure = { error -> onFailure = { error ->
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
error = error.message error = error.message ?: "Ошибка отправки сообщения"
) )
} }
) )
} }
} }
fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
}
} }
data class AIUiState( data class AIUiState(

View File

@@ -122,44 +122,64 @@ fun LoginScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Поле логина // Поле email
Box( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() verticalArrangement = Arrangement.spacedBy(4.dp)
.height(height = inputHeight)
.background(
color = LoginInputLightBlue,
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
) { ) {
TextField( Box(
value = uiState.login,
onValueChange = { viewModel.onLoginChange(it) },
placeholder = {
Text(
text = "Введи логин",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .height(height = inputHeight)
colors = TextFieldDefaults.colors( .background(
unfocusedContainerColor = Color.Transparent, color = if (uiState.errorMessage != null && uiState.email.isNotBlank() && !EmailValidator.isValid(uiState.email)) {
focusedContainerColor = Color.Transparent, Color(0xFFFFEBEE)
unfocusedIndicatorColor = Color.Transparent, } else {
focusedIndicatorColor = Color.Transparent, LoginInputLightBlue
unfocusedTextColor = Color.Black, },
focusedTextColor = Color.Black shape = RoundedCornerShape(20.dp)
), ),
textStyle = MaterialTheme.typography.bodyLarge.copy( contentAlignment = Alignment.CenterStart
fontSize = inputTextSize ) {
), TextField(
singleLine = true, value = uiState.email,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) onValueChange = { viewModel.onEmailChange(it) },
) placeholder = {
Text(
text = "Введи email",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
isError = uiState.errorMessage != null && uiState.email.isNotBlank() && !EmailValidator.isValid(uiState.email)
)
}
// Сообщение об ошибке email
if (uiState.errorMessage != null && uiState.email.isNotBlank() && !EmailValidator.isValid(uiState.email)) {
Text(
text = "Введите корректный email",
fontSize = (inputTextSizeValue * 0.8f).toInt().sp,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
} }
// Поле пароля // Поле пароля
@@ -208,12 +228,12 @@ fun LoginScreen(
Button( Button(
onClick = { onClick = {
if (uiState.isFormValid) { if (uiState.isFormValid) {
// Переход на экран расписания при заполненных полях viewModel.login {
navController.navigate("schedule") { // Переход на экран расписания после успешного входа
popUpTo(0) { inclusive = true } navController.navigate("schedule") {
popUpTo(0) { inclusive = true }
}
} }
// Также вызываем логин для проверки через API (в фоне)
viewModel.login { }
} }
}, },
modifier = Modifier modifier = Modifier
@@ -256,12 +276,12 @@ fun LoginScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
val linkTextSize = (screenHeightDp * 0.024f).toInt().coerceIn(18, 26).sp val linkTextSize = (screenHeightDp * 0.032f).toInt().coerceIn(24, 32).sp
Text( Text(
text = "Нет логина и пароля?", text = "Нет логина и пароля?",
fontSize = linkTextSize, fontSize = linkTextSize,
color = LoginGreenAccent, color = LoginGreenAccent,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
modifier = Modifier.clickable { modifier = Modifier.clickable {
navController.navigate("registration") navController.navigate("registration")
} }
@@ -271,7 +291,7 @@ fun LoginScreen(
text = "Не помнишь пароль?", text = "Не помнишь пароль?",
fontSize = linkTextSize, fontSize = linkTextSize,
color = LoginGreenAccent, color = LoginGreenAccent,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
modifier = Modifier.clickable { modifier = Modifier.clickable {
navController.navigate("forgot_password") navController.navigate("forgot_password")
} }

View File

@@ -2,7 +2,7 @@ package com.novayaplaneta.ui.screens.auth
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.novayaplaneta.domain.repository.AuthRepository import com.novayaplaneta.domain.usecase.LoginUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -12,23 +12,25 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LoginViewModel @Inject constructor( class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository private val loginUseCase: LoginUseCase
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState()) private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow() val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun onLoginChange(login: String) { fun onEmailChange(email: String) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
login = login, email = email,
isFormValid = isFormValid(login, _uiState.value.password) isFormValid = isFormValid(email, _uiState.value.password),
errorMessage = null
) )
} }
fun onPasswordChange(password: String) { fun onPasswordChange(password: String) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
password = password, password = password,
isFormValid = isFormValid(_uiState.value.login, password) isFormValid = isFormValid(_uiState.value.email, password),
errorMessage = null
) )
} }
@@ -38,7 +40,7 @@ class LoginViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val result = authRepository.login(_uiState.value.login, _uiState.value.password) val result = loginUseCase(_uiState.value.email, _uiState.value.password)
result.onSuccess { user -> result.onSuccess { user ->
_uiState.value = _uiState.value.copy(isLoading = false, isLoggedIn = true) _uiState.value = _uiState.value.copy(isLoading = false, isLoggedIn = true)
@@ -52,13 +54,13 @@ class LoginViewModel @Inject constructor(
} }
} }
private fun isFormValid(login: String, password: String): Boolean { private fun isFormValid(email: String, password: String): Boolean {
return login.isNotBlank() && password.isNotBlank() return email.isNotBlank() && password.isNotBlank() && EmailValidator.isValid(email)
} }
} }
data class LoginUiState( data class LoginUiState(
val login: String = "", val email: String = "",
val password: String = "", val password: String = "",
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isFormValid: Boolean = false, val isFormValid: Boolean = false,

View File

@@ -127,7 +127,7 @@ fun RegistrationScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Поле логина // Поле полного имени
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -139,11 +139,11 @@ fun RegistrationScreen(
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart
) { ) {
TextField( TextField(
value = uiState.login, value = uiState.fullName,
onValueChange = { viewModel.onLoginChange(it) }, onValueChange = { viewModel.onFullNameChange(it) },
placeholder = { placeholder = {
Text( Text(
text = "Введи логин", text = "Введи полное имя",
fontSize = inputTextSize, fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f) color = Color.Gray.copy(alpha = 0.7f)
) )
@@ -168,85 +168,114 @@ fun RegistrationScreen(
} }
// Поле пароля // Поле пароля
Box( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() verticalArrangement = Arrangement.spacedBy(4.dp)
.height(height = inputHeight)
.background(
color = LoginInputLightBlue,
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
) { ) {
TextField( Box(
value = uiState.password,
onValueChange = { viewModel.onPasswordChange(it) },
placeholder = {
Text(
text = "Введи пароль",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .height(height = inputHeight)
visualTransformation = PasswordVisualTransformation(), .background(
colors = TextFieldDefaults.colors( color = if (uiState.passwordError != null) {
unfocusedContainerColor = Color.Transparent, Color(0xFFFFEBEE)
focusedContainerColor = Color.Transparent, } else {
unfocusedIndicatorColor = Color.Transparent, LoginInputLightBlue
focusedIndicatorColor = Color.Transparent, },
unfocusedTextColor = Color.Black, shape = RoundedCornerShape(20.dp)
focusedTextColor = Color.Black ),
), contentAlignment = Alignment.CenterStart
textStyle = MaterialTheme.typography.bodyLarge.copy( ) {
fontSize = inputTextSize TextField(
), value = uiState.password,
singleLine = true, onValueChange = { viewModel.onPasswordChange(it) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) placeholder = {
) Text(
text = "Введи пароль",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
visualTransformation = PasswordVisualTransformation(),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = uiState.passwordError != null
)
}
if (uiState.passwordError != null) {
Text(
text = uiState.passwordError!!,
fontSize = (inputTextSizeValue * 0.8f).toInt().sp,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
} }
// Поле повторения пароля // Поле повторения пароля
Box( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() verticalArrangement = Arrangement.spacedBy(4.dp)
.height(height = inputHeight)
.background(
color = LoginInputLightBlue,
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
) { ) {
TextField( Box(
value = uiState.confirmPassword,
onValueChange = { viewModel.onConfirmPasswordChange(it) },
placeholder = {
Text(
text = "Повтори пароль",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .height(height = inputHeight)
visualTransformation = PasswordVisualTransformation(), .background(
colors = TextFieldDefaults.colors( color = if (uiState.passwordError != null) {
unfocusedContainerColor = Color.Transparent, Color(0xFFFFEBEE)
focusedContainerColor = Color.Transparent, } else {
unfocusedIndicatorColor = Color.Transparent, LoginInputLightBlue
focusedIndicatorColor = Color.Transparent, },
unfocusedTextColor = Color.Black, shape = RoundedCornerShape(20.dp)
focusedTextColor = Color.Black ),
), contentAlignment = Alignment.CenterStart
textStyle = MaterialTheme.typography.bodyLarge.copy( ) {
fontSize = inputTextSize TextField(
), value = uiState.confirmPassword,
singleLine = true, onValueChange = { viewModel.onConfirmPasswordChange(it) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) placeholder = {
) Text(
text = "Повтори пароль",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
visualTransformation = PasswordVisualTransformation(),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = uiState.passwordError != null
)
}
} }
// Поле email // Поле email
@@ -309,76 +338,17 @@ fun RegistrationScreen(
} }
} }
// Поле кода (показывается после нажатия "Готово!" когда все 4 поля заполнены)
if (uiState.showCodeField) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = inputHeight)
.background(
color = LoginInputLightBlue,
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
) {
TextField(
value = uiState.code,
onValueChange = { viewModel.onCodeChange(it) },
placeholder = {
Text(
text = "Введи код из письма на почте",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
}
}
// Кнопка "Готово!" - адаптивный размер
// Активна когда заполнены все 4 поля (до показа кода) или когда заполнен код
val isButtonEnabled = if (!uiState.showCodeField) {
uiState.isBasicFormValid && !uiState.isLoading
} else {
uiState.isFormValid && !uiState.isLoading
}
val buttonColor = if (isButtonEnabled) {
ButtonDefaults.buttonColors(
containerColor = LoginGreenAccent,
contentColor = Color.White
)
} else {
ButtonDefaults.buttonColors(
containerColor = LoginInputLightBlue,
contentColor = Color.Gray,
disabledContainerColor = LoginInputLightBlue,
disabledContentColor = Color.Gray
)
} }
// Кнопка "Зарегистрироваться" - адаптивный размер
Button( Button(
onClick = { onClick = {
viewModel.onReadyClick { if (uiState.isFormValid) {
navController.navigate("login") { viewModel.register {
popUpTo("login") { inclusive = false } // Переход на экран расписания после успешной регистрации
navController.navigate("schedule") {
popUpTo(0) { inclusive = true }
}
} }
} }
}, },
@@ -386,8 +356,20 @@ fun RegistrationScreen(
.fillMaxWidth() .fillMaxWidth()
.height(height = inputHeight), .height(height = inputHeight),
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
enabled = isButtonEnabled, enabled = uiState.isFormValid && !uiState.isLoading,
colors = buttonColor colors = if (uiState.isFormValid) {
ButtonDefaults.buttonColors(
containerColor = LoginGreenAccent,
contentColor = Color.White
)
} else {
ButtonDefaults.buttonColors(
containerColor = LoginInputLightBlue,
contentColor = Color.Gray,
disabledContainerColor = LoginInputLightBlue,
disabledContentColor = Color.Gray
)
}
) { ) {
if (uiState.isLoading) { if (uiState.isLoading) {
CircularProgressIndicator( CircularProgressIndicator(
@@ -397,7 +379,7 @@ fun RegistrationScreen(
} else { } else {
val buttonTextSize = (screenHeightDp * 0.027f).toInt().coerceIn(22, 34).sp val buttonTextSize = (screenHeightDp * 0.027f).toInt().coerceIn(22, 34).sp
Text( Text(
text = "Готово!", text = "Зарегистрироваться",
fontSize = buttonTextSize, fontSize = buttonTextSize,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )

View File

@@ -2,8 +2,8 @@ package com.novayaplaneta.ui.screens.auth
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.novayaplaneta.domain.usecase.RegisterUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -11,64 +11,74 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class RegistrationViewModel @Inject constructor() : ViewModel() { class RegistrationViewModel @Inject constructor(
private val registerUseCase: RegisterUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(RegistrationUiState()) private val _uiState = MutableStateFlow(RegistrationUiState())
val uiState: StateFlow<RegistrationUiState> = _uiState.asStateFlow() val uiState: StateFlow<RegistrationUiState> = _uiState.asStateFlow()
fun onLoginChange(login: String) { fun onFullNameChange(fullName: String) {
_uiState.value = _uiState.value.copy(login = login) _uiState.value = _uiState.value.copy(
fullName = fullName,
errorMessage = null
)
} }
fun onPasswordChange(password: String) { fun onPasswordChange(password: String) {
_uiState.value = _uiState.value.copy(password = password) _uiState.value = _uiState.value.copy(
password = password,
errorMessage = null
)
} }
fun onConfirmPasswordChange(password: String) { fun onConfirmPasswordChange(password: String) {
_uiState.value = _uiState.value.copy(confirmPassword = password) _uiState.value = _uiState.value.copy(
confirmPassword = password,
errorMessage = null
)
} }
fun onEmailChange(email: String) { fun onEmailChange(email: String) {
_uiState.value = _uiState.value.copy(email = email) _uiState.value = _uiState.value.copy(
email = email,
errorMessage = null
)
} }
fun onCodeChange(code: String) { fun register(onSuccess: () -> Unit) {
_uiState.value = _uiState.value.copy(code = code) if (!_uiState.value.isFormValid) return
}
fun onReadyClick(onSuccess: () -> Unit) {
val currentState = _uiState.value
// Если поле кода еще не показано, но все 4 поля заполнены - показываем поле кода viewModelScope.launch {
if (!currentState.showCodeField && currentState.isBasicFormValid) { _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
_uiState.value = currentState.copy(showCodeField = true)
return val result = registerUseCase(
} email = _uiState.value.email,
fullName = _uiState.value.fullName,
// Если форма полностью валидна (включая код), выполняем регистрацию password = _uiState.value.password,
if (currentState.isFormValid) { role = "CHILD"
viewModelScope.launch { )
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
result.onSuccess { user ->
// TODO: Реализовать вызов API для регистрации _uiState.value = _uiState.value.copy(isLoading = false, isRegistered = true)
// Пока что просто эмулируем успешную регистрацию
delay(1000)
_uiState.value = _uiState.value.copy(isLoading = false)
onSuccess() onSuccess()
}.onFailure { exception ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = exception.message ?: "Ошибка регистрации"
)
} }
} }
} }
} }
data class RegistrationUiState( data class RegistrationUiState(
val login: String = "", val fullName: String = "",
val password: String = "", val password: String = "",
val confirmPassword: String = "", val confirmPassword: String = "",
val email: String = "", val email: String = "",
val code: String = "",
val showCodeField: Boolean = false,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isRegistered: Boolean = false,
val errorMessage: String? = null val errorMessage: String? = null
) { ) {
// Валидация email // Валидация email
@@ -81,17 +91,19 @@ data class RegistrationUiState(
"Введите корректный email" "Введите корректный email"
} else null } else null
// Валидация первых 4 полей (без кода) // Валидация паролей
val isBasicFormValid: Boolean val passwordError: String?
get() = login.isNotBlank() && get() = if (confirmPassword.isNotBlank() && password != confirmPassword) {
"Пароли не совпадают"
} else null
// Валидация формы
val isFormValid: Boolean
get() = fullName.isNotBlank() &&
password.isNotBlank() && password.isNotBlank() &&
confirmPassword.isNotBlank() && confirmPassword.isNotBlank() &&
isEmailValid && isEmailValid &&
password == confirmPassword password == confirmPassword &&
password.length >= 6
// Валидация полной формы (включая код)
val isFormValid: Boolean
get() = isBasicFormValid &&
code.isNotBlank()
} }

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.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarToday import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Public
@@ -22,9 +24,11 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.novayaplaneta.ui.components.NovayaPlanetaLogo import com.novayaplaneta.ui.components.NovayaPlanetaLogo
import com.novayaplaneta.ui.theme.* import com.novayaplaneta.ui.theme.*
@@ -177,8 +181,26 @@ fun RewardsScreen(
modifier = Modifier.padding(bottom = 24.dp) modifier = Modifier.padding(bottom = 24.dp)
) )
// Отображение ошибок
uiState.errorMessage?.let { error ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = error,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
// Список наград в сетке (несколько колонок) // Список наград в сетке (несколько колонок)
if (uiState.isLoading) { if (uiState.isLoading && uiState.rewards.isEmpty()) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -195,13 +217,42 @@ fun RewardsScreen(
items(uiState.rewards) { reward -> items(uiState.rewards) { reward ->
RewardCard( RewardCard(
reward = reward, reward = reward,
screenHeightDp = screenHeightDp screenHeightDp = screenHeightDp,
accentGreen = accentGreen,
onClaimClick = { viewModel.claimReward(reward.id) },
onDeleteClick = { viewModel.deleteReward(reward.id) }
)
}
// Кнопка добавления в конце списка
item {
AddRewardButton(
onClick = { viewModel.showAddDialog() },
screenHeightDp = screenHeightDp,
accentGreen = accentGreen
) )
} }
} }
} }
} }
} }
// Диалог добавления награды
if (uiState.showAddDialog) {
AddRewardDialog(
rewardTitle = uiState.newRewardTitle,
rewardDescription = uiState.newRewardDescription,
rewardPoints = uiState.newRewardPoints,
onTitleChange = { viewModel.updateNewRewardTitle(it) },
onDescriptionChange = { viewModel.updateNewRewardDescription(it) },
onPointsChange = { viewModel.updateNewRewardPoints(it) },
onAdd = { viewModel.createReward() },
onDismiss = { viewModel.hideAddDialog() },
accentGreen = accentGreen,
screenHeightDp = screenHeightDp,
isLoading = uiState.isLoading
)
}
} }
} }
@@ -245,16 +296,20 @@ fun NavItem(
@Composable @Composable
fun RewardCard( fun RewardCard(
reward: com.novayaplaneta.domain.model.Reward, reward: com.novayaplaneta.domain.model.Reward,
screenHeightDp: Int screenHeightDp: Int,
accentGreen: Color,
onClaimClick: () -> Unit = {},
onDeleteClick: () -> Unit = {}
) { ) {
val cardHeight = 180.dp val cardHeight = 180.dp
val isClaimed = reward.isClaimed
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(cardHeight) .height(cardHeight)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(color = Color.LightGray) .background(color = if (isClaimed) accentGreen.copy(alpha = 0.3f) else Color.LightGray)
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
@@ -264,14 +319,14 @@ fun RewardCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(0.6f) .weight(0.6f)
.background(color = Color.White.copy(alpha = 0.5f)), .background(color = if (isClaimed) accentGreen.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Star, imageVector = Icons.Filled.Star,
contentDescription = reward.title, contentDescription = reward.title,
modifier = Modifier.size(60.dp), modifier = Modifier.size(60.dp),
tint = AccentGold tint = if (isClaimed) accentGreen else AccentGold
) )
} }
@@ -280,18 +335,250 @@ fun RewardCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(0.4f) .weight(0.4f)
.background(color = Color.LightGray) .background(color = if (isClaimed) accentGreen.copy(alpha = 0.3f) else Color.LightGray)
.padding(12.dp), .padding(12.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp Column(
Text( horizontalAlignment = Alignment.CenterHorizontally
text = reward.title, ) {
fontSize = textSize, val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp
fontWeight = FontWeight.Bold, Text(
color = Color.Black, text = reward.title,
textAlign = TextAlign.Center fontSize = textSize,
) fontWeight = FontWeight.Bold,
color = Color.Black,
textAlign = TextAlign.Center
)
Text(
text = "${reward.pointsRequired} очков",
fontSize = textSize * 0.7f,
color = Color.Black.copy(alpha = 0.7f),
textAlign = TextAlign.Center
)
if (isClaimed) {
Text(
text = "Получено",
fontSize = textSize * 0.6f,
color = accentGreen,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
} else {
Button(
onClick = onClaimClick,
modifier = Modifier.padding(top = 4.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentGreen,
contentColor = Color.White
)
) {
Text("Получить", fontSize = 10.sp)
}
}
}
}
}
}
}
@Composable
fun AddRewardButton(
onClick: () -> Unit,
screenHeightDp: Int,
accentGreen: Color
) {
val cardHeight = 180.dp
Box(
modifier = Modifier
.fillMaxWidth()
.height(cardHeight)
.clip(RoundedCornerShape(16.dp))
.background(color = accentGreen)
.clickable(onClick = onClick)
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Добавить награду",
modifier = Modifier.size(60.dp),
tint = Color.White
)
}
}
}
@Composable
fun AddRewardDialog(
rewardTitle: String,
rewardDescription: String,
rewardPoints: Int,
onTitleChange: (String) -> Unit,
onDescriptionChange: (String) -> Unit,
onPointsChange: (Int) -> Unit,
onAdd: () -> Unit,
onDismiss: () -> Unit,
accentGreen: Color,
screenHeightDp: Int,
isLoading: Boolean = false
) {
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth(0.8f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = Color.White
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// Заголовок
val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp
Text(
text = "Добавить награду",
fontSize = titleSize,
fontWeight = FontWeight.Bold,
color = Color.Black
)
// Поле ввода названия награды
val inputTextSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 22).sp
OutlinedTextField(
value = rewardTitle,
onValueChange = onTitleChange,
label = { Text("Название") },
placeholder = {
Text(
text = "Введите название награды",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isLoading,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedBorderColor = accentGreen,
unfocusedBorderColor = Color.Gray
),
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = inputTextSize
)
)
// Поле ввода описания
OutlinedTextField(
value = rewardDescription,
onValueChange = onDescriptionChange,
label = { Text("Описание") },
modifier = Modifier.fillMaxWidth(),
maxLines = 2,
enabled = !isLoading,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedBorderColor = accentGreen,
unfocusedBorderColor = Color.Gray
),
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = inputTextSize
)
)
// Поле ввода очков
OutlinedTextField(
value = rewardPoints.toString(),
onValueChange = {
it.toIntOrNull()?.let { points ->
if (points > 0) onPointsChange(points)
}
},
label = { Text("Очки") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedBorderColor = accentGreen,
unfocusedBorderColor = Color.Gray
),
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = inputTextSize
)
)
// Кнопки внизу
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Кнопка "Назад"
Button(
onClick = onDismiss,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.LightGray,
contentColor = Color.Black
)
) {
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
Text(
text = "Назад",
fontSize = buttonTextSize,
fontWeight = FontWeight.Bold
)
}
// Кнопка "Добавить"
Button(
onClick = onAdd,
modifier = Modifier
.weight(1f)
.height(56.dp),
enabled = rewardTitle.isNotBlank() && !isLoading,
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentGreen,
contentColor = Color.White,
disabledContainerColor = Color.LightGray,
disabledContentColor = Color.Gray
)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White
)
} else {
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
Text(
text = "Добавить",
fontSize = buttonTextSize,
fontWeight = FontWeight.Bold
)
}
}
}
} }
} }
} }

View File

@@ -3,127 +3,221 @@ package com.novayaplaneta.ui.screens.rewards
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.novayaplaneta.domain.model.Reward import com.novayaplaneta.domain.model.Reward
import com.novayaplaneta.domain.repository.RewardRepository import com.novayaplaneta.domain.repository.AuthRepository
import com.novayaplaneta.domain.usecase.ClaimRewardUseCase
import com.novayaplaneta.domain.usecase.CreateRewardUseCase
import com.novayaplaneta.domain.usecase.DeleteRewardUseCase
import com.novayaplaneta.domain.usecase.GetRewardsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class RewardsViewModel @Inject constructor( class RewardsViewModel @Inject constructor(
private val rewardRepository: RewardRepository private val getRewardsUseCase: GetRewardsUseCase,
private val createRewardUseCase: CreateRewardUseCase,
private val deleteRewardUseCase: DeleteRewardUseCase,
private val claimRewardUseCase: ClaimRewardUseCase,
private val authRepository: AuthRepository
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(RewardsUiState()) private val _uiState = MutableStateFlow(RewardsUiState())
val uiState: StateFlow<RewardsUiState> = _uiState.asStateFlow() val uiState: StateFlow<RewardsUiState> = _uiState.asStateFlow()
private var currentUserId: String? = null
init { init {
loadDefaultRewards() loadUserId()
} }
private fun loadDefaultRewards() { private fun loadUserId() {
// Создаем список наград по умолчанию
val defaultRewards = listOf(
Reward(
id = "reward_1",
title = "Золотая звезда",
description = "За выполнение 3 задач",
imageUrl = null,
points = 10,
earnedAt = null,
userId = "default"
),
Reward(
id = "reward_2",
title = "Подарочная коробка",
description = "За выполнение 5 задач",
imageUrl = null,
points = 20,
earnedAt = null,
userId = "default"
),
Reward(
id = "reward_3",
title = "Игрушка",
description = "За выполнение всех задач дня",
imageUrl = null,
points = 30,
earnedAt = null,
userId = "default"
),
Reward(
id = "reward_4",
title = "Мультфильм",
description = "За неделю без пропусков",
imageUrl = null,
points = 50,
earnedAt = null,
userId = "default"
),
Reward(
id = "reward_5",
title = "Поход в парк",
description = "За месяц работы",
imageUrl = null,
points = 100,
earnedAt = null,
userId = "default"
),
Reward(
id = "reward_6",
title = "Новая книга",
description = "За выполнение 10 задач",
imageUrl = null,
points = 40,
earnedAt = null,
userId = "default"
),
Reward(
id = "reward_7",
title = "Любимая игра",
description = "За хорошее поведение",
imageUrl = null,
points = 25,
earnedAt = null,
userId = "default"
),
Reward(
id = "reward_8",
title = "Специальный обед",
description = "За старание в учебе",
imageUrl = null,
points = 35,
earnedAt = null,
userId = "default"
)
)
_uiState.value = _uiState.value.copy(rewards = defaultRewards, isLoading = false)
}
fun loadRewards(userId: String) {
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true) try {
rewardRepository.getRewards(userId).collect { rewards -> val user = authRepository.getCurrentUser().first()
currentUserId = user?.id
if (currentUserId != null) {
loadRewards()
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
rewards = if (rewards.isEmpty()) _uiState.value.rewards else rewards, errorMessage = e.message ?: "Ошибка загрузки пользователя"
isLoading = false
) )
} }
} }
} }
fun earnReward(userId: String, rewardId: String) { fun loadRewards(isClaimed: Boolean? = null) {
viewModelScope.launch { viewModelScope.launch {
rewardRepository.earnReward(userId, rewardId) _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
try {
val loadResult = getRewardsUseCase.loadRewards(skip = 0, limit = 100, isClaimed = isClaimed)
if (loadResult.isFailure) {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = loadResult.exceptionOrNull()?.message ?: "Ошибка загрузки наград"
)
return@launch
}
val userId = currentUserId ?: return@launch
getRewardsUseCase(userId).collect { rewards ->
_uiState.value = _uiState.value.copy(
rewards = rewards,
isLoading = false,
errorMessage = null
)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = e.message ?: "Ошибка загрузки наград"
)
}
} }
} }
fun claimReward(rewardId: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
// Оптимистичное обновление UI
val currentRewards = _uiState.value.rewards.toMutableList()
val rewardIndex = currentRewards.indexOfFirst { it.id == rewardId }
if (rewardIndex >= 0) {
val oldReward = currentRewards[rewardIndex]
if (oldReward.isClaimed) {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = "Награда уже получена"
)
return@launch
}
currentRewards[rewardIndex] = oldReward.copy(isClaimed = true)
_uiState.value = _uiState.value.copy(rewards = currentRewards)
}
val result = claimRewardUseCase(rewardId)
result.onSuccess {
_uiState.value = _uiState.value.copy(isLoading = false)
// Список обновится автоматически через Flow
}.onFailure { exception ->
// Откатываем изменение в случае ошибки
if (rewardIndex >= 0) {
val oldReward = currentRewards[rewardIndex]
currentRewards[rewardIndex] = oldReward.copy(isClaimed = false)
_uiState.value = _uiState.value.copy(
rewards = currentRewards,
isLoading = false,
errorMessage = exception.message ?: "Ошибка получения награды"
)
}
}
}
}
fun showAddDialog() {
_uiState.value = _uiState.value.copy(
showAddDialog = true,
newRewardTitle = "",
newRewardDescription = "",
newRewardPoints = 10
)
}
fun hideAddDialog() {
_uiState.value = _uiState.value.copy(
showAddDialog = false,
newRewardTitle = "",
newRewardDescription = "",
newRewardPoints = 10
)
}
fun updateNewRewardTitle(title: String) {
_uiState.value = _uiState.value.copy(newRewardTitle = title)
}
fun updateNewRewardDescription(description: String) {
_uiState.value = _uiState.value.copy(newRewardDescription = description)
}
fun updateNewRewardPoints(points: Int) {
_uiState.value = _uiState.value.copy(newRewardPoints = points)
}
fun createReward() {
viewModelScope.launch {
val state = _uiState.value
val userId = currentUserId ?: return@launch
if (state.newRewardTitle.isBlank()) return@launch
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val newReward = Reward(
id = "", // Будет присвоен сервером
title = state.newRewardTitle,
description = state.newRewardDescription.takeIf { it.isNotBlank() },
imageUrl = null,
pointsRequired = state.newRewardPoints,
isClaimed = false,
earnedAt = null,
userId = userId
)
val result = createRewardUseCase(newReward)
result.onSuccess {
_uiState.value = _uiState.value.copy(
isLoading = false,
showAddDialog = false,
newRewardTitle = "",
newRewardDescription = "",
newRewardPoints = 10
)
// Список обновится автоматически через Flow
}.onFailure { exception ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = exception.message ?: "Ошибка создания награды"
)
}
}
}
fun deleteReward(rewardId: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val result = deleteRewardUseCase(rewardId)
result.onSuccess {
_uiState.value = _uiState.value.copy(isLoading = false)
// Список обновится автоматически через Flow
}.onFailure { exception ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = exception.message ?: "Ошибка удаления награды"
)
}
}
}
fun clearError() {
_uiState.value = _uiState.value.copy(errorMessage = null)
}
} }
data class RewardsUiState( data class RewardsUiState(
val rewards: List<Reward> = emptyList(), val rewards: List<Reward> = emptyList(),
val isLoading: Boolean = false val isLoading: Boolean = false,
val errorMessage: String? = null,
val showAddDialog: Boolean = false,
val newRewardTitle: String = "",
val newRewardDescription: String = "",
val newRewardPoints: Int = 10
) )

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 package com.novayaplaneta.ui.screens.schedule
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -12,6 +13,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarToday import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.SmartToy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
@@ -19,6 +22,9 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -171,28 +177,100 @@ fun ScheduleScreen(
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
// Основной контент: только сегодняшняя дата (опущена ниже) // Основной контент: список расписаний
DateSection( if (uiState.isLoading) {
date = dateOnly, Box(
dayOfWeek = dayOfWeek, modifier = Modifier.fillMaxSize(),
dateCardColor = dateCardColor, contentAlignment = Alignment.Center
accentGreen = accentGreen, ) {
screenHeightDp = screenHeightDp, CircularProgressIndicator(color = accentGreen)
tasks = uiState.tasks, }
onAddClick = { viewModel.showAddDialog() } } else if (uiState.errorMessage != null) {
) Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Ошибка",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.Red
)
Text(
text = uiState.errorMessage ?: "Неизвестная ошибка",
fontSize = 18.sp,
color = Color.Black
)
Button(
onClick = { viewModel.loadSchedules() },
colors = ButtonDefaults.buttonColors(containerColor = accentGreen)
) {
Text("Повторить")
}
}
}
} else {
SchedulesListSection(
schedules = uiState.schedules,
availableRewards = uiState.availableRewards,
dateOnly = dateOnly,
dayOfWeek = dayOfWeek,
dateCardColor = dateCardColor,
accentGreen = accentGreen,
screenHeightDp = screenHeightDp,
onAddClick = { viewModel.showAddDialog() },
onGenerateClick = { viewModel.showGenerateDialog() },
onScheduleClick = { scheduleId ->
navController?.navigate("tasks/$scheduleId")
},
onDeleteClick = { scheduleId ->
viewModel.deleteSchedule(scheduleId)
}
)
}
} }
} }
// Диалог выбора задачи // Диалог создания расписания
if (uiState.showAddDialog) { if (uiState.showAddDialog) {
AddTaskDialog( CreateScheduleDialog(
selectedTaskType = uiState.selectedTaskType, title = uiState.newScheduleTitle,
onTaskTypeSelected = { viewModel.selectTaskType(it) }, description = uiState.newScheduleDescription,
onSelect = { viewModel.addTask() }, date = uiState.newScheduleDate,
selectedRewardId = uiState.selectedRewardId,
availableRewards = uiState.availableRewards,
onTitleChange = { viewModel.updateNewScheduleTitle(it) },
onDescriptionChange = { viewModel.updateNewScheduleDescription(it) },
onDateChange = { viewModel.updateNewScheduleDate(it) },
onRewardSelected = { viewModel.updateSelectedReward(it) },
onCreate = { viewModel.createScheduleFromDialog() },
onDismiss = { viewModel.hideAddDialog() }, onDismiss = { viewModel.hideAddDialog() },
accentGreen = accentGreen, accentGreen = accentGreen,
screenHeightDp = screenHeightDp screenHeightDp = screenHeightDp,
isLoading = uiState.isLoading
)
}
// Диалог генерации расписания через ИИ
if (uiState.showGenerateDialog) {
GenerateScheduleDialog(
childAge = uiState.generateChildAge,
preferences = uiState.generatePreferences,
date = uiState.generateDate,
description = uiState.generateDescription,
onChildAgeChange = { viewModel.updateGenerateChildAge(it) },
onPreferencesChange = { viewModel.updateGeneratePreferences(it) },
onDateChange = { viewModel.updateGenerateDate(it) },
onDescriptionChange = { viewModel.updateGenerateDescription(it) },
onGenerate = { viewModel.generateScheduleWithAI() },
onDismiss = { viewModel.hideGenerateDialog() },
accentGreen = accentGreen,
screenHeightDp = screenHeightDp,
isLoading = uiState.isLoading
) )
} }
} }
@@ -236,19 +314,23 @@ fun NavItem(
} }
@Composable @Composable
fun DateSection( fun SchedulesListSection(
date: String, schedules: List<com.novayaplaneta.domain.model.Schedule>,
availableRewards: List<com.novayaplaneta.domain.model.Reward>,
dateOnly: String,
dayOfWeek: String, dayOfWeek: String,
dateCardColor: Color, dateCardColor: Color,
accentGreen: Color, accentGreen: Color,
screenHeightDp: Int, screenHeightDp: Int,
tasks: List<TaskType>, onAddClick: () -> Unit,
onAddClick: () -> Unit onGenerateClick: () -> Unit,
onScheduleClick: (String) -> Unit,
onDeleteClick: (String) -> Unit
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Дата и кнопка + в одной строке // Дата и кнопки в одной строке
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
@@ -267,7 +349,7 @@ fun DateSection(
horizontalAlignment = Alignment.Start horizontalAlignment = Alignment.Start
) { ) {
Text( Text(
text = date, text = dateOnly,
fontSize = dateTextSize, fontSize = dateTextSize,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.Black color = Color.Black
@@ -281,6 +363,20 @@ fun DateSection(
} }
} }
// Кнопка генерации через ИИ (синяя круглая)
FloatingActionButton(
onClick = onGenerateClick,
modifier = Modifier.size(80.dp),
containerColor = Color(0xFF2196F3),
contentColor = Color.White
) {
Icon(
imageVector = Icons.Default.SmartToy,
contentDescription = "Сгенерировать через ИИ",
modifier = Modifier.size(40.dp)
)
}
// Кнопка добавления (зеленая круглая с плюсом) // Кнопка добавления (зеленая круглая с плюсом)
FloatingActionButton( FloatingActionButton(
onClick = onAddClick, onClick = onAddClick,
@@ -296,16 +392,37 @@ fun DateSection(
} }
} }
// Задачи в ряд // Список расписаний
if (tasks.isNotEmpty()) { if (schedules.isEmpty()) {
LazyRow( Box(
horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier
.fillMaxWidth()
.padding(vertical = 48.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Нет расписаний",
fontSize = 20.sp,
color = Color.Gray
)
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
items(tasks) { task -> items(schedules) { schedule ->
TaskCard( val reward = schedule.rewardId?.let { rewardId ->
taskType = task, availableRewards.find { it.id == rewardId }
screenHeightDp = screenHeightDp }
ScheduleCard(
schedule = schedule,
reward = reward,
screenHeightDp = screenHeightDp,
accentGreen = accentGreen,
dateCardColor = dateCardColor,
onClick = { onScheduleClick(schedule.id) },
onDeleteClick = { onDeleteClick(schedule.id) }
) )
} }
} }
@@ -314,66 +431,131 @@ fun DateSection(
} }
@Composable @Composable
fun TaskCard( fun ScheduleCard(
taskType: TaskType, schedule: com.novayaplaneta.domain.model.Schedule,
screenHeightDp: Int reward: com.novayaplaneta.domain.model.Reward?,
screenHeightDp: Int,
accentGreen: Color,
dateCardColor: Color,
onClick: () -> Unit,
onDeleteClick: () -> Unit
) { ) {
val cardWidth = 200.dp val dateFormatter = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy")
val cardHeight = 180.dp val scheduleDate = schedule.date.toLocalDate().format(dateFormatter)
Box( Card(
modifier = Modifier modifier = Modifier
.width(cardWidth) .fillMaxWidth()
.height(cardHeight) .clickable { onClick() },
.clip(RoundedCornerShape(16.dp)) shape = RoundedCornerShape(16.dp),
.background(color = Color.LightGray) colors = CardDefaults.cardColors(containerColor = dateCardColor)
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize() modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// Верхняя часть (для изображения) Row(
Box( modifier = Modifier.fillMaxWidth(),
modifier = Modifier horizontalArrangement = Arrangement.SpaceBetween,
.fillMaxWidth() verticalAlignment = Alignment.CenterVertically
.weight(0.6f)
.background(color = Color.White.copy(alpha = 0.5f))
)
// Нижняя часть (для текста)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.4f)
.background(color = Color.LightGray)
.padding(12.dp),
contentAlignment = Alignment.Center
) { ) {
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp Column(modifier = Modifier.weight(1f)) {
Text(
text = schedule.title,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Color.Black
)
Text(
text = scheduleDate,
fontSize = 16.sp,
color = accentGreen,
fontWeight = FontWeight.Medium
)
}
IconButton(onClick = onDeleteClick) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Удалить",
tint = Color.Red
)
}
}
if (!schedule.description.isNullOrBlank()) {
Text( Text(
text = taskType.title, text = schedule.description ?: "",
fontSize = textSize, fontSize = 16.sp,
fontWeight = FontWeight.Bold, color = Color.Black
color = Color.Black,
textAlign = TextAlign.Center
) )
} }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (schedule.tasks.isNotEmpty()) {
Text(
text = "Задач: ${schedule.tasks.size}",
fontSize = 14.sp,
color = Color.Gray
)
}
// Отображение награды
reward?.let {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFFFFD700).copy(alpha = 0.3f)) // Золотистый цвет для награды
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = "Награда",
tint = Color(0xFFFFB300), // Не сильно оранжевый, золотистый
modifier = Modifier.size(20.dp)
)
Text(
text = it.title,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFFF8C00) // Не сильно оранжевый
)
}
}
}
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AddTaskDialog( fun CreateScheduleDialog(
selectedTaskType: TaskType?, title: String,
onTaskTypeSelected: (TaskType) -> Unit, description: String,
onSelect: () -> Unit, date: java.time.LocalDate?,
selectedRewardId: String?,
availableRewards: List<com.novayaplaneta.domain.model.Reward>,
onTitleChange: (String) -> Unit,
onDescriptionChange: (String) -> Unit,
onDateChange: (java.time.LocalDate) -> Unit,
onRewardSelected: (String?) -> Unit,
onCreate: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
accentGreen: Color, accentGreen: Color,
screenHeightDp: Int screenHeightDp: Int,
isLoading: Boolean
) { ) {
Dialog(onDismissRequest = onDismiss) { Dialog(onDismissRequest = onDismiss) {
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.8f) .fillMaxWidth(0.9f)
.wrapContentHeight(), .wrapContentHeight(),
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
@@ -385,39 +567,104 @@ fun AddTaskDialog(
.fillMaxWidth() .fillMaxWidth()
.padding(24.dp), .padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Заголовок // Заголовок
val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp
Text( Text(
text = "Выберите задачу", text = "Создать расписание",
fontSize = titleSize, fontSize = titleSize,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.Black color = Color.Black
) )
// Опции выбора // Поле названия
Column( OutlinedTextField(
verticalArrangement = Arrangement.spacedBy(16.dp), value = title,
modifier = Modifier.fillMaxWidth() onValueChange = onTitleChange,
) { label = { Text("Название") },
// Подарок modifier = Modifier.fillMaxWidth(),
TaskOption( enabled = !isLoading
taskType = TaskType.Gift, )
isSelected = selectedTaskType == TaskType.Gift,
onClick = { onTaskTypeSelected(TaskType.Gift) }, // Поле описания
accentGreen = accentGreen, OutlinedTextField(
screenHeightDp = screenHeightDp value = description,
) onValueChange = onDescriptionChange,
label = { Text("Описание") },
// Кушать ложкой modifier = Modifier.fillMaxWidth(),
TaskOption( maxLines = 3,
taskType = TaskType.EatWithSpoon, enabled = !isLoading
isSelected = selectedTaskType == TaskType.EatWithSpoon, )
onClick = { onTaskTypeSelected(TaskType.EatWithSpoon) },
accentGreen = accentGreen, // Выбор награды
screenHeightDp = screenHeightDp if (availableRewards.isNotEmpty()) {
) Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Награда (опционально)",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.Black
)
// Выпадающий список наград
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = availableRewards.find { it.id == selectedRewardId }?.title ?: "Не выбрано",
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor()
.fillMaxWidth(),
enabled = !isLoading,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentGreen,
focusedLabelColor = accentGreen
)
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
// Опция "Не выбрано"
DropdownMenuItem(
text = { Text("Не выбрано") },
onClick = {
onRewardSelected(null)
expanded = false
}
)
availableRewards.forEach { reward ->
DropdownMenuItem(
text = {
Column {
Text(reward.title, fontWeight = FontWeight.Bold)
reward.description?.let {
Text(it, fontSize = 12.sp, color = Color.Gray)
}
}
},
onClick = {
onRewardSelected(reward.id)
expanded = false
}
)
}
}
}
}
} }
// Кнопки внизу // Кнопки внизу
@@ -425,7 +672,7 @@ fun AddTaskDialog(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Кнопка "Назад" // Кнопка "Отмена"
Button( Button(
onClick = onDismiss, onClick = onDismiss,
modifier = Modifier modifier = Modifier
@@ -435,23 +682,24 @@ fun AddTaskDialog(
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = Color.LightGray, containerColor = Color.LightGray,
contentColor = Color.Black contentColor = Color.Black
) ),
enabled = !isLoading
) { ) {
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
Text( Text(
text = "Назад", text = "Отмена",
fontSize = buttonTextSize, fontSize = buttonTextSize,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
} }
// Кнопка "Выбрать" // Кнопка "Создать"
Button( Button(
onClick = onSelect, onClick = onCreate,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(56.dp), .height(56.dp),
enabled = selectedTaskType != null, enabled = title.isNotBlank() && !isLoading,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = accentGreen, containerColor = accentGreen,
@@ -460,12 +708,19 @@ fun AddTaskDialog(
disabledContentColor = Color.Gray disabledContentColor = Color.Gray
) )
) { ) {
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp if (isLoading) {
Text( CircularProgressIndicator(
text = "Выбрать", modifier = Modifier.size(24.dp),
fontSize = buttonTextSize, color = Color.White
fontWeight = FontWeight.Bold )
) } else {
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
Text(
text = "Создать",
fontSize = buttonTextSize,
fontWeight = FontWeight.Bold
)
}
} }
} }
} }
@@ -473,34 +728,4 @@ fun AddTaskDialog(
} }
} }
@Composable
fun TaskOption(
taskType: TaskType,
isSelected: Boolean,
onClick: () -> Unit,
accentGreen: Color,
screenHeightDp: Int
) {
val borderWidth = if (isSelected) 4.dp else 2.dp
val borderColor = if (isSelected) accentGreen else Color.Gray
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.clip(RoundedCornerShape(16.dp))
.border(borderWidth, borderColor, RoundedCornerShape(16.dp))
.background(color = if (isSelected) accentGreen.copy(alpha = 0.1f) else Color.Transparent)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
val textSize = (screenHeightDp * 0.025f).toInt().coerceIn(20, 28).sp
Text(
text = taskType.title,
fontSize = textSize,
fontWeight = FontWeight.Bold,
color = if (isSelected) accentGreen else Color.Black
)
}
}

View File

@@ -2,12 +2,22 @@ package com.novayaplaneta.ui.screens.schedule
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.novayaplaneta.domain.model.Reward
import com.novayaplaneta.domain.model.Schedule
import com.novayaplaneta.domain.repository.AuthRepository
import com.novayaplaneta.domain.usecase.CreateScheduleUseCase
import com.novayaplaneta.domain.usecase.DeleteScheduleUseCase
import com.novayaplaneta.domain.usecase.GenerateScheduleUseCase
import com.novayaplaneta.domain.usecase.GetRewardsUseCase
import com.novayaplaneta.domain.usecase.GetSchedulesUseCase import com.novayaplaneta.domain.usecase.GetSchedulesUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import javax.inject.Inject import javax.inject.Inject
sealed class TaskType(val title: String) { sealed class TaskType(val title: String) {
@@ -17,52 +27,277 @@ sealed class TaskType(val title: String) {
@HiltViewModel @HiltViewModel
class ScheduleViewModel @Inject constructor( class ScheduleViewModel @Inject constructor(
private val getSchedulesUseCase: GetSchedulesUseCase private val getSchedulesUseCase: GetSchedulesUseCase,
private val createScheduleUseCase: CreateScheduleUseCase,
private val deleteScheduleUseCase: DeleteScheduleUseCase,
private val generateScheduleUseCase: GenerateScheduleUseCase,
private val getRewardsUseCase: GetRewardsUseCase,
private val authRepository: AuthRepository
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ScheduleUiState()) private val _uiState = MutableStateFlow(ScheduleUiState())
val uiState: StateFlow<ScheduleUiState> = _uiState.asStateFlow() val uiState: StateFlow<ScheduleUiState> = _uiState.asStateFlow()
fun loadSchedules(userId: String) { init {
loadSchedules()
loadRewards()
}
private fun loadRewards() {
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true) try {
getSchedulesUseCase(userId).collect { schedules -> val user = authRepository.getCurrentUser().first()
val userId = user?.id ?: return@launch
val loadResult = getRewardsUseCase.loadRewards()
if (loadResult.isSuccess) {
getRewardsUseCase(userId).collect { rewards ->
_uiState.value = _uiState.value.copy(availableRewards = rewards)
}
}
} catch (e: Exception) {
// Игнорируем ошибки загрузки наград
}
}
}
fun loadSchedules(scheduleDate: String? = null) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
try {
// Получаем текущего пользователя
val user = authRepository.getCurrentUser().first()
val userId = user?.id ?: ""
// Загружаем расписания с сервера
val loadResult = getSchedulesUseCase.loadSchedules(scheduleDate)
if (loadResult.isFailure) {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = loadResult.exceptionOrNull()?.message ?: "Ошибка загрузки расписаний"
)
return@launch
}
// Получаем актуальный список расписаний из Flow
val schedules = getSchedulesUseCase(userId).first()
// Фильтруем расписания по дате, если указана
val filteredSchedules = if (scheduleDate != null) {
schedules.filter { schedule ->
val scheduleDateStr = schedule.date.toLocalDate().format(DateTimeFormatter.ISO_DATE)
scheduleDateStr == scheduleDate
}
} else {
schedules
}
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
schedules = schedules, schedules = filteredSchedules,
isLoading = false isLoading = false,
errorMessage = null
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = e.message ?: "Ошибка загрузки расписаний"
) )
} }
} }
} }
fun showAddDialog() { fun showAddDialog() {
_uiState.value = _uiState.value.copy(showAddDialog = true, selectedTaskType = null) _uiState.value = _uiState.value.copy(
showAddDialog = true,
newScheduleTitle = "",
newScheduleDescription = "",
newScheduleDate = LocalDate.now(),
selectedRewardId = null
)
} }
fun hideAddDialog() { fun hideAddDialog() {
_uiState.value = _uiState.value.copy(showAddDialog = false, selectedTaskType = null)
}
fun selectTaskType(taskType: TaskType) {
_uiState.value = _uiState.value.copy(selectedTaskType = taskType)
}
fun addTask() {
val selected = _uiState.value.selectedTaskType ?: return
val newTasks = _uiState.value.tasks + selected
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
tasks = newTasks,
showAddDialog = false, showAddDialog = false,
selectedTaskType = null newScheduleTitle = "",
newScheduleDescription = "",
newScheduleDate = null,
selectedRewardId = null
) )
} }
fun updateSelectedReward(rewardId: String?) {
_uiState.value = _uiState.value.copy(selectedRewardId = rewardId)
}
fun updateNewScheduleTitle(title: String) {
_uiState.value = _uiState.value.copy(newScheduleTitle = title)
}
fun updateNewScheduleDescription(description: String) {
_uiState.value = _uiState.value.copy(newScheduleDescription = description)
}
fun updateNewScheduleDate(date: LocalDate) {
_uiState.value = _uiState.value.copy(newScheduleDate = date)
}
fun createScheduleFromDialog() {
val state = _uiState.value
if (state.newScheduleTitle.isNotBlank() && state.newScheduleDate != null) {
createSchedule(
title = state.newScheduleTitle,
description = state.newScheduleDescription,
date = state.newScheduleDate,
rewardId = state.selectedRewardId
)
}
}
fun createSchedule(title: String, description: String, date: LocalDate, rewardId: String? = null) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
try {
val user = authRepository.getCurrentUser().first()
val userId = user?.id ?: ""
val schedule = Schedule(
id = "", // Будет присвоен сервером
title = title,
description = description,
tasks = emptyList(),
date = date.atStartOfDay(),
createdAt = java.time.LocalDateTime.now(),
userId = userId,
rewardId = rewardId
)
val result = createScheduleUseCase(schedule)
result.onSuccess {
_uiState.value = _uiState.value.copy(
isLoading = false,
showAddDialog = false
)
// Перезагружаем расписания
loadSchedules()
}.onFailure { exception ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = exception.message ?: "Ошибка создания расписания"
)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = e.message ?: "Ошибка создания расписания"
)
}
}
}
fun deleteSchedule(scheduleId: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val result = deleteScheduleUseCase(scheduleId)
result.onSuccess {
_uiState.value = _uiState.value.copy(isLoading = false)
// Перезагружаем расписания
loadSchedules()
}.onFailure { exception ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = exception.message ?: "Ошибка удаления расписания"
)
}
}
}
fun showGenerateDialog() {
_uiState.value = _uiState.value.copy(
showGenerateDialog = true,
generateChildAge = 5,
generatePreferences = emptyList(),
generateDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE),
generateDescription = ""
)
}
fun hideGenerateDialog() {
_uiState.value = _uiState.value.copy(
showGenerateDialog = false,
generateChildAge = 5,
generatePreferences = emptyList(),
generateDate = "",
generateDescription = ""
)
}
fun updateGenerateChildAge(age: Int) {
_uiState.value = _uiState.value.copy(generateChildAge = age)
}
fun updateGeneratePreferences(preferences: List<String>) {
_uiState.value = _uiState.value.copy(generatePreferences = preferences)
}
fun updateGenerateDate(date: String) {
_uiState.value = _uiState.value.copy(generateDate = date)
}
fun updateGenerateDescription(description: String) {
_uiState.value = _uiState.value.copy(generateDescription = description)
}
fun generateScheduleWithAI() {
viewModelScope.launch {
val state = _uiState.value
if (state.generateDate.isNotBlank()) {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val result = generateScheduleUseCase(
childAge = state.generateChildAge,
preferences = state.generatePreferences,
date = state.generateDate,
description = state.generateDescription
)
result.onSuccess { generateResult ->
_uiState.value = _uiState.value.copy(
isLoading = false,
showGenerateDialog = false,
generatedScheduleId = generateResult.scheduleId
)
// Перезагружаем расписания, чтобы показать новое
loadSchedules()
}.onFailure { exception ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = exception.message ?: "Ошибка генерации расписания"
)
}
}
}
}
} }
data class ScheduleUiState( data class ScheduleUiState(
val schedules: List<com.novayaplaneta.domain.model.Schedule> = emptyList(), val schedules: List<Schedule> = emptyList(),
val tasks: List<TaskType> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val errorMessage: String? = null,
val showAddDialog: Boolean = false, val showAddDialog: Boolean = false,
val selectedTaskType: TaskType? = null val newScheduleTitle: String = "",
val newScheduleDescription: String = "",
val newScheduleDate: LocalDate? = null,
val selectedRewardId: String? = null,
val availableRewards: List<Reward> = emptyList(),
val showGenerateDialog: Boolean = false,
val generateChildAge: Int = 5,
val generatePreferences: List<String> = emptyList(),
val generateDate: String = "",
val generateDescription: String = "",
val generatedScheduleId: String? = null
) )

View File

@@ -210,12 +210,12 @@ fun SettingsScreen(
val textSize = (screenHeightDp * 0.025f).toInt().coerceIn(20, 28).sp val textSize = (screenHeightDp * 0.025f).toInt().coerceIn(20, 28).sp
UserInfoRow( UserInfoRow(
label = "Имя:", label = "Имя:",
value = user.name, value = user.fullName,
textSize = textSize textSize = textSize
) )
UserInfoRow( UserInfoRow(
label = "Логин:", label = "Логин:",
value = "${user.name}12", // Используем имя + число как логин value = "${user.fullName}12", // Используем имя + число как логин
textSize = textSize textSize = textSize
) )
UserInfoRow( UserInfoRow(

View File

@@ -48,10 +48,9 @@ class SettingsViewModel @Inject constructor(
// Заглушка с тестовыми данными // Заглушка с тестовыми данными
return User( return User(
id = "user_123", id = "user_123",
name = "Коля", fullName = "Коля",
email = "kolya12@mail.ru", email = "kolya12@mail.ru",
role = UserRole.CHILD, role = UserRole.CHILD
token = null
) )
} }

View File

@@ -1,75 +1,443 @@
package com.novayaplaneta.ui.screens.task package com.novayaplaneta.ui.screens.task
import androidx.compose.foundation.layout.* import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.* import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.novayaplaneta.ui.theme.LoginBackgroundTurquoise
import com.novayaplaneta.ui.theme.LoginGreenAccent
import com.novayaplaneta.ui.theme.LoginInputLightBlue
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TaskScreen( fun TaskScreen(
scheduleId: String? = null,
navController: NavController? = null,
viewModel: TaskViewModel = hiltViewModel(), viewModel: TaskViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val configuration = LocalConfiguration.current
Scaffold( val screenHeightDp = configuration.screenHeightDp
topBar = {
TopAppBar( // Цвета из autism-friendly палитры
title = { Text("Задания") } 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( Column(
modifier = modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(24.dp)
.padding(16.dp)
) { ) {
if (uiState.isLoading) { // Верхняя панель с кнопкой назад и кнопкой добавления
CircularProgressIndicator( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() horizontalArrangement = Arrangement.SpaceBetween,
.wrapContentWidth() verticalAlignment = Alignment.CenterVertically
) {
// Кнопка назад
if (navController != null) {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
Icons.Filled.ArrowBack,
contentDescription = "Назад",
tint = accentGreen,
modifier = Modifier.size(32.dp)
)
}
} else {
Spacer(modifier = Modifier.size(48.dp))
}
// Заголовок
Text(
text = "Задания",
fontSize = (screenHeightDp * 0.04f).toInt().coerceIn(32, 48).sp,
fontWeight = FontWeight.Bold,
color = accentGreen
) )
// Кнопка добавления
if (scheduleId != null) {
FloatingActionButton(
onClick = { viewModel.showAddDialog() },
modifier = Modifier.size(56.dp),
containerColor = accentGreen,
contentColor = Color.White
) {
Icon(
Icons.Filled.Add,
contentDescription = "Добавить задачу",
modifier = Modifier.size(28.dp)
)
}
} else {
Spacer(modifier = Modifier.size(56.dp))
}
}
Spacer(modifier = Modifier.height(32.dp))
// Контент
if (uiState.isLoading && uiState.tasks.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = accentGreen)
}
} else if (uiState.tasks.isEmpty() && !uiState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Нет задач",
fontSize = 24.sp,
color = Color.Gray,
fontWeight = FontWeight.Medium
)
}
} else { } else {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) { ) {
items(uiState.tasks) { task -> items(uiState.tasks) { task ->
Card( // Получаем состояние таймера из Flow
modifier = Modifier.fillMaxWidth() val timerStates by viewModel.timerStates.collectAsState()
) { val timerState = timerStates[task.id]
Row(
modifier = Modifier TaskCard(
.fillMaxWidth() task = task,
.padding(16.dp), timerState = timerState,
horizontalArrangement = Arrangement.SpaceBetween accentGreen = accentGreen,
) { dateCardColor = dateCardColor,
Column(modifier = Modifier.weight(1f)) { screenHeightDp = screenHeightDp,
Text( onStartTimer = { duration ->
text = task.title, viewModel.startTimer(task.id, duration)
style = MaterialTheme.typography.titleMedium },
) onPauseTimer = {
task.description?.let { viewModel.pauseTimer(task.id)
Text( },
text = it, onResumeTimer = {
style = MaterialTheme.typography.bodySmall viewModel.resumeTimer(task.id)
) },
} onStopTimer = {
} viewModel.stopTimer(task.id)
Checkbox( },
checked = task.completed, onDelete = {
onCheckedChange = { viewModel.completeTask(task.id) } viewModel.deleteTask(task.id)
) }
)
}
}
}
}
// Диалог создания задачи/награды
if (uiState.showAddDialog) {
CreateTaskOrRewardDialog(
dialogMode = uiState.dialogMode,
taskTitle = uiState.newTaskTitle,
taskDescription = uiState.newTaskDescription,
taskDuration = uiState.newTaskDuration,
rewardTitle = uiState.newRewardTitle,
rewardDescription = uiState.newRewardDescription,
rewardPoints = uiState.newRewardPoints,
onModeChange = { viewModel.setDialogMode(it) },
onTaskTitleChange = { viewModel.updateNewTaskTitle(it) },
onTaskDescriptionChange = { viewModel.updateNewTaskDescription(it) },
onTaskDurationChange = { viewModel.updateNewTaskDuration(it) },
onRewardTitleChange = { viewModel.updateNewRewardTitle(it) },
onRewardDescriptionChange = { viewModel.updateNewRewardDescription(it) },
onRewardPointsChange = { viewModel.updateNewRewardPoints(it) },
onCreate = {
when (uiState.dialogMode) {
DialogMode.TASK -> {
scheduleId?.let { id ->
viewModel.createTask(id)
} }
} }
DialogMode.REWARD -> {
viewModel.createReward()
}
}
},
onDismiss = { viewModel.hideAddDialog() },
accentGreen = accentGreen,
screenHeightDp = screenHeightDp,
isLoading = uiState.isLoading,
scheduleId = scheduleId
)
}
// Snackbar для ошибок
uiState.errorMessage?.let { error ->
LaunchedEffect(error) {
// Можно добавить SnackbarHost если нужно
}
}
}
}
@Composable
fun TaskCard(
task: com.novayaplaneta.domain.model.Task,
timerState: TimerState?,
accentGreen: Color,
dateCardColor: Color,
screenHeightDp: Int,
onStartTimer: (Int) -> Unit,
onPauseTimer: () -> Unit,
onResumeTimer: () -> Unit,
onStopTimer: () -> Unit,
onDelete: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = dateCardColor)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = task.title,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Color.Black
)
task.description?.let {
Text(
text = it,
fontSize = 16.sp,
color = Color.Black
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
task.duration?.let {
Text(
text = "Длительность: $it мин",
fontSize = 14.sp,
color = Color.Gray
)
}
if (task.completed) {
Text(
text = "✓ Выполнено",
fontSize = 14.sp,
color = accentGreen,
fontWeight = FontWeight.Medium
)
}
}
}
// Таймер или кнопка удаления
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (task.completed) {
// Показываем только иконку выполнения
Icon(
Icons.Filled.PlayArrow,
contentDescription = "Выполнено",
tint = accentGreen,
modifier = Modifier.size(32.dp)
)
} else if (timerState != null) {
// Показываем визуальный таймер
TaskTimer(
timerState = timerState,
accentGreen = accentGreen,
onPause = onPauseTimer,
onResume = onResumeTimer,
onStop = onStopTimer
)
} else {
// Показываем кнопку запуска таймера
IconButton(
onClick = {
task.duration?.let { onStartTimer(it) }
}
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = "Запустить таймер",
tint = accentGreen,
modifier = Modifier.size(32.dp)
)
}
}
IconButton(onClick = onDelete) {
Icon(
Icons.Filled.Delete,
contentDescription = "Удалить",
tint = Color.Red,
modifier = Modifier.size(24.dp)
)
}
}
}
}
}
@Composable
fun TaskTimer(
timerState: TimerState,
accentGreen: Color,
onPause: () -> Unit,
onResume: () -> Unit,
onStop: () -> Unit
) {
val totalSeconds = timerState.durationMinutes * 60
val progress = if (totalSeconds > 0) {
timerState.remainingSeconds.toFloat() / totalSeconds
} else 0f
val timeText = formatTime(timerState.remainingSeconds)
Box(
modifier = Modifier.size(100.dp),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val strokeWidth = 10.dp.toPx()
val radius = size.minDimension / 2 - strokeWidth / 2
// Фоновый круг
drawCircle(
color = Color.Gray.copy(alpha = 0.2f),
radius = radius,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
// Прогресс таймера (обратный - показываем оставшееся время)
val sweepAngle = 360f * progress
drawArc(
color = accentGreen,
startAngle = -90f,
sweepAngle = sweepAngle,
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(8.dp)
) {
Text(
text = timeText,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.Black
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(top = 4.dp)
) {
if (timerState.isRunning) {
IconButton(
onClick = onPause,
modifier = Modifier.size(28.dp)
) {
Icon(
Icons.Filled.Pause,
contentDescription = "Пауза",
tint = accentGreen,
modifier = Modifier.size(20.dp)
)
}
} else {
IconButton(
onClick = onResume,
modifier = Modifier.size(28.dp)
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = "Продолжить",
tint = accentGreen,
modifier = Modifier.size(20.dp)
)
} }
} }
} }
@@ -77,3 +445,264 @@ fun TaskScreen(
} }
} }
private fun formatTime(seconds: Int): String {
val minutes = seconds / 60
val remainingSeconds = seconds % 60
return String.format("%02d:%02d", minutes, remainingSeconds)
}
@Composable
fun CreateTaskOrRewardDialog(
dialogMode: DialogMode,
taskTitle: String,
taskDescription: String,
taskDuration: String,
rewardTitle: String,
rewardDescription: String,
rewardPoints: Int,
onModeChange: (DialogMode) -> Unit,
onTaskTitleChange: (String) -> Unit,
onTaskDescriptionChange: (String) -> Unit,
onTaskDurationChange: (String) -> Unit,
onRewardTitleChange: (String) -> Unit,
onRewardDescriptionChange: (String) -> Unit,
onRewardPointsChange: (Int) -> Unit,
onCreate: () -> Unit,
onDismiss: () -> Unit,
accentGreen: Color,
screenHeightDp: Int,
isLoading: Boolean,
scheduleId: String?
) {
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = Color.White
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Переключатель режимов
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Кнопка "Задача"
Button(
onClick = { onModeChange(DialogMode.TASK) },
modifier = Modifier
.weight(1f)
.height(48.dp),
enabled = scheduleId != null && !isLoading,
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (dialogMode == DialogMode.TASK) accentGreen else Color.LightGray,
contentColor = if (dialogMode == DialogMode.TASK) Color.White else Color.Black,
disabledContainerColor = Color.LightGray,
disabledContentColor = Color.Gray
)
) {
Text(
text = "Задача",
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
// Кнопка "Награда"
Button(
onClick = { onModeChange(DialogMode.REWARD) },
modifier = Modifier
.weight(1f)
.height(48.dp),
enabled = !isLoading,
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (dialogMode == DialogMode.REWARD) accentGreen else Color.LightGray,
contentColor = if (dialogMode == DialogMode.REWARD) Color.White else Color.Black
)
) {
Text(
text = "Награда",
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
}
// Заголовок
val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp
Text(
text = if (dialogMode == DialogMode.TASK) "Создать задачу" else "Создать награду",
fontSize = titleSize,
fontWeight = FontWeight.Bold,
color = Color.Black
)
when (dialogMode) {
DialogMode.TASK -> {
// Поле названия задачи
OutlinedTextField(
value = taskTitle,
onValueChange = onTaskTitleChange,
label = { Text("Название") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentGreen,
focusedLabelColor = accentGreen
)
)
// Поле описания задачи
OutlinedTextField(
value = taskDescription,
onValueChange = onTaskDescriptionChange,
label = { Text("Описание") },
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
enabled = !isLoading,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentGreen,
focusedLabelColor = accentGreen
)
)
// Поле длительности
OutlinedTextField(
value = taskDuration,
onValueChange = { newValue ->
if (newValue.all { it.isDigit() }) {
onTaskDurationChange(newValue)
}
},
label = { Text("Длительность (минуты)") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentGreen,
focusedLabelColor = accentGreen
)
)
}
DialogMode.REWARD -> {
// Поле названия награды
OutlinedTextField(
value = rewardTitle,
onValueChange = onRewardTitleChange,
label = { Text("Название") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentGreen,
focusedLabelColor = accentGreen
)
)
// Поле описания награды
OutlinedTextField(
value = rewardDescription,
onValueChange = onRewardDescriptionChange,
label = { Text("Описание") },
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
enabled = !isLoading,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentGreen,
focusedLabelColor = accentGreen
)
)
// Поле баллов
OutlinedTextField(
value = rewardPoints.toString(),
onValueChange = { newValue ->
if (newValue.all { it.isDigit() }) {
onRewardPointsChange(newValue.toIntOrNull() ?: 10)
}
},
label = { Text("Баллы") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentGreen,
focusedLabelColor = accentGreen
)
)
}
}
// Кнопки внизу
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Кнопка "Отмена"
Button(
onClick = onDismiss,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.LightGray,
contentColor = Color.Black
),
enabled = !isLoading
) {
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
Text(
text = "Отмена",
fontSize = buttonTextSize,
fontWeight = FontWeight.Bold
)
}
// Кнопка "Создать"
Button(
onClick = onCreate,
modifier = Modifier
.weight(1f)
.height(56.dp),
enabled = when (dialogMode) {
DialogMode.TASK -> taskTitle.isNotBlank() && !isLoading
DialogMode.REWARD -> rewardTitle.isNotBlank() && !isLoading
},
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentGreen,
contentColor = Color.White,
disabledContainerColor = Color.LightGray,
disabledContentColor = Color.Gray
)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White
)
} else {
val buttonTextSize =
(screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
Text(
text = "Создать",
fontSize = buttonTextSize,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
}

View File

@@ -2,43 +2,501 @@ package com.novayaplaneta.ui.screens.task
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.novayaplaneta.domain.repository.TaskRepository import com.novayaplaneta.data.local.TimerStateManager
import com.novayaplaneta.data.local.SavedTimerState
import com.novayaplaneta.domain.model.Reward
import com.novayaplaneta.domain.model.Task
import com.novayaplaneta.domain.repository.AuthRepository
import com.novayaplaneta.domain.usecase.ClaimRewardUseCase
import com.novayaplaneta.domain.usecase.CompleteTaskUseCase
import com.novayaplaneta.domain.usecase.CreateRewardUseCase
import com.novayaplaneta.domain.usecase.CreateTaskUseCase
import com.novayaplaneta.domain.usecase.DeleteTaskUseCase
import com.novayaplaneta.domain.usecase.GetSchedulesUseCase
import com.novayaplaneta.domain.usecase.GetTaskByIdUseCase
import com.novayaplaneta.domain.usecase.GetTasksUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDateTime
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class TaskViewModel @Inject constructor( class TaskViewModel @Inject constructor(
private val taskRepository: TaskRepository private val getTasksUseCase: GetTasksUseCase,
private val getTaskByIdUseCase: GetTaskByIdUseCase,
private val createTaskUseCase: CreateTaskUseCase,
private val createRewardUseCase: CreateRewardUseCase,
private val deleteTaskUseCase: DeleteTaskUseCase,
private val completeTaskUseCase: CompleteTaskUseCase,
private val claimRewardUseCase: ClaimRewardUseCase,
private val getSchedulesUseCase: GetSchedulesUseCase,
private val authRepository: AuthRepository,
private val timerStateManager: TimerStateManager
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(TaskUiState()) private val _uiState = MutableStateFlow(TaskUiState())
val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow() val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()
private val _timerStates = MutableStateFlow<Map<String, TimerState>>(emptyMap())
val timerStates: StateFlow<Map<String, TimerState>> = _timerStates.asStateFlow()
private var currentScheduleId: String? = null
private val timerJobs = mutableMapOf<String, Job>()
fun loadTasks(scheduleId: String) { fun loadTasks(scheduleId: String) {
currentScheduleId = scheduleId
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true) _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
taskRepository.getTasks(scheduleId).collect { tasks ->
try {
// Загружаем сохраненные состояния таймеров
val savedStates = timerStateManager.getAllTimerStates()
savedStates.forEach { (taskId, savedState) ->
val timerState = TimerState(
isRunning = savedState.isRunning,
durationMinutes = savedState.durationMinutes,
remainingSeconds = savedState.remainingSeconds
)
val currentStates = _timerStates.value.toMutableMap()
currentStates[taskId] = timerState
_timerStates.value = currentStates
// Перезапускаем таймер если он был запущен
if (savedState.isRunning && savedState.remainingSeconds > 0) {
resumeTimer(taskId)
}
}
val loadResult = getTasksUseCase.loadTasks(scheduleId)
if (loadResult.isFailure) {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = loadResult.exceptionOrNull()?.message ?: "Ошибка загрузки задач"
)
return@launch
}
// Подписываемся на Flow для обновлений
getTasksUseCase(scheduleId).collect { tasks ->
_uiState.value = _uiState.value.copy(
tasks = tasks.sortedBy { it.order },
isLoading = false,
errorMessage = null
)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
tasks = tasks, isLoading = false,
isLoading = false errorMessage = e.message ?: "Ошибка загрузки задач"
) )
} }
} }
} }
fun completeTask(taskId: String) { fun loadTaskById(taskId: String) {
viewModelScope.launch { viewModelScope.launch {
taskRepository.completeTask(taskId) _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val result = getTaskByIdUseCase(taskId)
result.onSuccess { task ->
_uiState.value = _uiState.value.copy(
selectedTask = task,
isLoading = false,
errorMessage = null
)
}.onFailure { exception ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = exception.message ?: "Ошибка загрузки задачи"
)
}
} }
} }
fun createTask(scheduleId: String) {
val title = _uiState.value.newTaskTitle
if (title.isBlank()) return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val task = Task(
id = UUID.randomUUID().toString(),
title = title,
description = _uiState.value.newTaskDescription.takeIf { it.isNotBlank() },
imageUrl = null,
completed = false,
scheduledTime = null,
duration = _uiState.value.newTaskDuration.toIntOrNull(),
scheduleId = scheduleId,
order = _uiState.value.tasks.size,
category = null,
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now()
)
val result = createTaskUseCase(task)
result.onSuccess {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = null,
showAddDialog = false,
newTaskTitle = "",
newTaskDescription = "",
newTaskDuration = ""
)
// Список задач обновится автоматически через Flow
}.onFailure { exception ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = exception.message ?: "Ошибка создания задачи"
)
}
}
}
fun createReward() {
val title = _uiState.value.newRewardTitle
if (title.isBlank()) return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val userId = authRepository.getCurrentUser().first()?.id
if (userId == null) {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = "Пользователь не найден"
)
return@launch
}
val reward = Reward(
id = UUID.randomUUID().toString(),
title = title,
description = _uiState.value.newRewardDescription.takeIf { it.isNotBlank() },
imageUrl = null,
pointsRequired = _uiState.value.newRewardPoints,
isClaimed = false,
earnedAt = null,
userId = userId,
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now()
)
val result = createRewardUseCase(reward)
result.onSuccess {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = null,
showAddDialog = false,
newRewardTitle = "",
newRewardDescription = "",
newRewardPoints = 10
)
}.onFailure { exception ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = exception.message ?: "Ошибка создания награды"
)
}
}
}
fun showAddDialog() {
_uiState.value = _uiState.value.copy(showAddDialog = true)
}
fun hideAddDialog() {
_uiState.value = _uiState.value.copy(
showAddDialog = false,
newTaskTitle = "",
newTaskDescription = "",
newTaskDuration = "",
newRewardTitle = "",
newRewardDescription = "",
newRewardPoints = 10
)
}
fun setDialogMode(mode: DialogMode) {
_uiState.value = _uiState.value.copy(dialogMode = mode)
}
fun updateNewTaskTitle(title: String) {
_uiState.value = _uiState.value.copy(newTaskTitle = title)
}
fun updateNewTaskDescription(description: String) {
_uiState.value = _uiState.value.copy(newTaskDescription = description)
}
fun updateNewTaskDuration(duration: String) {
_uiState.value = _uiState.value.copy(newTaskDuration = duration)
}
fun updateNewRewardTitle(title: String) {
_uiState.value = _uiState.value.copy(newRewardTitle = title)
}
fun updateNewRewardDescription(description: String) {
_uiState.value = _uiState.value.copy(newRewardDescription = description)
}
fun updateNewRewardPoints(points: Int) {
_uiState.value = _uiState.value.copy(newRewardPoints = points)
}
fun deleteTask(taskId: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val result = deleteTaskUseCase(taskId)
result.onSuccess {
_uiState.value = _uiState.value.copy(isLoading = false)
// Список задач обновится автоматически через Flow
}.onFailure { exception ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = exception.message ?: "Ошибка удаления задачи"
)
}
}
}
fun completeTask(taskId: String, completed: Boolean) {
viewModelScope.launch {
// Останавливаем таймер если задача завершена
if (completed) {
stopTimer(taskId)
}
// Оптимистичное обновление UI
val currentTasks = _uiState.value.tasks.toMutableList()
val taskIndex = currentTasks.indexOfFirst { it.id == taskId }
if (taskIndex >= 0) {
val oldTask = currentTasks[taskIndex]
currentTasks[taskIndex] = oldTask.copy(completed = completed)
_uiState.value = _uiState.value.copy(tasks = currentTasks.sortedBy { it.order })
}
val result = completeTaskUseCase(taskId, completed)
result.onSuccess {
// Проверяем, все ли задачи выполнены, и выдаем награду если нужно
if (completed && currentScheduleId != null) {
checkAndAwardReward(currentScheduleId!!)
}
}.onFailure { exception ->
// Откатываем изменение в случае ошибки
if (taskIndex >= 0) {
val oldTask = currentTasks[taskIndex]
currentTasks[taskIndex] = oldTask.copy(completed = !completed)
_uiState.value = _uiState.value.copy(
tasks = currentTasks.sortedBy { it.order },
errorMessage = exception.message ?: "Ошибка обновления задачи"
)
}
}
}
}
private suspend fun checkAndAwardReward(scheduleId: String) {
try {
val user = authRepository.getCurrentUser().first()
val userId = user?.id ?: return
// Получаем расписание
val schedules = getSchedulesUseCase(userId).first()
val schedule = schedules.find { it.id == scheduleId } ?: return
// Проверяем, есть ли награда в расписании
val rewardId = schedule.rewardId ?: return
// Проверяем, все ли задачи выполнены
val allTasksCompleted = schedule.tasks.isNotEmpty() &&
schedule.tasks.all { it.completed }
if (allTasksCompleted) {
// Выдаем награду
claimRewardUseCase(rewardId)
}
} catch (e: Exception) {
// Игнорируем ошибки при проверке награды
}
}
fun startTimer(taskId: String, durationMinutes: Int) {
// Останавливаем предыдущий таймер для этой задачи если есть
stopTimer(taskId)
val newState = TimerState(
isRunning = true,
durationMinutes = durationMinutes,
remainingSeconds = durationMinutes * 60
)
val currentStates = _timerStates.value.toMutableMap()
currentStates[taskId] = newState
_timerStates.value = currentStates
timerJobs[taskId] = viewModelScope.launch {
while (true) {
delay(1000)
val currentStates = _timerStates.value
val state = currentStates[taskId] ?: break
if (!state.isRunning) continue
if (state.remainingSeconds <= 0) {
// Таймер истек, завершаем задачу
completeTask(taskId, true)
val updatedStates = currentStates.toMutableMap()
updatedStates.remove(taskId)
_timerStates.value = updatedStates
timerJobs.remove(taskId)
timerStateManager.removeTimerState(taskId)
break
}
val newRemaining = state.remainingSeconds - 1
val updatedState = state.copy(remainingSeconds = newRemaining)
val updatedStates = currentStates.toMutableMap()
updatedStates[taskId] = updatedState
_timerStates.value = updatedStates
// Сохраняем состояние таймера
timerStateManager.saveTimerState(
taskId,
SavedTimerState(
isRunning = updatedState.isRunning,
durationMinutes = updatedState.durationMinutes,
remainingSeconds = updatedState.remainingSeconds
)
)
}
}
}
fun pauseTimer(taskId: String) {
val currentStates = _timerStates.value.toMutableMap()
currentStates[taskId]?.let { state ->
val pausedState = state.copy(isRunning = false)
currentStates[taskId] = pausedState
_timerStates.value = currentStates
// Сохраняем состояние таймера
viewModelScope.launch {
timerStateManager.saveTimerState(
taskId,
SavedTimerState(
isRunning = pausedState.isRunning,
durationMinutes = pausedState.durationMinutes,
remainingSeconds = pausedState.remainingSeconds
)
)
}
}
}
fun resumeTimer(taskId: String) {
val currentStates = _timerStates.value.toMutableMap()
currentStates[taskId]?.let { state ->
if (state.remainingSeconds > 0) {
currentStates[taskId] = state.copy(isRunning = true)
_timerStates.value = currentStates
// Перезапускаем таймер
timerJobs[taskId]?.cancel()
timerJobs[taskId] = viewModelScope.launch {
while (true) {
delay(1000)
val currentStates = _timerStates.value
val currentState = currentStates[taskId] ?: break
if (!currentState.isRunning) continue
if (currentState.remainingSeconds <= 0) {
completeTask(taskId, true)
val updatedStates = currentStates.toMutableMap()
updatedStates.remove(taskId)
_timerStates.value = updatedStates
timerJobs.remove(taskId)
timerStateManager.removeTimerState(taskId)
break
}
val newRemaining = currentState.remainingSeconds - 1
val updatedState = currentState.copy(remainingSeconds = newRemaining)
val updatedStates = currentStates.toMutableMap()
updatedStates[taskId] = updatedState
_timerStates.value = updatedStates
// Сохраняем состояние таймера
timerStateManager.saveTimerState(
taskId,
SavedTimerState(
isRunning = updatedState.isRunning,
durationMinutes = updatedState.durationMinutes,
remainingSeconds = updatedState.remainingSeconds
)
)
}
}
}
}
}
fun stopTimer(taskId: String) {
timerJobs[taskId]?.cancel()
timerJobs.remove(taskId)
val currentStates = _timerStates.value.toMutableMap()
currentStates.remove(taskId)
_timerStates.value = currentStates
// Удаляем сохраненное состояние
viewModelScope.launch {
timerStateManager.removeTimerState(taskId)
}
}
fun getTimerState(taskId: String): TimerState? {
return _timerStates.value[taskId]
}
fun clearError() {
_uiState.value = _uiState.value.copy(errorMessage = null)
}
}
enum class DialogMode {
TASK, REWARD
} }
data class TaskUiState( data class TaskUiState(
val tasks: List<com.novayaplaneta.domain.model.Task> = emptyList(), val tasks: List<Task> = emptyList(),
val isLoading: Boolean = false val selectedTask: Task? = null,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val showAddDialog: Boolean = false,
val dialogMode: DialogMode = DialogMode.TASK,
val newTaskTitle: String = "",
val newTaskDescription: String = "",
val newTaskDuration: String = "",
val newRewardTitle: String = "",
val newRewardDescription: String = "",
val newRewardPoints: Int = 10
)
data class TimerState(
val isRunning: Boolean = false,
val durationMinutes: Int = 0,
val remainingSeconds: Int = 0
) )

View File

@@ -14,22 +14,22 @@ val SurfaceDark = Color(0xFF1A1A1A)
val OnBackgroundDark = Color(0xFFFFFFFF) val OnBackgroundDark = Color(0xFFFFFFFF)
val OnSurfaceDark = Color(0xFFFFFFFF) val OnSurfaceDark = Color(0xFFFFFFFF)
// Accent Colors - мягкие пастельные тона для комфорта детей с РАС // Accent Colors - очень мягкие пастельные тона для максимального комфорта детей с РАС
val AccentGreen = Color(0xFF7FD97F) // Мягкий пастельный зеленый val AccentGreen = Color(0xFFA8D5BA) // Очень мягкий мятно-зеленый (низкая насыщенность)
val AccentOrange = Color(0xFFFFB88C) // Мягкий пастельный персиковый val AccentOrange = Color(0xFFFFD4B3) // Очень мягкий персиковый (теплый, успокаивающий)
val AccentGold = Color(0xFFFFE68C) // Мягкий пастельный желтый val AccentGold = Color(0xFFFFEEC7) // Очень мягкий бежево-желтый (кремовый оттенок)
// Status Colors - приглушенные для комфорта // Status Colors - очень приглушенные для комфорта
val SuccessColor = Color(0xFF7FD97F) // Мягкий зеленый val SuccessColor = Color(0xFFA8D5BA) // Очень мягкий зеленый
val WarningColor = Color(0xFFFFB88C) // Мягкий персиковый val WarningColor = Color(0xFFFFD4B3) // Очень мягкий персиковый
val ErrorColor = Color(0xFFFF9E9E) // Мягкий розовый вместо яркого красного val ErrorColor = Color(0xFFFFC8C8) // Очень мягкий розовый (вместо яркого красного)
// Цвета для экрана авторизации (благоприятные для РАС из PDF) // Цвета для экрана авторизации (оптимизированные для РАС - очень мягкие тона)
val LoginBackgroundTurquoise = Color(0xFFDAE7E9) // Мягкий голубой фон val LoginBackgroundTurquoise = Color(0xFFE8F4F5) // Очень мягкий мятный фон (еще мягче)
val LoginCardLightBlue = Color(0xFFBCDAEC) // Спокойный светло-голубой val LoginCardLightBlue = Color(0xFFD4E8F0) // Очень мягкий голубой для карточек
val LoginInputLightBlue = Color(0xFFBCDAEC) // Для полей ввода val LoginInputLightBlue = Color(0xFFD4E8F0) // Очень мягкий голубой для полей ввода
val LoginButtonBlue = Color(0xFFBCDAEC) // Для кнопки val LoginButtonBlue = Color(0xFFD4E8F0) // Очень мягкий голубой для кнопок
val LoginGreenAccent = Color(0xFF80EF80) // Пастельно-зелёный акцент val LoginGreenAccent = Color(0xFF95D5A3) // Очень мягкий зеленый акцент (приглушенный)
val LoginGreenSoft = Color(0xFFC5E6C5) // Мягкий пастельно-зелёный val LoginGreenSoft = Color(0xFFD1E8D7) // Очень мягкий светло-зеленый
val LoginGreenDark = Color(0xFF80EF80) // Пастельно-зелёный темнее val LoginGreenDark = Color(0xFF95D5A3) // Очень мягкий зеленый для темных элементов

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" lottie = "6.1.0"
coroutines = "1.9.0" coroutines = "1.9.0"
ksp = "2.1.0-1.0.28" ksp = "2.1.0-1.0.28"
datastore = "1.1.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -71,6 +72,9 @@ lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", versio
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
# DataStore
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }