Merge pull request 'фикс багов, интеграция с бэком+агентами' (#1) from fixesAndPreparation into master

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
18 changed files with 1243 additions and 120 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable
data class CreateScheduleRequest( data class CreateScheduleRequest(
val title: String, val title: String,
val date: String, // yyyy-MM-dd val date: String, // yyyy-MM-dd
val description: String val description: String,
val reward_id: String? = null
) )

View File

@@ -8,6 +8,6 @@ data class GenerateScheduleResponse(
val schedule_id: String, val schedule_id: String,
val title: String, val title: String,
val tasks: List<JsonObject>, val tasks: List<JsonObject>,
val tokens_used: Int val tokens_used: Int? = null
) )

View File

@@ -11,7 +11,8 @@ data class ScheduleDto(
val user_id: String, val user_id: String,
val created_at: String, // ISO date val created_at: String, // ISO date
val updated_at: String, // ISO date val updated_at: String, // ISO date
val tasks: List<TaskDto> = emptyList() val tasks: List<TaskDto> = emptyList(),
val reward_id: String? = null
) )
// TaskDto moved to separate file // TaskDto moved to separate file

View File

@@ -31,7 +31,8 @@ fun ScheduleDto.toDomain(): Schedule {
tasks = tasks.map { it.toDomain() }, tasks = tasks.map { it.toDomain() },
date = dateLocalDateTime, date = dateLocalDateTime,
createdAt = createdAtDateTime, createdAt = createdAtDateTime,
userId = user_id userId = user_id,
rewardId = reward_id
) )
} }
@@ -44,7 +45,8 @@ fun Schedule.toCreateRequest(): com.novayaplaneta.data.remote.dto.CreateSchedule
return com.novayaplaneta.data.remote.dto.CreateScheduleRequest( return com.novayaplaneta.data.remote.dto.CreateScheduleRequest(
title = title, title = title,
date = dateString, date = dateString,
description = description ?: "" description = description ?: "",
reward_id = rewardId
) )
} }

View File

@@ -58,6 +58,13 @@ class AuthRepositoryImpl @Inject constructor(
// Сохраняем токены // Сохраняем токены
tokenManager.saveTokens(tokenResponse.access_token, tokenResponse.refresh_token) tokenManager.saveTokens(tokenResponse.access_token, tokenResponse.refresh_token)
// Убеждаемся, что токен действительно сохранен перед вызовом getMe()
// Это гарантирует, что DataStore завершил сохранение
val savedToken = tokenManager.getAccessToken()
if (savedToken == null) {
return Result.failure(Exception("Failed to save access token"))
}
// Получаем информацию о пользователе // Получаем информацию о пользователе
val meResponse = authApi.getMe() val meResponse = authApi.getMe()
if (meResponse.isSuccessful && meResponse.body() != null) { if (meResponse.isSuccessful && meResponse.body() != null) {
@@ -73,11 +80,14 @@ class AuthRepositoryImpl @Inject constructor(
saveUser(user) saveUser(user)
Result.success(user) Result.success(user)
} else { } else {
Result.failure(Exception("Failed to get user info")) val errorCode = meResponse.code()
val errorBody = meResponse.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Failed to get user info: HTTP $errorCode - $errorBody"))
} }
} else { } else {
val errorBody = response.errorBody()?.string() ?: "Login failed" val errorCode = response.code()
Result.failure(Exception(errorBody)) val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Login failed: HTTP $errorCode - $errorBody"))
} }
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)

View File

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

View File

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

View File

@@ -3,8 +3,10 @@ package com.novayaplaneta.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.novayaplaneta.ui.screens.ai.AIScreen import com.novayaplaneta.ui.screens.ai.AIScreen
import com.novayaplaneta.ui.screens.auth.ForgotPasswordScreen import com.novayaplaneta.ui.screens.auth.ForgotPasswordScreen
import com.novayaplaneta.ui.screens.auth.LoginScreen import com.novayaplaneta.ui.screens.auth.LoginScreen
@@ -42,8 +44,17 @@ fun NewPlanetNavigation(
composable("schedule") { composable("schedule") {
ScheduleScreen(navController = navController) ScheduleScreen(navController = navController)
} }
composable("tasks") { composable(
TaskScreen() route = "tasks/{scheduleId}",
arguments = listOf(
navArgument("scheduleId") { type = NavType.StringType }
)
) { backStackEntry ->
val scheduleId = backStackEntry.arguments?.getString("scheduleId")
TaskScreen(
scheduleId = scheduleId,
navController = navController
)
} }
composable("timer") { composable("timer") {
TimerScreen() TimerScreen()

View File

@@ -186,7 +186,7 @@ fun AIScreen(
// Заголовок чата // Заголовок чата
val titleSize = (screenHeightDp * 0.04f).toInt().coerceIn(32, 52).sp val titleSize = (screenHeightDp * 0.04f).toInt().coerceIn(32, 52).sp
Text( Text(
text = "Чат с Землей", text = "Помощник Земля",
fontSize = titleSize, fontSize = titleSize,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.Black, color = Color.Black,

View File

@@ -22,6 +22,7 @@ class AIViewModel @Inject constructor(
private var conversationId: String? = null private var conversationId: String? = null
private var currentUserId: String? = null private var currentUserId: String? = null
private var sendMessageJob: kotlinx.coroutines.Job? = null
init { init {
loadUserId() loadUserId()
@@ -59,7 +60,13 @@ class AIViewModel @Inject constructor(
fun sendMessage(message: String) { fun sendMessage(message: String) {
val userId = currentUserId ?: return val userId = currentUserId ?: return
viewModelScope.launch {
// Предотвращаем повторные отправки
if (_uiState.value.isLoading || sendMessageJob?.isActive == true) {
return
}
sendMessageJob = viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null) _uiState.value = _uiState.value.copy(isLoading = true, error = null)
aiRepository.sendMessage(userId, message, conversationId).fold( aiRepository.sendMessage(userId, message, conversationId).fold(
@@ -70,6 +77,34 @@ class AIViewModel @Inject constructor(
loadChatHistory(userId) loadChatHistory(userId)
_uiState.value = _uiState.value.copy(isLoading = false) _uiState.value = _uiState.value.copy(isLoading = false)
}, },
onFailure = { error ->
val errorMessage = error.message ?: "Ошибка отправки сообщения"
// Если ошибка связана с дубликатом conversation_id, сбрасываем его
if (errorMessage.contains("duplicate key", ignoreCase = true) ||
errorMessage.contains("conversation_id", ignoreCase = true)) {
conversationId = null
// Пытаемся отправить сообщение снова без conversation_id
retrySendMessage(userId, message)
} else {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = errorMessage
)
}
}
)
}
}
private fun retrySendMessage(userId: String, message: String) {
viewModelScope.launch {
aiRepository.sendMessage(userId, message, null).fold(
onSuccess = { response ->
conversationId = response.conversationId
loadChatHistory(userId)
_uiState.value = _uiState.value.copy(isLoading = false)
},
onFailure = { error -> onFailure = { error ->
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,

View File

@@ -1,6 +1,7 @@
package com.novayaplaneta.ui.screens.schedule package com.novayaplaneta.ui.screens.schedule
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -21,6 +22,9 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -212,6 +216,7 @@ fun ScheduleScreen(
} else { } else {
SchedulesListSection( SchedulesListSection(
schedules = uiState.schedules, schedules = uiState.schedules,
availableRewards = uiState.availableRewards,
dateOnly = dateOnly, dateOnly = dateOnly,
dayOfWeek = dayOfWeek, dayOfWeek = dayOfWeek,
dateCardColor = dateCardColor, dateCardColor = dateCardColor,
@@ -220,7 +225,7 @@ fun ScheduleScreen(
onAddClick = { viewModel.showAddDialog() }, onAddClick = { viewModel.showAddDialog() },
onGenerateClick = { viewModel.showGenerateDialog() }, onGenerateClick = { viewModel.showGenerateDialog() },
onScheduleClick = { scheduleId -> onScheduleClick = { scheduleId ->
// TODO: Navigate to schedule details navController?.navigate("tasks/$scheduleId")
}, },
onDeleteClick = { scheduleId -> onDeleteClick = { scheduleId ->
viewModel.deleteSchedule(scheduleId) viewModel.deleteSchedule(scheduleId)
@@ -236,9 +241,12 @@ fun ScheduleScreen(
title = uiState.newScheduleTitle, title = uiState.newScheduleTitle,
description = uiState.newScheduleDescription, description = uiState.newScheduleDescription,
date = uiState.newScheduleDate, date = uiState.newScheduleDate,
selectedRewardId = uiState.selectedRewardId,
availableRewards = uiState.availableRewards,
onTitleChange = { viewModel.updateNewScheduleTitle(it) }, onTitleChange = { viewModel.updateNewScheduleTitle(it) },
onDescriptionChange = { viewModel.updateNewScheduleDescription(it) }, onDescriptionChange = { viewModel.updateNewScheduleDescription(it) },
onDateChange = { viewModel.updateNewScheduleDate(it) }, onDateChange = { viewModel.updateNewScheduleDate(it) },
onRewardSelected = { viewModel.updateSelectedReward(it) },
onCreate = { viewModel.createScheduleFromDialog() }, onCreate = { viewModel.createScheduleFromDialog() },
onDismiss = { viewModel.hideAddDialog() }, onDismiss = { viewModel.hideAddDialog() },
accentGreen = accentGreen, accentGreen = accentGreen,
@@ -308,6 +316,7 @@ fun NavItem(
@Composable @Composable
fun SchedulesListSection( fun SchedulesListSection(
schedules: List<com.novayaplaneta.domain.model.Schedule>, schedules: List<com.novayaplaneta.domain.model.Schedule>,
availableRewards: List<com.novayaplaneta.domain.model.Reward>,
dateOnly: String, dateOnly: String,
dayOfWeek: String, dayOfWeek: String,
dateCardColor: Color, dateCardColor: Color,
@@ -403,8 +412,12 @@ fun SchedulesListSection(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
items(schedules) { schedule -> items(schedules) { schedule ->
val reward = schedule.rewardId?.let { rewardId ->
availableRewards.find { it.id == rewardId }
}
ScheduleCard( ScheduleCard(
schedule = schedule, schedule = schedule,
reward = reward,
screenHeightDp = screenHeightDp, screenHeightDp = screenHeightDp,
accentGreen = accentGreen, accentGreen = accentGreen,
dateCardColor = dateCardColor, dateCardColor = dateCardColor,
@@ -420,6 +433,7 @@ fun SchedulesListSection(
@Composable @Composable
fun ScheduleCard( fun ScheduleCard(
schedule: com.novayaplaneta.domain.model.Schedule, schedule: com.novayaplaneta.domain.model.Schedule,
reward: com.novayaplaneta.domain.model.Reward?,
screenHeightDp: Int, screenHeightDp: Int,
accentGreen: Color, accentGreen: Color,
dateCardColor: Color, dateCardColor: Color,
@@ -478,25 +492,60 @@ fun ScheduleCard(
) )
} }
if (schedule.tasks.isNotEmpty()) { Row(
Text( modifier = Modifier.fillMaxWidth(),
text = "Задач: ${schedule.tasks.size}", horizontalArrangement = Arrangement.SpaceBetween,
fontSize = 14.sp, verticalAlignment = Alignment.CenterVertically
color = Color.Gray ) {
) if (schedule.tasks.isNotEmpty()) {
Text(
text = "Задач: ${schedule.tasks.size}",
fontSize = 14.sp,
color = Color.Gray
)
}
// Отображение награды
reward?.let {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFFFFD700).copy(alpha = 0.3f)) // Золотистый цвет для награды
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = "Награда",
tint = Color(0xFFFFB300), // Не сильно оранжевый, золотистый
modifier = Modifier.size(20.dp)
)
Text(
text = it.title,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFFF8C00) // Не сильно оранжевый
)
}
}
} }
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CreateScheduleDialog( fun CreateScheduleDialog(
title: String, title: String,
description: String, description: String,
date: java.time.LocalDate?, date: java.time.LocalDate?,
selectedRewardId: String?,
availableRewards: List<com.novayaplaneta.domain.model.Reward>,
onTitleChange: (String) -> Unit, onTitleChange: (String) -> Unit,
onDescriptionChange: (String) -> Unit, onDescriptionChange: (String) -> Unit,
onDateChange: (java.time.LocalDate) -> Unit, onDateChange: (java.time.LocalDate) -> Unit,
onRewardSelected: (String?) -> Unit,
onCreate: () -> Unit, onCreate: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
accentGreen: Color, accentGreen: Color,
@@ -548,6 +597,76 @@ fun CreateScheduleDialog(
enabled = !isLoading enabled = !isLoading
) )
// Выбор награды
if (availableRewards.isNotEmpty()) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Награда (опционально)",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.Black
)
// Выпадающий список наград
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = availableRewards.find { it.id == selectedRewardId }?.title ?: "Не выбрано",
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor()
.fillMaxWidth(),
enabled = !isLoading,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentGreen,
focusedLabelColor = accentGreen
)
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
// Опция "Не выбрано"
DropdownMenuItem(
text = { Text("Не выбрано") },
onClick = {
onRewardSelected(null)
expanded = false
}
)
availableRewards.forEach { reward ->
DropdownMenuItem(
text = {
Column {
Text(reward.title, fontWeight = FontWeight.Bold)
reward.description?.let {
Text(it, fontSize = 12.sp, color = Color.Gray)
}
}
},
onClick = {
onRewardSelected(reward.id)
expanded = false
}
)
}
}
}
}
}
// Кнопки внизу // Кнопки внизу
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),

View File

@@ -2,11 +2,13 @@ package com.novayaplaneta.ui.screens.schedule
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.novayaplaneta.domain.model.Reward
import com.novayaplaneta.domain.model.Schedule import com.novayaplaneta.domain.model.Schedule
import com.novayaplaneta.domain.repository.AuthRepository import com.novayaplaneta.domain.repository.AuthRepository
import com.novayaplaneta.domain.usecase.CreateScheduleUseCase import com.novayaplaneta.domain.usecase.CreateScheduleUseCase
import com.novayaplaneta.domain.usecase.DeleteScheduleUseCase import com.novayaplaneta.domain.usecase.DeleteScheduleUseCase
import com.novayaplaneta.domain.usecase.GenerateScheduleUseCase import com.novayaplaneta.domain.usecase.GenerateScheduleUseCase
import com.novayaplaneta.domain.usecase.GetRewardsUseCase
import com.novayaplaneta.domain.usecase.GetSchedulesUseCase import com.novayaplaneta.domain.usecase.GetSchedulesUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -29,6 +31,7 @@ class ScheduleViewModel @Inject constructor(
private val createScheduleUseCase: CreateScheduleUseCase, private val createScheduleUseCase: CreateScheduleUseCase,
private val deleteScheduleUseCase: DeleteScheduleUseCase, private val deleteScheduleUseCase: DeleteScheduleUseCase,
private val generateScheduleUseCase: GenerateScheduleUseCase, private val generateScheduleUseCase: GenerateScheduleUseCase,
private val getRewardsUseCase: GetRewardsUseCase,
private val authRepository: AuthRepository private val authRepository: AuthRepository
) : ViewModel() { ) : ViewModel() {
@@ -37,6 +40,25 @@ class ScheduleViewModel @Inject constructor(
init { init {
loadSchedules() loadSchedules()
loadRewards()
}
private fun loadRewards() {
viewModelScope.launch {
try {
val user = authRepository.getCurrentUser().first()
val userId = user?.id ?: return@launch
val loadResult = getRewardsUseCase.loadRewards()
if (loadResult.isSuccess) {
getRewardsUseCase(userId).collect { rewards ->
_uiState.value = _uiState.value.copy(availableRewards = rewards)
}
}
} catch (e: Exception) {
// Игнорируем ошибки загрузки наград
}
}
} }
fun loadSchedules(scheduleDate: String? = null) { fun loadSchedules(scheduleDate: String? = null) {
@@ -90,7 +112,8 @@ class ScheduleViewModel @Inject constructor(
showAddDialog = true, showAddDialog = true,
newScheduleTitle = "", newScheduleTitle = "",
newScheduleDescription = "", newScheduleDescription = "",
newScheduleDate = LocalDate.now() newScheduleDate = LocalDate.now(),
selectedRewardId = null
) )
} }
@@ -99,10 +122,15 @@ class ScheduleViewModel @Inject constructor(
showAddDialog = false, showAddDialog = false,
newScheduleTitle = "", newScheduleTitle = "",
newScheduleDescription = "", newScheduleDescription = "",
newScheduleDate = null newScheduleDate = null,
selectedRewardId = null
) )
} }
fun updateSelectedReward(rewardId: String?) {
_uiState.value = _uiState.value.copy(selectedRewardId = rewardId)
}
fun updateNewScheduleTitle(title: String) { fun updateNewScheduleTitle(title: String) {
_uiState.value = _uiState.value.copy(newScheduleTitle = title) _uiState.value = _uiState.value.copy(newScheduleTitle = title)
} }
@@ -121,12 +149,13 @@ class ScheduleViewModel @Inject constructor(
createSchedule( createSchedule(
title = state.newScheduleTitle, title = state.newScheduleTitle,
description = state.newScheduleDescription, description = state.newScheduleDescription,
date = state.newScheduleDate date = state.newScheduleDate,
rewardId = state.selectedRewardId
) )
} }
} }
fun createSchedule(title: String, description: String, date: LocalDate) { fun createSchedule(title: String, description: String, date: LocalDate, rewardId: String? = null) {
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
@@ -141,7 +170,8 @@ class ScheduleViewModel @Inject constructor(
tasks = emptyList(), tasks = emptyList(),
date = date.atStartOfDay(), date = date.atStartOfDay(),
createdAt = java.time.LocalDateTime.now(), createdAt = java.time.LocalDateTime.now(),
userId = userId userId = userId,
rewardId = rewardId
) )
val result = createScheduleUseCase(schedule) val result = createScheduleUseCase(schedule)
@@ -261,6 +291,8 @@ data class ScheduleUiState(
val newScheduleTitle: String = "", val newScheduleTitle: String = "",
val newScheduleDescription: String = "", val newScheduleDescription: String = "",
val newScheduleDate: LocalDate? = null, val newScheduleDate: LocalDate? = null,
val selectedRewardId: String? = null,
val availableRewards: List<Reward> = emptyList(),
val showGenerateDialog: Boolean = false, val showGenerateDialog: Boolean = false,
val generateChildAge: Int = 5, val generateChildAge: Int = 5,
val generatePreferences: List<String> = emptyList(), val generatePreferences: List<String> = emptyList(),

View File

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

View File

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