From 4ff516d06a2d07f583b91017fd1c5b1d9839ce87 Mon Sep 17 00:00:00 2001 From: gleb Date: Thu, 25 Dec 2025 22:40:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D1=83=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D1=8B=20=D1=81=20=D0=98=D0=98-=D0=B0=D0=B3=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=BC=20=D0=B8=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D1=83=20=D1=81=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/local/entity/TaskEntity.kt | 6 +- .../data/local/mapper/TaskMapper.kt | 16 +- .../com/novayaplaneta/data/remote/AiApi.kt | 22 + .../novayaplaneta/data/remote/BackendApi.kt | 58 +++ .../data/remote/dto/ChatRequest.kt | 3 +- .../data/remote/dto/ChatResponse.kt | 5 +- .../data/remote/dto/CompleteTaskResponse.kt | 19 + .../data/remote/dto/CreateScheduleRequest.kt | 11 + .../data/remote/dto/CreateTaskRequest.kt | 15 + .../remote/dto/GenerateScheduleRequest.kt | 12 + .../remote/dto/GenerateScheduleResponse.kt | 13 + .../data/remote/dto/ScheduleDto.kt | 18 + .../novayaplaneta/data/remote/dto/TaskDto.kt | 19 + .../data/remote/mapper/ScheduleMapper.kt | 50 +++ .../data/remote/mapper/TaskMapper.kt | 83 ++++ .../data/repository/AIRepositoryImpl.kt | 131 ++++-- .../data/repository/ScheduleRepositoryImpl.kt | 107 ++++- .../data/repository/TaskRepositoryImpl.kt | 166 +++++++- .../com/novayaplaneta/di/NetworkModule.kt | 9 + .../com/novayaplaneta/domain/model/Task.kt | 6 +- .../domain/repository/AIRepository.kt | 20 +- .../domain/repository/ScheduleRepository.kt | 2 +- .../domain/repository/TaskRepository.kt | 9 +- .../domain/usecase/CompleteTaskUseCase.kt | 6 +- .../domain/usecase/CreateScheduleUseCase.kt | 13 +- .../domain/usecase/CreateTaskUseCase.kt | 14 + .../domain/usecase/DeleteScheduleUseCase.kt | 18 + .../domain/usecase/DeleteTaskUseCase.kt | 13 + .../domain/usecase/GenerateScheduleUseCase.kt | 19 + .../domain/usecase/GetScheduleByIdUseCase.kt | 23 ++ .../domain/usecase/GetSchedulesUseCase.kt | 9 + .../domain/usecase/GetTaskByIdUseCase.kt | 14 + .../domain/usecase/GetTasksUseCase.kt | 23 ++ .../domain/usecase/SendAIMessageUseCase.kt | 5 +- .../novayaplaneta/ui/screens/ai/AIScreen.kt | 24 +- .../ui/screens/ai/AIViewModel.kt | 56 ++- .../schedule/GenerateScheduleDialog.kt | 154 +++++++ .../ui/screens/schedule/ScheduleScreen.kt | 378 +++++++++++------- .../ui/screens/schedule/ScheduleViewModel.kt | 247 +++++++++++- .../ui/screens/task/TaskScreen.kt | 86 +++- .../ui/screens/task/TaskViewModel.kt | 135 ++++++- 41 files changed, 1778 insertions(+), 259 deletions(-) create mode 100644 app/src/main/java/com/novayaplaneta/data/remote/AiApi.kt create mode 100644 app/src/main/java/com/novayaplaneta/data/remote/dto/CompleteTaskResponse.kt create mode 100644 app/src/main/java/com/novayaplaneta/data/remote/dto/CreateScheduleRequest.kt create mode 100644 app/src/main/java/com/novayaplaneta/data/remote/dto/CreateTaskRequest.kt create mode 100644 app/src/main/java/com/novayaplaneta/data/remote/dto/GenerateScheduleRequest.kt create mode 100644 app/src/main/java/com/novayaplaneta/data/remote/dto/GenerateScheduleResponse.kt create mode 100644 app/src/main/java/com/novayaplaneta/data/remote/dto/ScheduleDto.kt create mode 100644 app/src/main/java/com/novayaplaneta/data/remote/dto/TaskDto.kt create mode 100644 app/src/main/java/com/novayaplaneta/data/remote/mapper/ScheduleMapper.kt create mode 100644 app/src/main/java/com/novayaplaneta/data/remote/mapper/TaskMapper.kt create mode 100644 app/src/main/java/com/novayaplaneta/domain/usecase/CreateTaskUseCase.kt create mode 100644 app/src/main/java/com/novayaplaneta/domain/usecase/DeleteScheduleUseCase.kt create mode 100644 app/src/main/java/com/novayaplaneta/domain/usecase/DeleteTaskUseCase.kt create mode 100644 app/src/main/java/com/novayaplaneta/domain/usecase/GenerateScheduleUseCase.kt create mode 100644 app/src/main/java/com/novayaplaneta/domain/usecase/GetScheduleByIdUseCase.kt create mode 100644 app/src/main/java/com/novayaplaneta/domain/usecase/GetTaskByIdUseCase.kt create mode 100644 app/src/main/java/com/novayaplaneta/domain/usecase/GetTasksUseCase.kt create mode 100644 app/src/main/java/com/novayaplaneta/ui/screens/schedule/GenerateScheduleDialog.kt diff --git a/app/src/main/java/com/novayaplaneta/data/local/entity/TaskEntity.kt b/app/src/main/java/com/novayaplaneta/data/local/entity/TaskEntity.kt index d698894..8af7e1b 100644 --- a/app/src/main/java/com/novayaplaneta/data/local/entity/TaskEntity.kt +++ b/app/src/main/java/com/novayaplaneta/data/local/entity/TaskEntity.kt @@ -13,6 +13,10 @@ data class TaskEntity( val completed: Boolean = false, val scheduledTime: Long?, // timestamp val duration: Int? = null, - val scheduleId: String + val scheduleId: String, + val order: Int? = null, + val category: String? = null, + val createdAt: Long? = null, // timestamp + val updatedAt: Long? = null // timestamp ) diff --git a/app/src/main/java/com/novayaplaneta/data/local/mapper/TaskMapper.kt b/app/src/main/java/com/novayaplaneta/data/local/mapper/TaskMapper.kt index 2e1cb4a..6cd2d1b 100644 --- a/app/src/main/java/com/novayaplaneta/data/local/mapper/TaskMapper.kt +++ b/app/src/main/java/com/novayaplaneta/data/local/mapper/TaskMapper.kt @@ -17,7 +17,15 @@ fun TaskEntity.toDomain(): Task { LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) }, duration = duration, - scheduleId = scheduleId + scheduleId = scheduleId, + order = order ?: 0, + category = category, + createdAt = createdAt?.let { + LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) + }, + updatedAt = updatedAt?.let { + LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) + } ) } @@ -30,7 +38,11 @@ fun Task.toEntity(): TaskEntity { completed = completed, scheduledTime = scheduledTime?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(), duration = duration, - scheduleId = scheduleId + scheduleId = scheduleId, + order = order, + category = category, + createdAt = createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(), + updatedAt = updatedAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ) } diff --git a/app/src/main/java/com/novayaplaneta/data/remote/AiApi.kt b/app/src/main/java/com/novayaplaneta/data/remote/AiApi.kt new file mode 100644 index 0000000..97c0b44 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/data/remote/AiApi.kt @@ -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 + + @POST("api/v1/ai/schedule/generate") + suspend fun generateSchedule( + @Body request: GenerateScheduleRequest + ): Response +} + diff --git a/app/src/main/java/com/novayaplaneta/data/remote/BackendApi.kt b/app/src/main/java/com/novayaplaneta/data/remote/BackendApi.kt index ca61f58..620654b 100644 --- a/app/src/main/java/com/novayaplaneta/data/remote/BackendApi.kt +++ b/app/src/main/java/com/novayaplaneta/data/remote/BackendApi.kt @@ -2,13 +2,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.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.LoginResponse +import com.novayaplaneta.data.remote.dto.ScheduleDto +import com.novayaplaneta.data.remote.dto.TaskDto import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.Header +import retrofit2.http.PATCH import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query interface BackendApi { @POST("api/v1/auth/login") @@ -21,5 +30,54 @@ interface BackendApi { @Header("Authorization") token: String, @Body request: ChatRequest ): Response + + @GET("api/v1/schedules") + suspend fun getSchedules( + @Query("skip") skip: Int = 0, + @Query("limit") limit: Int = 100, + @Query("schedule_date") scheduleDate: String? = null + ): Response> + + @POST("api/v1/schedules") + suspend fun createSchedule( + @Body request: CreateScheduleRequest + ): Response + + @GET("api/v1/schedules/{schedule_id}") + suspend fun getScheduleById( + @Path("schedule_id") scheduleId: String + ): Response + + @DELETE("api/v1/schedules/{schedule_id}") + suspend fun deleteSchedule( + @Path("schedule_id") scheduleId: String + ): Response + + // Tasks endpoints + @GET("api/v1/tasks/schedule/{schedule_id}") + suspend fun getTasksByScheduleId( + @Path("schedule_id") scheduleId: String + ): Response> + + @GET("api/v1/tasks/{task_id}") + suspend fun getTaskById( + @Path("task_id") taskId: String + ): Response + + @POST("api/v1/tasks") + suspend fun createTask( + @Body request: CreateTaskRequest + ): Response + + @DELETE("api/v1/tasks/{task_id}") + suspend fun deleteTask( + @Path("task_id") taskId: String + ): Response + + @PATCH("api/v1/tasks/{task_id}/complete") + suspend fun completeTask( + @Path("task_id") taskId: String, + @Query("completed") completed: Boolean + ): Response } diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/ChatRequest.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/ChatRequest.kt index e1d9a96..fe2ada9 100644 --- a/app/src/main/java/com/novayaplaneta/data/remote/dto/ChatRequest.kt +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/ChatRequest.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable @Serializable data class ChatRequest( - val message: String + val message: String, + val conversation_id: String? = null ) diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/ChatResponse.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/ChatResponse.kt index a1511ce..6aa5bc1 100644 --- a/app/src/main/java/com/novayaplaneta/data/remote/dto/ChatResponse.kt +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/ChatResponse.kt @@ -4,6 +4,9 @@ import kotlinx.serialization.Serializable @Serializable data class ChatResponse( - val message: String + val response: String, + val conversation_id: String, + val tokens_used: Int, + val model: String ) diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/CompleteTaskResponse.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/CompleteTaskResponse.kt new file mode 100644 index 0000000..c267469 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/CompleteTaskResponse.kt @@ -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 +) + diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/CreateScheduleRequest.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/CreateScheduleRequest.kt new file mode 100644 index 0000000..480e291 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/CreateScheduleRequest.kt @@ -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 +) + diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/CreateTaskRequest.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/CreateTaskRequest.kt new file mode 100644 index 0000000..b2f7f76 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/CreateTaskRequest.kt @@ -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 +) + diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/GenerateScheduleRequest.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/GenerateScheduleRequest.kt new file mode 100644 index 0000000..e48c193 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/GenerateScheduleRequest.kt @@ -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, + val date: String, + val description: String +) + diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/GenerateScheduleResponse.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/GenerateScheduleResponse.kt new file mode 100644 index 0000000..3540881 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/GenerateScheduleResponse.kt @@ -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, + val tokens_used: Int +) + diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/ScheduleDto.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/ScheduleDto.kt new file mode 100644 index 0000000..aa2b5a4 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/ScheduleDto.kt @@ -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 = emptyList() +) + +// TaskDto moved to separate file + diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/TaskDto.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/TaskDto.kt new file mode 100644 index 0000000..e27926a --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/TaskDto.kt @@ -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 +) + diff --git a/app/src/main/java/com/novayaplaneta/data/remote/mapper/ScheduleMapper.kt b/app/src/main/java/com/novayaplaneta/data/remote/mapper/ScheduleMapper.kt new file mode 100644 index 0000000..7a398a9 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/data/remote/mapper/ScheduleMapper.kt @@ -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 ?: "" + ) +} + diff --git a/app/src/main/java/com/novayaplaneta/data/remote/mapper/TaskMapper.kt b/app/src/main/java/com/novayaplaneta/data/remote/mapper/TaskMapper.kt new file mode 100644 index 0000000..e637499 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/data/remote/mapper/TaskMapper.kt @@ -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 + ) +} + diff --git a/app/src/main/java/com/novayaplaneta/data/repository/AIRepositoryImpl.kt b/app/src/main/java/com/novayaplaneta/data/repository/AIRepositoryImpl.kt index 792876b..73c9685 100644 --- a/app/src/main/java/com/novayaplaneta/data/repository/AIRepositoryImpl.kt +++ b/app/src/main/java/com/novayaplaneta/data/repository/AIRepositoryImpl.kt @@ -3,24 +3,28 @@ package com.novayaplaneta.data.repository import com.novayaplaneta.data.local.dao.ChatMessageDao import com.novayaplaneta.data.local.mapper.toDomain import com.novayaplaneta.data.local.mapper.toEntity -import com.novayaplaneta.data.remote.BackendApi +import com.novayaplaneta.data.remote.AiApi import com.novayaplaneta.data.remote.dto.ChatRequest +import com.novayaplaneta.data.remote.dto.GenerateScheduleRequest import com.novayaplaneta.domain.model.ChatMessage import com.novayaplaneta.domain.repository.AIRepository +import com.novayaplaneta.domain.repository.ChatResponse +import com.novayaplaneta.domain.repository.GenerateScheduleResult import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import java.time.LocalDateTime import java.util.UUID import javax.inject.Inject class AIRepositoryImpl @Inject constructor( private val chatMessageDao: ChatMessageDao, - private val api: BackendApi, - private val authRepository: com.novayaplaneta.domain.repository.AuthRepository + private val aiApi: AiApi ) : AIRepository { - override suspend fun sendMessage(userId: String, message: String): Result { + override suspend fun sendMessage(userId: String, message: String, conversationId: String?): Result { return try { // Save user message val userMessage = ChatMessage( @@ -32,22 +36,33 @@ class AIRepositoryImpl @Inject constructor( ) chatMessageDao.insertMessage(userMessage.toEntity()) - // Тестовый ответ для демонстрации - kotlinx.coroutines.delay(1500) // Небольшая задержка для анимации + // Send request to API + val request = ChatRequest(message = message, conversation_id = conversationId) + val response = aiApi.chat(request) - val aiResponse = "!!!!" - - // Save AI response - val aiMessage = ChatMessage( - id = UUID.randomUUID().toString(), - message = aiResponse, - isFromAI = true, - timestamp = LocalDateTime.now(), - userId = userId - ) - chatMessageDao.insertMessage(aiMessage.toEntity()) - - Result.success(aiResponse) + if (response.isSuccessful && response.body() != null) { + val chatResponse = response.body()!! + + // Save AI response + val aiMessage = ChatMessage( + id = UUID.randomUUID().toString(), + message = chatResponse.response, + isFromAI = true, + timestamp = LocalDateTime.now(), + userId = userId + ) + chatMessageDao.insertMessage(aiMessage.toEntity()) + + Result.success( + ChatResponse( + response = chatResponse.response, + conversationId = chatResponse.conversation_id + ) + ) + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + Result.failure(Exception("Failed to send message: $errorBody (code: ${response.code()})")) + } } catch (e: Exception) { Result.failure(e) } @@ -59,9 +74,79 @@ class AIRepositoryImpl @Inject constructor( } } - override suspend fun generateSchedule(userId: String, preferences: String): Result { - // TODO: Implement schedule generation - return Result.failure(Exception("Not implemented")) + override suspend fun generateSchedule( + childAge: Int, + preferences: List, + date: String, + description: String + ): Result { + 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 + 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 { + 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() + } + } } } diff --git a/app/src/main/java/com/novayaplaneta/data/repository/ScheduleRepositoryImpl.kt b/app/src/main/java/com/novayaplaneta/data/repository/ScheduleRepositoryImpl.kt index e9ad51a..6cc70ba 100644 --- a/app/src/main/java/com/novayaplaneta/data/repository/ScheduleRepositoryImpl.kt +++ b/app/src/main/java/com/novayaplaneta/data/repository/ScheduleRepositoryImpl.kt @@ -4,47 +4,126 @@ import com.novayaplaneta.data.local.dao.ScheduleDao import com.novayaplaneta.data.local.dao.TaskDao import com.novayaplaneta.data.local.mapper.toDomain import com.novayaplaneta.data.local.mapper.toEntity +import com.novayaplaneta.data.remote.BackendApi +import com.novayaplaneta.data.remote.mapper.toCreateRequest +import com.novayaplaneta.data.remote.mapper.toDomain import com.novayaplaneta.domain.model.Schedule import com.novayaplaneta.domain.repository.ScheduleRepository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import javax.inject.Inject class ScheduleRepositoryImpl @Inject constructor( private val scheduleDao: ScheduleDao, - private val taskDao: TaskDao + private val taskDao: TaskDao, + private val backendApi: BackendApi ) : ScheduleRepository { + private val _schedulesCache = MutableStateFlow>(emptyList()) + val schedulesCache: StateFlow> = _schedulesCache.asStateFlow() + override fun getSchedules(userId: String): Flow> { - return scheduleDao.getSchedulesByUserId(userId).map { schedules -> - schedules.map { scheduleEntity -> - // Note: In production, you'd need to fetch tasks for each schedule - scheduleEntity.toDomain(emptyList()) - } - } + return _schedulesCache.asStateFlow() } override suspend fun getScheduleById(id: String): Schedule? { - val scheduleEntity = scheduleDao.getScheduleById(id) ?: return null - val tasks = taskDao.getTasksByScheduleId(id) - // Simplified - would need proper Flow handling - return scheduleEntity.toDomain(emptyList()) + return try { + val response = backendApi.getScheduleById(id) + if (response.isSuccessful && response.body() != null) { + val schedule = response.body()!!.toDomain() + // Обновляем кэш, если расписание уже есть, или добавляем новое + val currentSchedules = _schedulesCache.value.toMutableList() + val index = currentSchedules.indexOfFirst { it.id == id } + if (index >= 0) { + currentSchedules[index] = schedule + } else { + currentSchedules.add(schedule) + } + _schedulesCache.value = currentSchedules + schedule + } else { + null + } + } catch (e: Exception) { + null + } } override suspend fun createSchedule(schedule: Schedule) { - scheduleDao.insertSchedule(schedule.toEntity()) + try { + val request = schedule.toCreateRequest() + val response = backendApi.createSchedule(request) + if (response.isSuccessful && response.body() != null) { + val createdSchedule = response.body()!!.toDomain() + // Обновляем кэш + val currentSchedules = _schedulesCache.value.toMutableList() + currentSchedules.add(createdSchedule) + _schedulesCache.value = currentSchedules + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + throw Exception("Failed to create schedule: $errorBody (code: ${response.code()})") + } + } catch (e: Exception) { + throw e + } } override suspend fun updateSchedule(schedule: Schedule) { + // TODO: Implement if API supports update scheduleDao.updateSchedule(schedule.toEntity()) } override suspend fun deleteSchedule(id: String) { - scheduleDao.deleteSchedule(id) + try { + val response = backendApi.deleteSchedule(id) + if (response.isSuccessful) { + // Удаляем из кэша + val currentSchedules = _schedulesCache.value.toMutableList() + currentSchedules.removeAll { it.id == id } + _schedulesCache.value = currentSchedules + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + throw Exception("Failed to delete schedule: $errorBody (code: ${response.code()})") + } + } catch (e: Exception) { + throw e + } } override suspend fun syncSchedules(userId: String) { - // TODO: Implement sync with backend + try { + val response = backendApi.getSchedules(skip = 0, limit = 100) + if (response.isSuccessful && response.body() != null) { + val schedules = response.body()!!.map { it.toDomain() } + _schedulesCache.value = schedules + } else { + throw Exception("Failed to sync schedules: ${response.errorBody()?.string()}") + } + } catch (e: Exception) { + throw e + } + } + + override suspend fun loadSchedules(scheduleDate: String?) { + try { + val response = backendApi.getSchedules( + skip = 0, + limit = 100, + scheduleDate = scheduleDate + ) + if (response.isSuccessful && response.body() != null) { + val schedules = response.body()!!.map { it.toDomain() } + _schedulesCache.value = schedules + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + throw Exception("Failed to load schedules: $errorBody (code: ${response.code()})") + } + } catch (e: Exception) { + throw e + } } } diff --git a/app/src/main/java/com/novayaplaneta/data/repository/TaskRepositoryImpl.kt b/app/src/main/java/com/novayaplaneta/data/repository/TaskRepositoryImpl.kt index 6c07f87..7b6119a 100644 --- a/app/src/main/java/com/novayaplaneta/data/repository/TaskRepositoryImpl.kt +++ b/app/src/main/java/com/novayaplaneta/data/repository/TaskRepositoryImpl.kt @@ -3,40 +3,182 @@ package com.novayaplaneta.data.repository import com.novayaplaneta.data.local.dao.TaskDao import com.novayaplaneta.data.local.mapper.toDomain import com.novayaplaneta.data.local.mapper.toEntity +import com.novayaplaneta.data.remote.BackendApi +import com.novayaplaneta.data.remote.dto.CreateTaskRequest +import com.novayaplaneta.data.remote.mapper.toCreateRequest +import com.novayaplaneta.data.remote.mapper.toDomain import com.novayaplaneta.domain.model.Task import com.novayaplaneta.domain.repository.TaskRepository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import javax.inject.Inject class TaskRepositoryImpl @Inject constructor( - private val taskDao: TaskDao + private val taskDao: TaskDao, + private val backendApi: BackendApi ) : TaskRepository { - override fun getTasks(scheduleId: String): Flow> { - return taskDao.getTasksByScheduleId(scheduleId).map { tasks -> - tasks.map { it.toDomain() } + // Кэш задач по scheduleId + private val _tasksCache = mutableMapOf>>() + + override suspend fun loadTasks(scheduleId: String): Result { + return try { + val response = backendApi.getTasksByScheduleId(scheduleId) + if (response.isSuccessful && response.body() != null) { + val tasks = response.body()!!.map { it.toDomain() }.sortedBy { it.order } + + // Обновляем кэш + val cache = _tasksCache.getOrPut(scheduleId) { MutableStateFlow(emptyList()) } + cache.value = tasks + + // Сохраняем в локальную БД + tasks.forEach { task -> + taskDao.insertTask(task.toEntity()) + } + + Result.success(Unit) + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + Result.failure(Exception("Failed to load tasks: $errorBody (code: ${response.code()})")) + } + } catch (e: Exception) { + Result.failure(e) } } - override suspend fun getTaskById(id: String): Task? { - return taskDao.getTaskById(id)?.toDomain() + override fun getTasks(scheduleId: String): Flow> { + val cache = _tasksCache.getOrPut(scheduleId) { MutableStateFlow(emptyList()) } + return cache.asStateFlow() } - override suspend fun createTask(task: Task) { - taskDao.insertTask(task.toEntity()) + override suspend fun getTaskById(id: String): Result { + 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 { + return try { + val request = task.toCreateRequest() + val response = backendApi.createTask(request) + if (response.isSuccessful && response.body() != null) { + val createdTask = response.body()!!.toDomain() + + // Обновляем кэш + val cache = _tasksCache[task.scheduleId] + if (cache != null) { + val currentTasks = cache.value.toMutableList() + currentTasks.add(createdTask) + cache.value = currentTasks.sortedBy { it.order } + } + + // Сохраняем в локальную БД + taskDao.insertTask(createdTask.toEntity()) + + Result.success(createdTask) + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + Result.failure(Exception("Failed to create task: $errorBody (code: ${response.code()})")) + } + } catch (e: Exception) { + Result.failure(e) + } } override suspend fun updateTask(task: Task) { taskDao.updateTask(task.toEntity()) } - override suspend fun deleteTask(id: String) { - taskDao.deleteTask(id) + override suspend fun deleteTask(id: String): Result { + return try { + // Сначала получаем задачу для определения scheduleId + val task = taskDao.getTaskById(id)?.toDomain() + val scheduleId = task?.scheduleId + + val response = backendApi.deleteTask(id) + if (response.isSuccessful) { + // Удаляем из кэша + if (scheduleId != null) { + val cache = _tasksCache[scheduleId] + if (cache != null) { + val currentTasks = cache.value.toMutableList() + currentTasks.removeAll { it.id == id } + cache.value = currentTasks + } + } + + // Удаляем из локальной БД + taskDao.deleteTask(id) + + Result.success(Unit) + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + Result.failure(Exception("Failed to delete task: $errorBody (code: ${response.code()})")) + } + } catch (e: Exception) { + Result.failure(e) + } } - override suspend fun completeTask(id: String) { - taskDao.completeTask(id) + override suspend fun completeTask(id: String, completed: Boolean): Result { + 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) + } } } diff --git a/app/src/main/java/com/novayaplaneta/di/NetworkModule.kt b/app/src/main/java/com/novayaplaneta/di/NetworkModule.kt index 00a1852..d411196 100644 --- a/app/src/main/java/com/novayaplaneta/di/NetworkModule.kt +++ b/app/src/main/java/com/novayaplaneta/di/NetworkModule.kt @@ -1,6 +1,7 @@ package com.novayaplaneta.di import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.novayaplaneta.data.remote.AiApi import com.novayaplaneta.data.remote.AuthApi import com.novayaplaneta.data.remote.AuthInterceptor import com.novayaplaneta.data.remote.BackendApi @@ -72,5 +73,13 @@ object NetworkModule { ): BackendApi { return retrofit.create(BackendApi::class.java) } + + @Provides + @Singleton + fun provideAiApi( + retrofit: Retrofit + ): AiApi { + return retrofit.create(AiApi::class.java) + } } diff --git a/app/src/main/java/com/novayaplaneta/domain/model/Task.kt b/app/src/main/java/com/novayaplaneta/domain/model/Task.kt index dc4e9d7..15e4208 100644 --- a/app/src/main/java/com/novayaplaneta/domain/model/Task.kt +++ b/app/src/main/java/com/novayaplaneta/domain/model/Task.kt @@ -10,6 +10,10 @@ data class Task( val completed: Boolean = false, val scheduledTime: LocalDateTime?, val duration: Int? = null, // in minutes - val scheduleId: String + val scheduleId: String, + val order: Int = 0, + val category: String? = null, + val createdAt: LocalDateTime? = null, + val updatedAt: LocalDateTime? = null ) diff --git a/app/src/main/java/com/novayaplaneta/domain/repository/AIRepository.kt b/app/src/main/java/com/novayaplaneta/domain/repository/AIRepository.kt index 447c479..12aae34 100644 --- a/app/src/main/java/com/novayaplaneta/domain/repository/AIRepository.kt +++ b/app/src/main/java/com/novayaplaneta/domain/repository/AIRepository.kt @@ -4,8 +4,24 @@ import com.novayaplaneta.domain.model.ChatMessage import kotlinx.coroutines.flow.Flow interface AIRepository { - suspend fun sendMessage(userId: String, message: String): Result + suspend fun sendMessage(userId: String, message: String, conversationId: String?): Result fun getChatHistory(userId: String): Flow> - suspend fun generateSchedule(userId: String, preferences: String): Result + suspend fun generateSchedule( + childAge: Int, + preferences: List, + date: String, + description: String + ): Result } +data class ChatResponse( + val response: String, + val conversationId: String +) + +data class GenerateScheduleResult( + val scheduleId: String, + val title: String, + val tasks: List> +) + diff --git a/app/src/main/java/com/novayaplaneta/domain/repository/ScheduleRepository.kt b/app/src/main/java/com/novayaplaneta/domain/repository/ScheduleRepository.kt index 61a47c2..ab81699 100644 --- a/app/src/main/java/com/novayaplaneta/domain/repository/ScheduleRepository.kt +++ b/app/src/main/java/com/novayaplaneta/domain/repository/ScheduleRepository.kt @@ -2,7 +2,6 @@ package com.novayaplaneta.domain.repository import com.novayaplaneta.domain.model.Schedule import kotlinx.coroutines.flow.Flow -import java.time.LocalDateTime interface ScheduleRepository { fun getSchedules(userId: String): Flow> @@ -11,5 +10,6 @@ interface ScheduleRepository { suspend fun updateSchedule(schedule: Schedule) suspend fun deleteSchedule(id: String) suspend fun syncSchedules(userId: String) + suspend fun loadSchedules(scheduleDate: String? = null) } diff --git a/app/src/main/java/com/novayaplaneta/domain/repository/TaskRepository.kt b/app/src/main/java/com/novayaplaneta/domain/repository/TaskRepository.kt index 8d5cf46..2d9134c 100644 --- a/app/src/main/java/com/novayaplaneta/domain/repository/TaskRepository.kt +++ b/app/src/main/java/com/novayaplaneta/domain/repository/TaskRepository.kt @@ -4,11 +4,12 @@ import com.novayaplaneta.domain.model.Task import kotlinx.coroutines.flow.Flow interface TaskRepository { + suspend fun loadTasks(scheduleId: String): Result fun getTasks(scheduleId: String): Flow> - suspend fun getTaskById(id: String): Task? - suspend fun createTask(task: Task) + suspend fun getTaskById(id: String): Result + suspend fun createTask(task: Task): Result suspend fun updateTask(task: Task) - suspend fun deleteTask(id: String) - suspend fun completeTask(id: String) + suspend fun deleteTask(id: String): Result + suspend fun completeTask(id: String, completed: Boolean): Result } diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/CompleteTaskUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/CompleteTaskUseCase.kt index 6965364..7a6e495 100644 --- a/app/src/main/java/com/novayaplaneta/domain/usecase/CompleteTaskUseCase.kt +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/CompleteTaskUseCase.kt @@ -1,13 +1,13 @@ package com.novayaplaneta.domain.usecase +import com.novayaplaneta.domain.model.Task import com.novayaplaneta.domain.repository.TaskRepository import javax.inject.Inject class CompleteTaskUseCase @Inject constructor( private val repository: TaskRepository ) { - suspend operator fun invoke(taskId: String) { - repository.completeTask(taskId) + suspend operator fun invoke(id: String, completed: Boolean): Result { + return repository.completeTask(id, completed) } } - diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/CreateScheduleUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/CreateScheduleUseCase.kt index c7ce7ec..735edc0 100644 --- a/app/src/main/java/com/novayaplaneta/domain/usecase/CreateScheduleUseCase.kt +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/CreateScheduleUseCase.kt @@ -7,8 +7,17 @@ import javax.inject.Inject class CreateScheduleUseCase @Inject constructor( private val repository: ScheduleRepository ) { - suspend operator fun invoke(schedule: Schedule) { - repository.createSchedule(schedule) + suspend operator fun invoke(schedule: Schedule): Result { + return try { + repository.createSchedule(schedule) + // После создания перезагружаем список, чтобы получить актуальные данные + repository.loadSchedules() + // Возвращаем успешный результат + // Созданное расписание уже добавлено в кэш через createSchedule + Result.success(schedule) + } catch (e: Exception) { + Result.failure(e) + } } } diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/CreateTaskUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/CreateTaskUseCase.kt new file mode 100644 index 0000000..acf7038 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/CreateTaskUseCase.kt @@ -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 { + return repository.createTask(task) + } +} + diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/DeleteScheduleUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/DeleteScheduleUseCase.kt new file mode 100644 index 0000000..2f5f603 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/DeleteScheduleUseCase.kt @@ -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 { + return try { + repository.deleteSchedule(id) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } +} + diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/DeleteTaskUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/DeleteTaskUseCase.kt new file mode 100644 index 0000000..d4d594a --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/DeleteTaskUseCase.kt @@ -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 { + return repository.deleteTask(id) + } +} + diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/GenerateScheduleUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/GenerateScheduleUseCase.kt new file mode 100644 index 0000000..da65bfd --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/GenerateScheduleUseCase.kt @@ -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, + date: String, + description: String + ): Result { + return aiRepository.generateSchedule(childAge, preferences, date, description) + } +} + diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/GetScheduleByIdUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/GetScheduleByIdUseCase.kt new file mode 100644 index 0000000..ac68b9c --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/GetScheduleByIdUseCase.kt @@ -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 { + 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) + } + } +} + diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/GetSchedulesUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/GetSchedulesUseCase.kt index 9cca52e..e836143 100644 --- a/app/src/main/java/com/novayaplaneta/domain/usecase/GetSchedulesUseCase.kt +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/GetSchedulesUseCase.kt @@ -11,5 +11,14 @@ class GetSchedulesUseCase @Inject constructor( operator fun invoke(userId: String): Flow> { return repository.getSchedules(userId) } + + suspend fun loadSchedules(scheduleDate: String? = null): Result { + return try { + repository.loadSchedules(scheduleDate) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } } diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/GetTaskByIdUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/GetTaskByIdUseCase.kt new file mode 100644 index 0000000..362b872 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/GetTaskByIdUseCase.kt @@ -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 { + return repository.getTaskById(id) + } +} + diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/GetTasksUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/GetTasksUseCase.kt new file mode 100644 index 0000000..f5eeb27 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/GetTasksUseCase.kt @@ -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> { + return repository.getTasks(scheduleId) + } + + suspend fun loadTasks(scheduleId: String): Result { + return try { + repository.loadTasks(scheduleId) + } catch (e: Exception) { + Result.failure(e) + } + } +} + diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/SendAIMessageUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/SendAIMessageUseCase.kt index 1fd1acc..5244433 100644 --- a/app/src/main/java/com/novayaplaneta/domain/usecase/SendAIMessageUseCase.kt +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/SendAIMessageUseCase.kt @@ -1,13 +1,14 @@ package com.novayaplaneta.domain.usecase import com.novayaplaneta.domain.repository.AIRepository +import com.novayaplaneta.domain.repository.ChatResponse import javax.inject.Inject class SendAIMessageUseCase @Inject constructor( private val repository: AIRepository ) { - suspend operator fun invoke(userId: String, message: String): Result { - return repository.sendMessage(userId, message) + suspend operator fun invoke(userId: String, message: String, conversationId: String? = null): Result { + return repository.sendMessage(userId, message, conversationId) } } diff --git a/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIScreen.kt b/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIScreen.kt index 26e54b8..d7d9854 100644 --- a/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIScreen.kt +++ b/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIScreen.kt @@ -7,6 +7,9 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarDuration import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -50,10 +53,17 @@ fun AIScreen( ) { val uiState by viewModel.uiState.collectAsState() var messageText by remember { mutableStateOf("") } + val snackbarHostState = remember { SnackbarHostState() } - // Загружаем историю чата при первом открытии экрана - LaunchedEffect(Unit) { - viewModel.loadChatHistory("default") + // Показываем ошибки через Snackbar + LaunchedEffect(uiState.error) { + uiState.error?.let { error -> + snackbarHostState.showSnackbar( + message = error, + duration = SnackbarDuration.Long + ) + viewModel.clearError() + } } val configuration = LocalConfiguration.current @@ -235,7 +245,7 @@ fun AIScreen( Button( onClick = { if (messageText.isNotBlank()) { - viewModel.sendMessage("default", messageText) + viewModel.sendMessage(messageText) messageText = "" } }, @@ -259,6 +269,12 @@ fun AIScreen( } } } + + // Snackbar для отображения ошибок + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter) + ) } } diff --git a/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIViewModel.kt b/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIViewModel.kt index fb29c02..47de5a6 100644 --- a/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIViewModel.kt +++ b/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIViewModel.kt @@ -7,42 +7,82 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class AIViewModel @Inject constructor( - private val aiRepository: AIRepository + private val aiRepository: AIRepository, + private val authRepository: com.novayaplaneta.domain.repository.AuthRepository ) : ViewModel() { private val _uiState = MutableStateFlow(AIUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun loadChatHistory(userId: String) { + private var conversationId: String? = null + private var currentUserId: String? = null + + init { + loadUserId() + } + + private fun loadUserId() { viewModelScope.launch { - aiRepository.getChatHistory(userId).collect { messages -> - _uiState.value = _uiState.value.copy(messages = messages) + try { + val user = authRepository.getCurrentUser().first() + currentUserId = user?.id + if (currentUserId != null) { + loadChatHistory(currentUserId!!) + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + error = e.message ?: "Ошибка загрузки пользователя" + ) } } } - fun sendMessage(userId: String, message: String) { + fun loadChatHistory(userId: String? = null) { + val targetUserId = userId ?: currentUserId ?: return viewModelScope.launch { - _uiState.value = _uiState.value.copy(isLoading = true) - aiRepository.sendMessage(userId, message).fold( + try { + val messages = aiRepository.getChatHistory(targetUserId).first() + _uiState.value = _uiState.value.copy(messages = messages) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + error = e.message ?: "Ошибка загрузки истории чата" + ) + } + } + } + + fun sendMessage(message: String) { + val userId = currentUserId ?: return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + aiRepository.sendMessage(userId, message, conversationId).fold( onSuccess = { response -> + // Сохраняем conversation_id для следующих сообщений + conversationId = response.conversationId + // Перезагружаем историю чата loadChatHistory(userId) _uiState.value = _uiState.value.copy(isLoading = false) }, onFailure = { error -> _uiState.value = _uiState.value.copy( isLoading = false, - error = error.message + error = error.message ?: "Ошибка отправки сообщения" ) } ) } } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } } data class AIUiState( diff --git a/app/src/main/java/com/novayaplaneta/ui/screens/schedule/GenerateScheduleDialog.kt b/app/src/main/java/com/novayaplaneta/ui/screens/schedule/GenerateScheduleDialog.kt new file mode 100644 index 0000000..a416ae4 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/ui/screens/schedule/GenerateScheduleDialog.kt @@ -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, + date: String, + description: String, + onChildAgeChange: (Int) -> Unit, + onPreferencesChange: (List) -> 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 + ) + } + } + } + } + } + } +} + diff --git a/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleScreen.kt b/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleScreen.kt index 47562fe..f67dd63 100644 --- a/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleScreen.kt +++ b/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleScreen.kt @@ -12,6 +12,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Star @@ -171,28 +173,96 @@ fun ScheduleScreen( Spacer(modifier = Modifier.height(48.dp)) - // Основной контент: только сегодняшняя дата (опущена ниже) - DateSection( - date = dateOnly, - dayOfWeek = dayOfWeek, - dateCardColor = dateCardColor, - accentGreen = accentGreen, - screenHeightDp = screenHeightDp, - tasks = uiState.tasks, - onAddClick = { viewModel.showAddDialog() } - ) + // Основной контент: список расписаний + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = accentGreen) + } + } else if (uiState.errorMessage != null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Ошибка", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = Color.Red + ) + Text( + text = uiState.errorMessage ?: "Неизвестная ошибка", + fontSize = 18.sp, + color = Color.Black + ) + Button( + onClick = { viewModel.loadSchedules() }, + colors = ButtonDefaults.buttonColors(containerColor = accentGreen) + ) { + Text("Повторить") + } + } + } + } else { + SchedulesListSection( + schedules = uiState.schedules, + dateOnly = dateOnly, + dayOfWeek = dayOfWeek, + dateCardColor = dateCardColor, + accentGreen = accentGreen, + screenHeightDp = screenHeightDp, + onAddClick = { viewModel.showAddDialog() }, + onGenerateClick = { viewModel.showGenerateDialog() }, + onScheduleClick = { scheduleId -> + // TODO: Navigate to schedule details + }, + onDeleteClick = { scheduleId -> + viewModel.deleteSchedule(scheduleId) + } + ) + } } } - // Диалог выбора задачи + // Диалог создания расписания if (uiState.showAddDialog) { - AddTaskDialog( - selectedTaskType = uiState.selectedTaskType, - onTaskTypeSelected = { viewModel.selectTaskType(it) }, - onSelect = { viewModel.addTask() }, + CreateScheduleDialog( + title = uiState.newScheduleTitle, + description = uiState.newScheduleDescription, + date = uiState.newScheduleDate, + onTitleChange = { viewModel.updateNewScheduleTitle(it) }, + onDescriptionChange = { viewModel.updateNewScheduleDescription(it) }, + onDateChange = { viewModel.updateNewScheduleDate(it) }, + onCreate = { viewModel.createScheduleFromDialog() }, onDismiss = { viewModel.hideAddDialog() }, accentGreen = accentGreen, - screenHeightDp = screenHeightDp + screenHeightDp = screenHeightDp, + isLoading = uiState.isLoading + ) + } + + // Диалог генерации расписания через ИИ + if (uiState.showGenerateDialog) { + GenerateScheduleDialog( + childAge = uiState.generateChildAge, + preferences = uiState.generatePreferences, + date = uiState.generateDate, + description = uiState.generateDescription, + onChildAgeChange = { viewModel.updateGenerateChildAge(it) }, + onPreferencesChange = { viewModel.updateGeneratePreferences(it) }, + onDateChange = { viewModel.updateGenerateDate(it) }, + onDescriptionChange = { viewModel.updateGenerateDescription(it) }, + onGenerate = { viewModel.generateScheduleWithAI() }, + onDismiss = { viewModel.hideGenerateDialog() }, + accentGreen = accentGreen, + screenHeightDp = screenHeightDp, + isLoading = uiState.isLoading ) } } @@ -236,19 +306,22 @@ fun NavItem( } @Composable -fun DateSection( - date: String, +fun SchedulesListSection( + schedules: List, + dateOnly: String, dayOfWeek: String, dateCardColor: Color, accentGreen: Color, screenHeightDp: Int, - tasks: List, - onAddClick: () -> Unit + onAddClick: () -> Unit, + onGenerateClick: () -> Unit, + onScheduleClick: (String) -> Unit, + onDeleteClick: (String) -> Unit ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp) ) { - // Дата и кнопка + в одной строке + // Дата и кнопки в одной строке Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -267,7 +340,7 @@ fun DateSection( horizontalAlignment = Alignment.Start ) { Text( - text = date, + text = dateOnly, fontSize = dateTextSize, fontWeight = FontWeight.Bold, 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( onClick = onAddClick, @@ -296,16 +383,33 @@ fun DateSection( } } - // Задачи в ряд - if (tasks.isNotEmpty()) { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(16.dp), + // Список расписаний + if (schedules.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Нет расписаний", + fontSize = 20.sp, + color = Color.Gray + ) + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { - items(tasks) { task -> - TaskCard( - taskType = task, - screenHeightDp = screenHeightDp + items(schedules) { schedule -> + ScheduleCard( + schedule = schedule, + screenHeightDp = screenHeightDp, + accentGreen = accentGreen, + dateCardColor = dateCardColor, + onClick = { onScheduleClick(schedule.id) }, + onDeleteClick = { onDeleteClick(schedule.id) } ) } } @@ -314,47 +418,71 @@ fun DateSection( } @Composable -fun TaskCard( - taskType: TaskType, - screenHeightDp: Int +fun ScheduleCard( + schedule: com.novayaplaneta.domain.model.Schedule, + screenHeightDp: Int, + accentGreen: Color, + dateCardColor: Color, + onClick: () -> Unit, + onDeleteClick: () -> Unit ) { - val cardWidth = 200.dp - val cardHeight = 180.dp + val dateFormatter = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy") + val scheduleDate = schedule.date.toLocalDate().format(dateFormatter) - Box( + Card( modifier = Modifier - .width(cardWidth) - .height(cardHeight) - .clip(RoundedCornerShape(16.dp)) - .background(color = Color.LightGray) + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = dateCardColor) ) { Column( - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - // Верхняя часть (для изображения) - Box( - modifier = Modifier - .fillMaxWidth() - .weight(0.6f) - .background(color = Color.White.copy(alpha = 0.5f)) - ) - - // Нижняя часть (для текста) - Box( - modifier = Modifier - .fillMaxWidth() - .weight(0.4f) - .background(color = Color.LightGray) - .padding(12.dp), - contentAlignment = Alignment.Center + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp + Column(modifier = Modifier.weight(1f)) { + Text( + text = schedule.title, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + Text( + text = scheduleDate, + fontSize = 16.sp, + color = accentGreen, + fontWeight = FontWeight.Medium + ) + } + IconButton(onClick = onDeleteClick) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Удалить", + tint = Color.Red + ) + } + } + + if (!schedule.description.isNullOrBlank()) { Text( - text = taskType.title, - fontSize = textSize, - fontWeight = FontWeight.Bold, - color = Color.Black, - textAlign = TextAlign.Center + text = schedule.description ?: "", + fontSize = 16.sp, + color = Color.Black + ) + } + + if (schedule.tasks.isNotEmpty()) { + Text( + text = "Задач: ${schedule.tasks.size}", + fontSize = 14.sp, + color = Color.Gray ) } } @@ -362,18 +490,23 @@ fun TaskCard( } @Composable -fun AddTaskDialog( - selectedTaskType: TaskType?, - onTaskTypeSelected: (TaskType) -> Unit, - onSelect: () -> Unit, +fun CreateScheduleDialog( + title: String, + description: String, + date: java.time.LocalDate?, + onTitleChange: (String) -> Unit, + onDescriptionChange: (String) -> Unit, + onDateChange: (java.time.LocalDate) -> Unit, + onCreate: () -> Unit, onDismiss: () -> Unit, accentGreen: Color, - screenHeightDp: Int + screenHeightDp: Int, + isLoading: Boolean ) { Dialog(onDismissRequest = onDismiss) { Card( modifier = Modifier - .fillMaxWidth(0.8f) + .fillMaxWidth(0.9f) .wrapContentHeight(), shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors( @@ -385,47 +518,42 @@ fun AddTaskDialog( .fillMaxWidth() .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp) + verticalArrangement = Arrangement.spacedBy(16.dp) ) { // Заголовок val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp Text( - text = "Выберите задачу", + text = "Создать расписание", fontSize = titleSize, fontWeight = FontWeight.Bold, color = Color.Black ) - // Опции выбора - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - // Подарок - TaskOption( - taskType = TaskType.Gift, - isSelected = selectedTaskType == TaskType.Gift, - onClick = { onTaskTypeSelected(TaskType.Gift) }, - accentGreen = accentGreen, - screenHeightDp = screenHeightDp - ) - - // Кушать ложкой - TaskOption( - taskType = TaskType.EatWithSpoon, - isSelected = selectedTaskType == TaskType.EatWithSpoon, - onClick = { onTaskTypeSelected(TaskType.EatWithSpoon) }, - accentGreen = accentGreen, - screenHeightDp = screenHeightDp - ) - } + // Поле названия + OutlinedTextField( + value = title, + onValueChange = onTitleChange, + label = { Text("Название") }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) + + // Поле описания + OutlinedTextField( + value = description, + onValueChange = onDescriptionChange, + label = { Text("Описание") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3, + enabled = !isLoading + ) // Кнопки внизу Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - // Кнопка "Назад" + // Кнопка "Отмена" Button( onClick = onDismiss, modifier = Modifier @@ -435,23 +563,24 @@ fun AddTaskDialog( colors = ButtonDefaults.buttonColors( containerColor = Color.LightGray, contentColor = Color.Black - ) + ), + enabled = !isLoading ) { val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp Text( - text = "Назад", + text = "Отмена", fontSize = buttonTextSize, fontWeight = FontWeight.Bold ) } - // Кнопка "Выбрать" + // Кнопка "Создать" Button( - onClick = onSelect, + onClick = onCreate, modifier = Modifier .weight(1f) .height(56.dp), - enabled = selectedTaskType != null, + enabled = title.isNotBlank() && !isLoading, shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors( containerColor = accentGreen, @@ -460,12 +589,19 @@ fun AddTaskDialog( disabledContentColor = Color.Gray ) ) { - val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp - Text( - text = "Выбрать", - fontSize = buttonTextSize, - fontWeight = FontWeight.Bold - ) + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White + ) + } else { + val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp + Text( + text = "Создать", + fontSize = buttonTextSize, + fontWeight = FontWeight.Bold + ) + } } } } @@ -473,34 +609,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 - ) - } -} diff --git a/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleViewModel.kt b/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleViewModel.kt index b8d5ec8..fc70c8c 100644 --- a/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleViewModel.kt @@ -2,12 +2,20 @@ package com.novayaplaneta.ui.screens.schedule import androidx.lifecycle.ViewModel 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 dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.format.DateTimeFormatter import javax.inject.Inject sealed class TaskType(val title: String) { @@ -17,52 +25,247 @@ sealed class TaskType(val title: String) { @HiltViewModel class ScheduleViewModel @Inject constructor( - private val getSchedulesUseCase: GetSchedulesUseCase + private val getSchedulesUseCase: GetSchedulesUseCase, + private val createScheduleUseCase: CreateScheduleUseCase, + private val deleteScheduleUseCase: DeleteScheduleUseCase, + private val generateScheduleUseCase: GenerateScheduleUseCase, + private val authRepository: AuthRepository ) : ViewModel() { private val _uiState = MutableStateFlow(ScheduleUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun loadSchedules(userId: String) { + init { + loadSchedules() + } + + fun loadSchedules(scheduleDate: String? = null) { viewModelScope.launch { - _uiState.value = _uiState.value.copy(isLoading = true) - getSchedulesUseCase(userId).collect { schedules -> + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + try { + // Получаем текущего пользователя + val user = authRepository.getCurrentUser().first() + val userId = user?.id ?: "" + + // Загружаем расписания с сервера + val loadResult = getSchedulesUseCase.loadSchedules(scheduleDate) + if (loadResult.isFailure) { + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = loadResult.exceptionOrNull()?.message ?: "Ошибка загрузки расписаний" + ) + return@launch + } + + // Получаем актуальный список расписаний из Flow + val schedules = getSchedulesUseCase(userId).first() + + // Фильтруем расписания по дате, если указана + val filteredSchedules = if (scheduleDate != null) { + schedules.filter { schedule -> + val scheduleDateStr = schedule.date.toLocalDate().format(DateTimeFormatter.ISO_DATE) + scheduleDateStr == scheduleDate + } + } else { + schedules + } + _uiState.value = _uiState.value.copy( - schedules = schedules, - isLoading = false + schedules = filteredSchedules, + isLoading = false, + errorMessage = null + ) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = e.message ?: "Ошибка загрузки расписаний" ) } } } fun showAddDialog() { - _uiState.value = _uiState.value.copy(showAddDialog = true, selectedTaskType = null) + _uiState.value = _uiState.value.copy( + showAddDialog = true, + newScheduleTitle = "", + newScheduleDescription = "", + newScheduleDate = LocalDate.now() + ) } fun hideAddDialog() { - _uiState.value = _uiState.value.copy(showAddDialog = false, selectedTaskType = null) - } - - fun selectTaskType(taskType: TaskType) { - _uiState.value = _uiState.value.copy(selectedTaskType = taskType) - } - - fun addTask() { - val selected = _uiState.value.selectedTaskType ?: return - val newTasks = _uiState.value.tasks + selected _uiState.value = _uiState.value.copy( - tasks = newTasks, showAddDialog = false, - selectedTaskType = null + newScheduleTitle = "", + newScheduleDescription = "", + newScheduleDate = null ) } + + 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) { + _uiState.value = _uiState.value.copy(generatePreferences = preferences) + } + + fun updateGenerateDate(date: String) { + _uiState.value = _uiState.value.copy(generateDate = date) + } + + fun updateGenerateDescription(description: String) { + _uiState.value = _uiState.value.copy(generateDescription = description) + } + + fun generateScheduleWithAI() { + viewModelScope.launch { + val state = _uiState.value + if (state.generateDate.isNotBlank()) { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + val result = generateScheduleUseCase( + childAge = state.generateChildAge, + preferences = state.generatePreferences, + date = state.generateDate, + description = state.generateDescription + ) + + result.onSuccess { generateResult -> + _uiState.value = _uiState.value.copy( + isLoading = false, + showGenerateDialog = false, + generatedScheduleId = generateResult.scheduleId + ) + // Перезагружаем расписания, чтобы показать новое + loadSchedules() + }.onFailure { exception -> + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = exception.message ?: "Ошибка генерации расписания" + ) + } + } + } + } } data class ScheduleUiState( - val schedules: List = emptyList(), - val tasks: List = emptyList(), + val schedules: List = emptyList(), val isLoading: Boolean = false, + val errorMessage: String? = null, val showAddDialog: Boolean = false, - val selectedTaskType: TaskType? = null + val newScheduleTitle: String = "", + val newScheduleDescription: String = "", + val newScheduleDate: LocalDate? = null, + val showGenerateDialog: Boolean = false, + val generateChildAge: Int = 5, + val generatePreferences: List = emptyList(), + val generateDate: String = "", + val generateDescription: String = "", + val generatedScheduleId: String? = null ) diff --git a/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskScreen.kt b/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskScreen.kt index d4a84c9..e6ab87f 100644 --- a/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskScreen.kt +++ b/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskScreen.kt @@ -3,26 +3,48 @@ package com.novayaplaneta.ui.screens.task import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn 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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController @OptIn(ExperimentalMaterial3Api::class) @Composable fun TaskScreen( + scheduleId: String? = null, + navController: NavController? = null, viewModel: TaskViewModel = hiltViewModel(), modifier: Modifier = Modifier ) { val uiState by viewModel.uiState.collectAsState() + // Загружаем задачи при открытии экрана + LaunchedEffect(scheduleId) { + scheduleId?.let { + viewModel.loadTasks(it) + } + } + Scaffold( topBar = { TopAppBar( - title = { Text("Задания") } + title = { Text("Задания") }, + actions = { + if (scheduleId != null) { + IconButton(onClick = { /* TODO: Show create task dialog */ }) { + Icon(Icons.Default.Add, contentDescription = "Добавить задачу") + } + } + } ) } ) { paddingValues -> @@ -32,12 +54,38 @@ fun TaskScreen( .padding(paddingValues) .padding(16.dp) ) { - if (uiState.isLoading) { - CircularProgressIndicator( + // Отображение ошибок + uiState.errorMessage?.let { error -> + Card( modifier = Modifier .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 { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp) @@ -50,7 +98,8 @@ fun TaskScreen( modifier = Modifier .fillMaxWidth() .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( @@ -63,11 +112,28 @@ fun TaskScreen( style = MaterialTheme.typography.bodySmall ) } + task.duration?.let { + Text( + text = "Длительность: $it мин", + style = MaterialTheme.typography.bodySmall + ) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = task.completed, + onCheckedChange = { completed -> + viewModel.completeTask(task.id, completed) + } + ) + IconButton(onClick = { viewModel.deleteTask(task.id) }) { + Icon( + Icons.Default.Delete, + contentDescription = "Удалить", + tint = MaterialTheme.colorScheme.error + ) + } } - Checkbox( - checked = task.completed, - onCheckedChange = { viewModel.completeTask(task.id) } - ) } } } diff --git a/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskViewModel.kt b/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskViewModel.kt index 9379514..3760d3a 100644 --- a/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskViewModel.kt +++ b/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskViewModel.kt @@ -2,43 +2,158 @@ package com.novayaplaneta.ui.screens.task import androidx.lifecycle.ViewModel 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 kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class 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() { private val _uiState = MutableStateFlow(TaskUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var currentScheduleId: String? = null + fun loadTasks(scheduleId: String) { + currentScheduleId = scheduleId viewModelScope.launch { - _uiState.value = _uiState.value.copy(isLoading = true) - taskRepository.getTasks(scheduleId).collect { tasks -> + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + try { + val loadResult = getTasksUseCase.loadTasks(scheduleId) + if (loadResult.isFailure) { + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = loadResult.exceptionOrNull()?.message ?: "Ошибка загрузки задач" + ) + return@launch + } + + // Подписываемся на Flow для обновлений + getTasksUseCase(scheduleId).collect { tasks -> + _uiState.value = _uiState.value.copy( + tasks = tasks.sortedBy { it.order }, + isLoading = false, + errorMessage = null + ) + } + } catch (e: Exception) { _uiState.value = _uiState.value.copy( - tasks = tasks, - isLoading = false + isLoading = false, + errorMessage = e.message ?: "Ошибка загрузки задач" ) } } } - fun completeTask(taskId: String) { + fun loadTaskById(taskId: String) { viewModelScope.launch { - taskRepository.completeTask(taskId) + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + val result = getTaskByIdUseCase(taskId) + result.onSuccess { task -> + _uiState.value = _uiState.value.copy( + selectedTask = task, + isLoading = false, + errorMessage = null + ) + }.onFailure { exception -> + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = exception.message ?: "Ошибка загрузки задачи" + ) + } } } + + fun createTask(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( - val tasks: List = emptyList(), - val isLoading: Boolean = false + val tasks: List = emptyList(), + val selectedTask: Task? = null, + val isLoading: Boolean = false, + val errorMessage: String? = null )