diff --git a/app/src/main/java/com/novayaplaneta/data/local/NewPlanetDatabase.kt b/app/src/main/java/com/novayaplaneta/data/local/NewPlanetDatabase.kt index 5451abf..e8760d6 100644 --- a/app/src/main/java/com/novayaplaneta/data/local/NewPlanetDatabase.kt +++ b/app/src/main/java/com/novayaplaneta/data/local/NewPlanetDatabase.kt @@ -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() { diff --git a/app/src/main/java/com/novayaplaneta/data/local/entity/ScheduleEntity.kt b/app/src/main/java/com/novayaplaneta/data/local/entity/ScheduleEntity.kt index 470aa63..92d33e1 100644 --- a/app/src/main/java/com/novayaplaneta/data/local/entity/ScheduleEntity.kt +++ b/app/src/main/java/com/novayaplaneta/data/local/entity/ScheduleEntity.kt @@ -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 ) diff --git a/app/src/main/java/com/novayaplaneta/data/local/mapper/ScheduleMapper.kt b/app/src/main/java/com/novayaplaneta/data/local/mapper/ScheduleMapper.kt index 3a8abbd..f25ec7d 100644 --- a/app/src/main/java/com/novayaplaneta/data/local/mapper/ScheduleMapper.kt +++ b/app/src/main/java/com/novayaplaneta/data/local/mapper/ScheduleMapper.kt @@ -15,7 +15,8 @@ fun ScheduleEntity.toDomain(tasks: List = 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 ) } diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/ChatResponse.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/ChatResponse.kt index 6aa5bc1..fe52d4e 100644 --- a/app/src/main/java/com/novayaplaneta/data/remote/dto/ChatResponse.kt +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/ChatResponse.kt @@ -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 ) diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/CreateScheduleRequest.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/CreateScheduleRequest.kt index 480e291..91018b8 100644 --- a/app/src/main/java/com/novayaplaneta/data/remote/dto/CreateScheduleRequest.kt +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/CreateScheduleRequest.kt @@ -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 ) diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/GenerateScheduleResponse.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/GenerateScheduleResponse.kt index 3540881..e72aba7 100644 --- a/app/src/main/java/com/novayaplaneta/data/remote/dto/GenerateScheduleResponse.kt +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/GenerateScheduleResponse.kt @@ -8,6 +8,6 @@ data class GenerateScheduleResponse( val schedule_id: String, val title: String, val tasks: List, - val tokens_used: Int + val tokens_used: Int? = null ) diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/ScheduleDto.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/ScheduleDto.kt index aa2b5a4..b75a451 100644 --- a/app/src/main/java/com/novayaplaneta/data/remote/dto/ScheduleDto.kt +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/ScheduleDto.kt @@ -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 = emptyList() + val tasks: List = emptyList(), + val reward_id: String? = null ) // TaskDto moved to separate file diff --git a/app/src/main/java/com/novayaplaneta/data/remote/mapper/ScheduleMapper.kt b/app/src/main/java/com/novayaplaneta/data/remote/mapper/ScheduleMapper.kt index 7a398a9..6ef5806 100644 --- a/app/src/main/java/com/novayaplaneta/data/remote/mapper/ScheduleMapper.kt +++ b/app/src/main/java/com/novayaplaneta/data/remote/mapper/ScheduleMapper.kt @@ -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 ) } diff --git a/app/src/main/java/com/novayaplaneta/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/novayaplaneta/data/repository/AuthRepositoryImpl.kt index d896356..0765a98 100644 --- a/app/src/main/java/com/novayaplaneta/data/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/novayaplaneta/data/repository/AuthRepositoryImpl.kt @@ -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) diff --git a/app/src/main/java/com/novayaplaneta/di/DatabaseModule.kt b/app/src/main/java/com/novayaplaneta/di/DatabaseModule.kt index bccbb7f..7eac976 100644 --- a/app/src/main/java/com/novayaplaneta/di/DatabaseModule.kt +++ b/app/src/main/java/com/novayaplaneta/di/DatabaseModule.kt @@ -26,7 +26,9 @@ object DatabaseModule { context, NewPlanetDatabase::class.java, "newplanet_database" - ).build() + ) + .fallbackToDestructiveMigration() // Пересоздает БД при изменении схемы (для разработки) + .build() } @Provides diff --git a/app/src/main/java/com/novayaplaneta/domain/model/Schedule.kt b/app/src/main/java/com/novayaplaneta/domain/model/Schedule.kt index 9e832ae..967404d 100644 --- a/app/src/main/java/com/novayaplaneta/domain/model/Schedule.kt +++ b/app/src/main/java/com/novayaplaneta/domain/model/Schedule.kt @@ -9,6 +9,7 @@ data class Schedule( val tasks: List, val date: LocalDateTime, val createdAt: LocalDateTime, - val userId: String + val userId: String, + val rewardId: String? = null ) diff --git a/app/src/main/java/com/novayaplaneta/ui/navigation/NewPlanetNavigation.kt b/app/src/main/java/com/novayaplaneta/ui/navigation/NewPlanetNavigation.kt index 6d88cff..de69f37 100644 --- a/app/src/main/java/com/novayaplaneta/ui/navigation/NewPlanetNavigation.kt +++ b/app/src/main/java/com/novayaplaneta/ui/navigation/NewPlanetNavigation.kt @@ -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() diff --git a/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIScreen.kt b/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIScreen.kt index d7d9854..802b980 100644 --- a/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIScreen.kt +++ b/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIScreen.kt @@ -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, diff --git a/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIViewModel.kt b/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIViewModel.kt index 47de5a6..81dbfbe 100644 --- a/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIViewModel.kt +++ b/app/src/main/java/com/novayaplaneta/ui/screens/ai/AIViewModel.kt @@ -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, diff --git a/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleScreen.kt b/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleScreen.kt index f67dd63..e8d3287 100644 --- a/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleScreen.kt +++ b/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleScreen.kt @@ -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, + availableRewards: List, 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,25 +492,60 @@ fun ScheduleCard( ) } - if (schedule.tasks.isNotEmpty()) { - Text( - text = "Задач: ${schedule.tasks.size}", - fontSize = 14.sp, - color = Color.Gray - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + if (schedule.tasks.isNotEmpty()) { + Text( + text = "Задач: ${schedule.tasks.size}", + fontSize = 14.sp, + color = Color.Gray + ) + } + + // Отображение награды + reward?.let { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFFFFD700).copy(alpha = 0.3f)) // Золотистый цвет для награды + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = "Награда", + tint = Color(0xFFFFB300), // Не сильно оранжевый, золотистый + modifier = Modifier.size(20.dp) + ) + Text( + text = it.title, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFFFF8C00) // Не сильно оранжевый + ) + } + } } } } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun CreateScheduleDialog( title: String, description: String, date: java.time.LocalDate?, + selectedRewardId: String?, + availableRewards: List, 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(), diff --git a/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleViewModel.kt b/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleViewModel.kt index fc70c8c..5d2c25d 100644 --- a/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/novayaplaneta/ui/screens/schedule/ScheduleViewModel.kt @@ -2,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 = emptyList(), val showGenerateDialog: Boolean = false, val generateChildAge: Int = 5, val generatePreferences: List = emptyList(), diff --git a/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskScreen.kt b/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskScreen.kt index e6ab87f..1efd851 100644 --- a/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskScreen.kt +++ b/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskScreen.kt @@ -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,116 +61,383 @@ 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) { scheduleId?.let { viewModel.loadTasks(it) } } - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Задания") }, - actions = { - if (scheduleId != null) { - IconButton(onClick = { /* TODO: Show create task dialog */ }) { - Icon(Icons.Default.Add, contentDescription = "Добавить задачу") - } - } - } - ) - } - ) { paddingValues -> + + Box( + modifier = modifier + .fillMaxSize() + .background(color = backgroundColor) + ) { Column( - modifier = modifier + modifier = Modifier .fillMaxSize() - .padding(paddingValues) - .padding(16.dp) + .padding(24.dp) ) { - // Отображение ошибок - uiState.errorMessage?.let { error -> - Card( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ) - ) { - Text( - text = error, - modifier = Modifier.padding(16.dp), - color = MaterialTheme.colorScheme.onErrorContainer - ) + // Верхняя панель с кнопкой назад и кнопкой добавления + 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 -> - Card( - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = task.title, - style = MaterialTheme.typography.titleMedium - ) - task.description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall - ) - } - task.duration?.let { - Text( - text = "Длительность: $it мин", - style = MaterialTheme.typography.bodySmall - ) - } - } - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = task.completed, - onCheckedChange = { completed -> - viewModel.completeTask(task.id, completed) - } - ) - IconButton(onClick = { viewModel.deleteTask(task.id) }) { - Icon( - Icons.Default.Delete, - contentDescription = "Удалить", - tint = MaterialTheme.colorScheme.error - ) - } - } + // Получаем состояние таймера из 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(), + 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 + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskViewModel.kt b/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskViewModel.kt index 3760d3a..c1786fa 100644 --- a/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskViewModel.kt +++ b/app/src/main/java/com/novayaplaneta/ui/screens/task/TaskViewModel.kt @@ -2,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 = _uiState.asStateFlow() + private val _timerStates = MutableStateFlow>(emptyMap()) + val timerStates: StateFlow> = _timerStates.asStateFlow() + private var currentScheduleId: String? = null + private val timerJobs = mutableMapOf() 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 = 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 )