Добавил логику работы с ИИ-агентом и работу с расписанием

This commit is contained in:
2025-12-25 22:40:40 +03:00
parent d8a0237e43
commit 4ff516d06a
41 changed files with 1778 additions and 259 deletions

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

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

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

@@ -2,13 +2,22 @@ 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.CompleteTaskResponse
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.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 +30,54 @@ 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>
} }

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,
val model: 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,11 @@
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
)

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
)

View File

@@ -0,0 +1,18 @@
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()
)
// 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,50 @@
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
)
}
// 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 ?: ""
)
}

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,24 +3,28 @@ 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 {
// Save user message // Save user message
val userMessage = ChatMessage( val userMessage = ChatMessage(
@@ -32,22 +36,33 @@ class AIRepositoryImpl @Inject constructor(
) )
chatMessageDao.insertMessage(userMessage.toEntity()) chatMessageDao.insertMessage(userMessage.toEntity())
// Тестовый ответ для демонстрации // Send request to API
kotlinx.coroutines.delay(1500) // Небольшая задержка для анимации val request = ChatRequest(message = message, conversation_id = conversationId)
val response = aiApi.chat(request)
val aiResponse = "!!!!" if (response.isSuccessful && response.body() != null) {
val chatResponse = response.body()!!
// Save AI response // Save AI response
val aiMessage = ChatMessage( val aiMessage = ChatMessage(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
message = aiResponse, message = chatResponse.response,
isFromAI = true, isFromAI = true,
timestamp = LocalDateTime.now(), timestamp = LocalDateTime.now(),
userId = userId userId = userId
) )
chatMessageDao.insertMessage(aiMessage.toEntity()) chatMessageDao.insertMessage(aiMessage.toEntity())
Result.success(aiResponse) Result.success(
ChatResponse(
response = chatResponse.response,
conversationId = chatResponse.conversation_id
)
)
} else {
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)
} }
@@ -59,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

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

View File

@@ -1,6 +1,7 @@
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.AuthApi
import com.novayaplaneta.data.remote.AuthInterceptor import com.novayaplaneta.data.remote.AuthInterceptor
import com.novayaplaneta.data.remote.BackendApi import com.novayaplaneta.data.remote.BackendApi
@@ -72,5 +73,13 @@ object NetworkModule {
): BackendApi { ): BackendApi {
return retrofit.create(BackendApi::class.java) return retrofit.create(BackendApi::class.java)
} }
@Provides
@Singleton
fun provideAiApi(
retrofit: Retrofit
): AiApi {
return retrofit.create(AiApi::class.java)
}
} }

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

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

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

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

@@ -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> {
return try {
repository.createSchedule(schedule) 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,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,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

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

@@ -7,6 +7,9 @@ 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.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.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -50,10 +53,17 @@ fun AIScreen(
) { ) {
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() }
// Загружаем историю чата при первом открытии экрана // Показываем ошибки через Snackbar
LaunchedEffect(Unit) { LaunchedEffect(uiState.error) {
viewModel.loadChatHistory("default") uiState.error?.let { error ->
snackbarHostState.showSnackbar(
message = error,
duration = SnackbarDuration.Long
)
viewModel.clearError()
}
} }
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
@@ -235,7 +245,7 @@ fun AIScreen(
Button( Button(
onClick = { onClick = {
if (messageText.isNotBlank()) { if (messageText.isNotBlank()) {
viewModel.sendMessage("default", messageText) viewModel.sendMessage(messageText)
messageText = "" messageText = ""
} }
}, },
@@ -259,6 +269,12 @@ fun AIScreen(
} }
} }
} }
// Snackbar для отображения ошибок
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter)
)
} }
} }

View File

@@ -7,42 +7,82 @@ 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
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
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) 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

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

@@ -12,6 +12,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
@@ -171,28 +173,96 @@ fun ScheduleScreen(
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
// Основной контент: только сегодняшняя дата (опущена ниже) // Основной контент: список расписаний
DateSection( if (uiState.isLoading) {
date = dateOnly, Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = accentGreen)
}
} else if (uiState.errorMessage != null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Ошибка",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.Red
)
Text(
text = uiState.errorMessage ?: "Неизвестная ошибка",
fontSize = 18.sp,
color = Color.Black
)
Button(
onClick = { viewModel.loadSchedules() },
colors = ButtonDefaults.buttonColors(containerColor = accentGreen)
) {
Text("Повторить")
}
}
}
} else {
SchedulesListSection(
schedules = uiState.schedules,
dateOnly = dateOnly,
dayOfWeek = dayOfWeek, dayOfWeek = dayOfWeek,
dateCardColor = dateCardColor, dateCardColor = dateCardColor,
accentGreen = accentGreen, accentGreen = accentGreen,
screenHeightDp = screenHeightDp, screenHeightDp = screenHeightDp,
tasks = uiState.tasks, onAddClick = { viewModel.showAddDialog() },
onAddClick = { viewModel.showAddDialog() } onGenerateClick = { viewModel.showGenerateDialog() },
onScheduleClick = { scheduleId ->
// TODO: Navigate to schedule details
},
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,
onTitleChange = { viewModel.updateNewScheduleTitle(it) },
onDescriptionChange = { viewModel.updateNewScheduleDescription(it) },
onDateChange = { viewModel.updateNewScheduleDate(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 +306,22 @@ fun NavItem(
} }
@Composable @Composable
fun DateSection( fun SchedulesListSection(
date: String, schedules: List<com.novayaplaneta.domain.model.Schedule>,
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 +340,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 +354,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,65 +383,106 @@ fun DateSection(
} }
} }
// Задачи в ряд // Список расписаний
if (tasks.isNotEmpty()) { if (schedules.isEmpty()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
items(tasks) { task ->
TaskCard(
taskType = task,
screenHeightDp = screenHeightDp
)
}
}
}
}
}
@Composable
fun TaskCard(
taskType: TaskType,
screenHeightDp: Int
) {
val cardWidth = 200.dp
val cardHeight = 180.dp
Box(
modifier = Modifier
.width(cardWidth)
.height(cardHeight)
.clip(RoundedCornerShape(16.dp))
.background(color = Color.LightGray)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Верхняя часть (для изображения)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(0.6f) .padding(vertical = 48.dp),
.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 contentAlignment = Alignment.Center
) { ) {
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp
Text( Text(
text = taskType.title, text = "Нет расписаний",
fontSize = textSize, fontSize = 20.sp,
color = Color.Gray
)
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
items(schedules) { schedule ->
ScheduleCard(
schedule = schedule,
screenHeightDp = screenHeightDp,
accentGreen = accentGreen,
dateCardColor = dateCardColor,
onClick = { onScheduleClick(schedule.id) },
onDeleteClick = { onDeleteClick(schedule.id) }
)
}
}
}
}
}
@Composable
fun ScheduleCard(
schedule: com.novayaplaneta.domain.model.Schedule,
screenHeightDp: Int,
accentGreen: Color,
dateCardColor: Color,
onClick: () -> Unit,
onDeleteClick: () -> Unit
) {
val dateFormatter = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy")
val scheduleDate = schedule.date.toLocalDate().format(dateFormatter)
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = dateCardColor)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = schedule.title,
fontSize = 20.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.Black, color = Color.Black
textAlign = TextAlign.Center )
Text(
text = scheduleDate,
fontSize = 16.sp,
color = accentGreen,
fontWeight = FontWeight.Medium
)
}
IconButton(onClick = onDeleteClick) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Удалить",
tint = Color.Red
)
}
}
if (!schedule.description.isNullOrBlank()) {
Text(
text = schedule.description ?: "",
fontSize = 16.sp,
color = Color.Black
)
}
if (schedule.tasks.isNotEmpty()) {
Text(
text = "Задач: ${schedule.tasks.size}",
fontSize = 14.sp,
color = Color.Gray
) )
} }
} }
@@ -362,18 +490,23 @@ fun TaskCard(
} }
@Composable @Composable
fun AddTaskDialog( fun CreateScheduleDialog(
selectedTaskType: TaskType?, title: String,
onTaskTypeSelected: (TaskType) -> Unit, description: String,
onSelect: () -> Unit, date: java.time.LocalDate?,
onTitleChange: (String) -> Unit,
onDescriptionChange: (String) -> Unit,
onDateChange: (java.time.LocalDate) -> 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,47 +518,42 @@ fun AddTaskDialog(
.fillMaxWidth() .fillMaxWidth()
.padding(24.dp), .padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Заголовок // Заголовок
val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp
Text( Text(
text = "Выберите задачу", text = "Создать расписание",
fontSize = titleSize, fontSize = titleSize,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.Black color = Color.Black
) )
// Опции выбора // Поле названия
Column( OutlinedTextField(
verticalArrangement = Arrangement.spacedBy(16.dp), value = title,
modifier = Modifier.fillMaxWidth() onValueChange = onTitleChange,
) { label = { Text("Название") },
// Подарок modifier = Modifier.fillMaxWidth(),
TaskOption( enabled = !isLoading
taskType = TaskType.Gift,
isSelected = selectedTaskType == TaskType.Gift,
onClick = { onTaskTypeSelected(TaskType.Gift) },
accentGreen = accentGreen,
screenHeightDp = screenHeightDp
) )
// Кушать ложкой // Поле описания
TaskOption( OutlinedTextField(
taskType = TaskType.EatWithSpoon, value = description,
isSelected = selectedTaskType == TaskType.EatWithSpoon, onValueChange = onDescriptionChange,
onClick = { onTaskTypeSelected(TaskType.EatWithSpoon) }, label = { Text("Описание") },
accentGreen = accentGreen, modifier = Modifier.fillMaxWidth(),
screenHeightDp = screenHeightDp maxLines = 3,
enabled = !isLoading
) )
}
// Кнопки внизу // Кнопки внизу
Row( Row(
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 +563,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,9 +589,15 @@ fun AddTaskDialog(
disabledContentColor = Color.Gray 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 val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
Text( Text(
text = "Выбрать", text = "Создать",
fontSize = buttonTextSize, fontSize = buttonTextSize,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -471,36 +606,7 @@ fun AddTaskDialog(
} }
} }
} }
}
@Composable
fun TaskOption(
taskType: TaskType,
isSelected: Boolean,
onClick: () -> Unit,
accentGreen: Color,
screenHeightDp: Int
) {
val borderWidth = if (isSelected) 4.dp else 2.dp
val borderColor = if (isSelected) accentGreen else Color.Gray
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.clip(RoundedCornerShape(16.dp))
.border(borderWidth, borderColor, RoundedCornerShape(16.dp))
.background(color = if (isSelected) accentGreen.copy(alpha = 0.1f) else Color.Transparent)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
val textSize = (screenHeightDp * 0.025f).toInt().coerceIn(20, 28).sp
Text(
text = taskType.title,
fontSize = textSize,
fontWeight = FontWeight.Bold,
color = if (isSelected) accentGreen else Color.Black
)
} }
} }

View File

@@ -2,12 +2,20 @@ 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.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.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 +25,247 @@ 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 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()
}
fun loadSchedules(scheduleDate: String? = null) {
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true) _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
getSchedulesUseCase(userId).collect { schedules ->
try {
// Получаем текущего пользователя
val user = authRepository.getCurrentUser().first()
val userId = user?.id ?: ""
// Загружаем расписания с сервера
val loadResult = getSchedulesUseCase.loadSchedules(scheduleDate)
if (loadResult.isFailure) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
schedules = schedules, isLoading = false,
isLoading = false errorMessage = loadResult.exceptionOrNull()?.message ?: "Ошибка загрузки расписаний"
)
return@launch
}
// Получаем актуальный список расписаний из Flow
val schedules = getSchedulesUseCase(userId).first()
// Фильтруем расписания по дате, если указана
val filteredSchedules = if (scheduleDate != null) {
schedules.filter { schedule ->
val scheduleDateStr = schedule.date.toLocalDate().format(DateTimeFormatter.ISO_DATE)
scheduleDateStr == scheduleDate
}
} else {
schedules
}
_uiState.value = _uiState.value.copy(
schedules = filteredSchedules,
isLoading = false,
errorMessage = null
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = e.message ?: "Ошибка загрузки расписаний"
) )
} }
} }
} }
fun showAddDialog() { fun showAddDialog() {
_uiState.value = _uiState.value.copy(showAddDialog = true, selectedTaskType = null) _uiState.value = _uiState.value.copy(
showAddDialog = true,
newScheduleTitle = "",
newScheduleDescription = "",
newScheduleDate = LocalDate.now()
)
} }
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
) )
} }
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
)
}
}
fun createSchedule(title: String, description: String, date: LocalDate) {
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
)
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 showGenerateDialog: Boolean = false,
val generateChildAge: Int = 5,
val generatePreferences: List<String> = emptyList(),
val generateDate: String = "",
val generateDescription: String = "",
val generatedScheduleId: String? = null
) )

View File

@@ -3,26 +3,48 @@ package com.novayaplaneta.ui.screens.task
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.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
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.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
@OptIn(ExperimentalMaterial3Api::class) @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()
// Загружаем задачи при открытии экрана
LaunchedEffect(scheduleId) {
scheduleId?.let {
viewModel.loadTasks(it)
}
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Задания") } title = { Text("Задания") },
actions = {
if (scheduleId != null) {
IconButton(onClick = { /* TODO: Show create task dialog */ }) {
Icon(Icons.Default.Add, contentDescription = "Добавить задачу")
}
}
}
) )
} }
) { paddingValues -> ) { paddingValues ->
@@ -32,12 +54,38 @@ fun TaskScreen(
.padding(paddingValues) .padding(paddingValues)
.padding(16.dp) .padding(16.dp)
) { ) {
if (uiState.isLoading) { // Отображение ошибок
CircularProgressIndicator( uiState.errorMessage?.let { error ->
Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentWidth() .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 && uiState.tasks.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (uiState.tasks.isEmpty() && !uiState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Нет задач")
}
} else { } else {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
@@ -50,7 +98,8 @@ fun TaskScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
@@ -63,11 +112,28 @@ fun TaskScreen(
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )
} }
task.duration?.let {
Text(
text = "Длительность: $it мин",
style = MaterialTheme.typography.bodySmall
)
} }
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox( Checkbox(
checked = task.completed, checked = task.completed,
onCheckedChange = { viewModel.completeTask(task.id) } onCheckedChange = { completed ->
viewModel.completeTask(task.id, completed)
}
) )
IconButton(onClick = { viewModel.deleteTask(task.id) }) {
Icon(
Icons.Default.Delete,
contentDescription = "Удалить",
tint = MaterialTheme.colorScheme.error
)
}
}
} }
} }
} }

View File

@@ -2,43 +2,158 @@ 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.domain.model.Task
import com.novayaplaneta.domain.usecase.CompleteTaskUseCase
import com.novayaplaneta.domain.usecase.CreateTaskUseCase
import com.novayaplaneta.domain.usecase.DeleteTaskUseCase
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.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 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 deleteTaskUseCase: DeleteTaskUseCase,
private val completeTaskUseCase: CompleteTaskUseCase
) : 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 var currentScheduleId: String? = null
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 loadResult = getTasksUseCase.loadTasks(scheduleId)
if (loadResult.isFailure) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
tasks = tasks, isLoading = false,
isLoading = false errorMessage = loadResult.exceptionOrNull()?.message ?: "Ошибка загрузки задач"
)
return@launch
}
// Подписываемся на Flow для обновлений
getTasksUseCase(scheduleId).collect { tasks ->
_uiState.value = _uiState.value.copy(
tasks = tasks.sortedBy { it.order },
isLoading = false,
errorMessage = null
)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = e.message ?: "Ошибка загрузки задач"
) )
} }
} }
} }
fun completeTask(taskId: String) { fun loadTaskById(taskId: String) {
viewModelScope.launch { 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(task: Task) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val result = createTaskUseCase(task)
result.onSuccess {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = null
)
// Список задач обновится автоматически через Flow
}.onFailure { exception ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = exception.message ?: "Ошибка создания задачи"
)
}
}
}
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 {
// Оптимистичное обновление 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.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 ?: "Ошибка обновления задачи"
)
}
}
}
}
fun clearError() {
_uiState.value = _uiState.value.copy(errorMessage = null)
}
} }
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
) )