фикс багов, интеграция с бэком+агентами #1

Merged
FDKost merged 1 commits from fixesAndPreparation into master 2025-12-26 22:20:41 +03:00
18 changed files with 1243 additions and 120 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable
data class CreateScheduleRequest(
val title: String,
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 title: String,
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 created_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

View File

@@ -31,7 +31,8 @@ fun ScheduleDto.toDomain(): Schedule {
tasks = tasks.map { it.toDomain() },
date = dateLocalDateTime,
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(
title = title,
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)
// Убеждаемся, что токен действительно сохранен перед вызовом getMe()
// Это гарантирует, что DataStore завершил сохранение
val savedToken = tokenManager.getAccessToken()
if (savedToken == null) {
return Result.failure(Exception("Failed to save access token"))
}
// Получаем информацию о пользователе
val meResponse = authApi.getMe()
if (meResponse.isSuccessful && meResponse.body() != null) {
@@ -73,11 +80,14 @@ class AuthRepositoryImpl @Inject constructor(
saveUser(user)
Result.success(user)
} 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 {
val errorBody = response.errorBody()?.string() ?: "Login failed"
Result.failure(Exception(errorBody))
val errorCode = response.code()
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Result.failure(Exception("Login failed: HTTP $errorCode - $errorBody"))
}
} catch (e: Exception) {
Result.failure(e)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,13 @@ package com.novayaplaneta.ui.screens.schedule
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.novayaplaneta.domain.model.Reward
import com.novayaplaneta.domain.model.Schedule
import com.novayaplaneta.domain.repository.AuthRepository
import com.novayaplaneta.domain.usecase.CreateScheduleUseCase
import com.novayaplaneta.domain.usecase.DeleteScheduleUseCase
import com.novayaplaneta.domain.usecase.GenerateScheduleUseCase
import com.novayaplaneta.domain.usecase.GetRewardsUseCase
import com.novayaplaneta.domain.usecase.GetSchedulesUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -29,6 +31,7 @@ class ScheduleViewModel @Inject constructor(
private val createScheduleUseCase: CreateScheduleUseCase,
private val deleteScheduleUseCase: DeleteScheduleUseCase,
private val generateScheduleUseCase: GenerateScheduleUseCase,
private val getRewardsUseCase: GetRewardsUseCase,
private val authRepository: AuthRepository
) : ViewModel() {
@@ -37,6 +40,25 @@ class ScheduleViewModel @Inject constructor(
init {
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) {
@@ -90,7 +112,8 @@ class ScheduleViewModel @Inject constructor(
showAddDialog = true,
newScheduleTitle = "",
newScheduleDescription = "",
newScheduleDate = LocalDate.now()
newScheduleDate = LocalDate.now(),
selectedRewardId = null
)
}
@@ -99,10 +122,15 @@ class ScheduleViewModel @Inject constructor(
showAddDialog = false,
newScheduleTitle = "",
newScheduleDescription = "",
newScheduleDate = null
newScheduleDate = null,
selectedRewardId = null
)
}
fun updateSelectedReward(rewardId: String?) {
_uiState.value = _uiState.value.copy(selectedRewardId = rewardId)
}
fun updateNewScheduleTitle(title: String) {
_uiState.value = _uiState.value.copy(newScheduleTitle = title)
}
@@ -121,12 +149,13 @@ class ScheduleViewModel @Inject constructor(
createSchedule(
title = state.newScheduleTitle,
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 {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
@@ -141,7 +170,8 @@ class ScheduleViewModel @Inject constructor(
tasks = emptyList(),
date = date.atStartOfDay(),
createdAt = java.time.LocalDateTime.now(),
userId = userId
userId = userId,
rewardId = rewardId
)
val result = createScheduleUseCase(schedule)
@@ -261,6 +291,8 @@ data class ScheduleUiState(
val newScheduleTitle: String = "",
val newScheduleDescription: String = "",
val newScheduleDate: LocalDate? = null,
val selectedRewardId: String? = null,
val availableRewards: List<Reward> = emptyList(),
val showGenerateDialog: Boolean = false,
val generateChildAge: Int = 5,
val generatePreferences: List<String> = emptyList(),

View File

@@ -1,23 +1,58 @@
package com.novayaplaneta.ui.screens.task
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.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.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.novayaplaneta.ui.theme.LoginBackgroundTurquoise
import com.novayaplaneta.ui.theme.LoginGreenAccent
import com.novayaplaneta.ui.theme.LoginInputLightBlue
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskScreen(
scheduleId: String? = null,
@@ -26,6 +61,13 @@ fun TaskScreen(
modifier: Modifier = Modifier
) {
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) {
@@ -34,65 +76,186 @@ fun TaskScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Задания") },
actions = {
if (scheduleId != null) {
IconButton(onClick = { /* TODO: Show create task dialog */ }) {
Icon(Icons.Default.Add, contentDescription = "Добавить задачу")
}
}
}
)
}
) { paddingValues ->
Column(
Box(
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.background(color = backgroundColor)
) {
// Отображение ошибок
uiState.errorMessage?.let { error ->
Card(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
.fillMaxSize()
.padding(24.dp)
) {
Text(
text = error,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onErrorContainer
// Верхняя панель с кнопкой назад и кнопкой добавления
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Кнопка назад
if (navController != null) {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
Icons.Filled.ArrowBack,
contentDescription = "Назад",
tint = accentGreen,
modifier = Modifier.size(32.dp)
)
}
} else {
Spacer(modifier = Modifier.size(48.dp))
}
// Заголовок
Text(
text = "Задания",
fontSize = (screenHeightDp * 0.04f).toInt().coerceIn(32, 48).sp,
fontWeight = FontWeight.Bold,
color = accentGreen
)
// Кнопка добавления
if (scheduleId != null) {
FloatingActionButton(
onClick = { viewModel.showAddDialog() },
modifier = Modifier.size(56.dp),
containerColor = accentGreen,
contentColor = Color.White
) {
Icon(
Icons.Filled.Add,
contentDescription = "Добавить задачу",
modifier = Modifier.size(28.dp)
)
}
} else {
Spacer(modifier = Modifier.size(56.dp))
}
}
Spacer(modifier = Modifier.height(32.dp))
// Контент
if (uiState.isLoading && uiState.tasks.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
CircularProgressIndicator(color = accentGreen)
}
} else if (uiState.tasks.isEmpty() && !uiState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Нет задач")
Text(
text = "Нет задач",
fontSize = 24.sp,
color = Color.Gray,
fontWeight = FontWeight.Medium
)
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
items(uiState.tasks) { task ->
// Получаем состояние таймера из Flow
val timerStates by viewModel.timerStates.collectAsState()
val timerState = timerStates[task.id]
TaskCard(
task = task,
timerState = timerState,
accentGreen = accentGreen,
dateCardColor = dateCardColor,
screenHeightDp = screenHeightDp,
onStartTimer = { duration ->
viewModel.startTimer(task.id, duration)
},
onPauseTimer = {
viewModel.pauseTimer(task.id)
},
onResumeTimer = {
viewModel.resumeTimer(task.id)
},
onStopTimer = {
viewModel.stopTimer(task.id)
},
onDelete = {
viewModel.deleteTask(task.id)
}
)
}
}
}
}
// Диалог создания задачи/награды
if (uiState.showAddDialog) {
CreateTaskOrRewardDialog(
dialogMode = uiState.dialogMode,
taskTitle = uiState.newTaskTitle,
taskDescription = uiState.newTaskDescription,
taskDuration = uiState.newTaskDuration,
rewardTitle = uiState.newRewardTitle,
rewardDescription = uiState.newRewardDescription,
rewardPoints = uiState.newRewardPoints,
onModeChange = { viewModel.setDialogMode(it) },
onTaskTitleChange = { viewModel.updateNewTaskTitle(it) },
onTaskDescriptionChange = { viewModel.updateNewTaskDescription(it) },
onTaskDurationChange = { viewModel.updateNewTaskDuration(it) },
onRewardTitleChange = { viewModel.updateNewRewardTitle(it) },
onRewardDescriptionChange = { viewModel.updateNewRewardDescription(it) },
onRewardPointsChange = { viewModel.updateNewRewardPoints(it) },
onCreate = {
when (uiState.dialogMode) {
DialogMode.TASK -> {
scheduleId?.let { id ->
viewModel.createTask(id)
}
}
DialogMode.REWARD -> {
viewModel.createReward()
}
}
},
onDismiss = { viewModel.hideAddDialog() },
accentGreen = accentGreen,
screenHeightDp = screenHeightDp,
isLoading = uiState.isLoading,
scheduleId = scheduleId
)
}
// Snackbar для ошибок
uiState.errorMessage?.let { error ->
LaunchedEffect(error) {
// Можно добавить SnackbarHost если нужно
}
}
}
}
@Composable
fun TaskCard(
task: com.novayaplaneta.domain.model.Task,
timerState: TimerState?,
accentGreen: Color,
dateCardColor: Color,
screenHeightDp: Int,
onStartTimer: (Int) -> Unit,
onPauseTimer: () -> Unit,
onResumeTimer: () -> Unit,
onStopTimer: () -> Unit,
onDelete: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = dateCardColor)
) {
Row(
modifier = Modifier
@@ -101,41 +264,180 @@ fun TaskScreen(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = task.title,
style = MaterialTheme.typography.titleMedium
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Color.Black
)
task.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall
fontSize = 16.sp,
color = Color.Black
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
task.duration?.let {
Text(
text = "Длительность: $it мин",
style = MaterialTheme.typography.bodySmall
fontSize = 14.sp,
color = Color.Gray
)
}
if (task.completed) {
Text(
text = "✓ Выполнено",
fontSize = 14.sp,
color = accentGreen,
fontWeight = FontWeight.Medium
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = task.completed,
onCheckedChange = { completed ->
viewModel.completeTask(task.id, completed)
}
)
IconButton(onClick = { viewModel.deleteTask(task.id) }) {
// Таймер или кнопка удаления
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (task.completed) {
// Показываем только иконку выполнения
Icon(
Icons.Default.Delete,
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 = MaterialTheme.colorScheme.error
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.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.repository.AuthRepository
import com.novayaplaneta.domain.usecase.ClaimRewardUseCase
import com.novayaplaneta.domain.usecase.CompleteTaskUseCase
import com.novayaplaneta.domain.usecase.CreateRewardUseCase
import com.novayaplaneta.domain.usecase.CreateTaskUseCase
import com.novayaplaneta.domain.usecase.DeleteTaskUseCase
import com.novayaplaneta.domain.usecase.GetSchedulesUseCase
import com.novayaplaneta.domain.usecase.GetTaskByIdUseCase
import com.novayaplaneta.domain.usecase.GetTasksUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.time.LocalDateTime
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
@@ -21,14 +32,23 @@ class TaskViewModel @Inject constructor(
private val getTasksUseCase: GetTasksUseCase,
private val getTaskByIdUseCase: GetTaskByIdUseCase,
private val createTaskUseCase: CreateTaskUseCase,
private val createRewardUseCase: CreateRewardUseCase,
private val deleteTaskUseCase: DeleteTaskUseCase,
private val completeTaskUseCase: CompleteTaskUseCase
private val completeTaskUseCase: CompleteTaskUseCase,
private val claimRewardUseCase: ClaimRewardUseCase,
private val getSchedulesUseCase: GetSchedulesUseCase,
private val authRepository: AuthRepository,
private val timerStateManager: TimerStateManager
) : ViewModel() {
private val _uiState = MutableStateFlow(TaskUiState())
val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()
private val _timerStates = MutableStateFlow<Map<String, TimerState>>(emptyMap())
val timerStates: StateFlow<Map<String, TimerState>> = _timerStates.asStateFlow()
private var currentScheduleId: String? = null
private val timerJobs = mutableMapOf<String, Job>()
fun loadTasks(scheduleId: String) {
currentScheduleId = scheduleId
@@ -36,6 +56,24 @@ class TaskViewModel @Inject constructor(
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
try {
// Загружаем сохраненные состояния таймеров
val savedStates = timerStateManager.getAllTimerStates()
savedStates.forEach { (taskId, savedState) ->
val timerState = TimerState(
isRunning = savedState.isRunning,
durationMinutes = savedState.durationMinutes,
remainingSeconds = savedState.remainingSeconds
)
val currentStates = _timerStates.value.toMutableMap()
currentStates[taskId] = timerState
_timerStates.value = currentStates
// Перезапускаем таймер если он был запущен
if (savedState.isRunning && savedState.remainingSeconds > 0) {
resumeTimer(taskId)
}
}
val loadResult = getTasksUseCase.loadTasks(scheduleId)
if (loadResult.isFailure) {
_uiState.value = _uiState.value.copy(
@@ -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 {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val task = Task(
id = UUID.randomUUID().toString(),
title = title,
description = _uiState.value.newTaskDescription.takeIf { it.isNotBlank() },
imageUrl = null,
completed = false,
scheduledTime = null,
duration = _uiState.value.newTaskDuration.toIntOrNull(),
scheduleId = scheduleId,
order = _uiState.value.tasks.size,
category = null,
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now()
)
val result = createTaskUseCase(task)
result.onSuccess {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = null
errorMessage = null,
showAddDialog = false,
newTaskTitle = "",
newTaskDescription = "",
newTaskDuration = ""
)
// Список задач обновится автоматически через Flow
}.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) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
@@ -121,6 +273,11 @@ class TaskViewModel @Inject constructor(
fun completeTask(taskId: String, completed: Boolean) {
viewModelScope.launch {
// Останавливаем таймер если задача завершена
if (completed) {
stopTimer(taskId)
}
// Оптимистичное обновление UI
val currentTasks = _uiState.value.tasks.toMutableList()
val taskIndex = currentTasks.indexOfFirst { it.id == taskId }
@@ -131,7 +288,12 @@ class TaskViewModel @Inject constructor(
}
val result = completeTaskUseCase(taskId, completed)
result.onFailure { exception ->
result.onSuccess {
// Проверяем, все ли задачи выполнены, и выдаем награду если нужно
if (completed && currentScheduleId != null) {
checkAndAwardReward(currentScheduleId!!)
}
}.onFailure { exception ->
// Откатываем изменение в случае ошибки
if (taskIndex >= 0) {
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() {
_uiState.value = _uiState.value.copy(errorMessage = null)
}
}
enum class DialogMode {
TASK, REWARD
}
data class TaskUiState(
val tasks: List<Task> = emptyList(),
val selectedTask: Task? = null,
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
)