фикс багов, интеграция с бэком+агентами #1
@@ -21,7 +21,7 @@ import com.novayaplaneta.data.local.entity.UserEntity
|
|||||||
RewardEntity::class,
|
RewardEntity::class,
|
||||||
ChatMessageEntity::class
|
ChatMessageEntity::class
|
||||||
],
|
],
|
||||||
version = 2,
|
version = 4,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class NewPlanetDatabase : RoomDatabase() {
|
abstract class NewPlanetDatabase : RoomDatabase() {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ data class ScheduleEntity(
|
|||||||
val description: String?,
|
val description: String?,
|
||||||
val date: Long, // timestamp
|
val date: Long, // timestamp
|
||||||
val createdAt: Long, // timestamp
|
val createdAt: Long, // timestamp
|
||||||
val userId: String
|
val userId: String,
|
||||||
|
val rewardId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ fun ScheduleEntity.toDomain(tasks: List<Task> = emptyList()): Schedule {
|
|||||||
tasks = tasks,
|
tasks = tasks,
|
||||||
date = LocalDateTime.ofInstant(Instant.ofEpochMilli(date), ZoneId.systemDefault()),
|
date = LocalDateTime.ofInstant(Instant.ofEpochMilli(date), ZoneId.systemDefault()),
|
||||||
createdAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(createdAt), ZoneId.systemDefault()),
|
createdAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(createdAt), ZoneId.systemDefault()),
|
||||||
userId = userId
|
userId = userId,
|
||||||
|
rewardId = rewardId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +27,8 @@ fun Schedule.toEntity(): ScheduleEntity {
|
|||||||
description = description,
|
description = description,
|
||||||
date = date.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(),
|
date = date.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(),
|
||||||
createdAt = createdAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(),
|
createdAt = createdAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(),
|
||||||
userId = userId
|
userId = userId,
|
||||||
|
rewardId = rewardId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
|
|||||||
data class ChatResponse(
|
data class ChatResponse(
|
||||||
val response: String,
|
val response: String,
|
||||||
val conversation_id: String,
|
val conversation_id: String,
|
||||||
val tokens_used: Int,
|
val tokens_used: Int? = null,
|
||||||
val model: String
|
val model: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable
|
|||||||
data class CreateScheduleRequest(
|
data class CreateScheduleRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
val date: String, // yyyy-MM-dd
|
val date: String, // yyyy-MM-dd
|
||||||
val description: String
|
val description: String,
|
||||||
|
val reward_id: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ data class GenerateScheduleResponse(
|
|||||||
val schedule_id: String,
|
val schedule_id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val tasks: List<JsonObject>,
|
val tasks: List<JsonObject>,
|
||||||
val tokens_used: Int
|
val tokens_used: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ data class ScheduleDto(
|
|||||||
val user_id: String,
|
val user_id: String,
|
||||||
val created_at: String, // ISO date
|
val created_at: String, // ISO date
|
||||||
val updated_at: String, // ISO date
|
val updated_at: String, // ISO date
|
||||||
val tasks: List<TaskDto> = emptyList()
|
val tasks: List<TaskDto> = emptyList(),
|
||||||
|
val reward_id: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
// TaskDto moved to separate file
|
// TaskDto moved to separate file
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ fun ScheduleDto.toDomain(): Schedule {
|
|||||||
tasks = tasks.map { it.toDomain() },
|
tasks = tasks.map { it.toDomain() },
|
||||||
date = dateLocalDateTime,
|
date = dateLocalDateTime,
|
||||||
createdAt = createdAtDateTime,
|
createdAt = createdAtDateTime,
|
||||||
userId = user_id
|
userId = user_id,
|
||||||
|
rewardId = reward_id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +45,8 @@ fun Schedule.toCreateRequest(): com.novayaplaneta.data.remote.dto.CreateSchedule
|
|||||||
return com.novayaplaneta.data.remote.dto.CreateScheduleRequest(
|
return com.novayaplaneta.data.remote.dto.CreateScheduleRequest(
|
||||||
title = title,
|
title = title,
|
||||||
date = dateString,
|
date = dateString,
|
||||||
description = description ?: ""
|
description = description ?: "",
|
||||||
|
reward_id = rewardId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ class AuthRepositoryImpl @Inject constructor(
|
|||||||
// Сохраняем токены
|
// Сохраняем токены
|
||||||
tokenManager.saveTokens(tokenResponse.access_token, tokenResponse.refresh_token)
|
tokenManager.saveTokens(tokenResponse.access_token, tokenResponse.refresh_token)
|
||||||
|
|
||||||
|
// Убеждаемся, что токен действительно сохранен перед вызовом getMe()
|
||||||
|
// Это гарантирует, что DataStore завершил сохранение
|
||||||
|
val savedToken = tokenManager.getAccessToken()
|
||||||
|
if (savedToken == null) {
|
||||||
|
return Result.failure(Exception("Failed to save access token"))
|
||||||
|
}
|
||||||
|
|
||||||
// Получаем информацию о пользователе
|
// Получаем информацию о пользователе
|
||||||
val meResponse = authApi.getMe()
|
val meResponse = authApi.getMe()
|
||||||
if (meResponse.isSuccessful && meResponse.body() != null) {
|
if (meResponse.isSuccessful && meResponse.body() != null) {
|
||||||
@@ -73,11 +80,14 @@ class AuthRepositoryImpl @Inject constructor(
|
|||||||
saveUser(user)
|
saveUser(user)
|
||||||
Result.success(user)
|
Result.success(user)
|
||||||
} else {
|
} else {
|
||||||
Result.failure(Exception("Failed to get user info"))
|
val errorCode = meResponse.code()
|
||||||
|
val errorBody = meResponse.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Failed to get user info: HTTP $errorCode - $errorBody"))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val errorBody = response.errorBody()?.string() ?: "Login failed"
|
val errorCode = response.code()
|
||||||
Result.failure(Exception(errorBody))
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
Result.failure(Exception("Login failed: HTTP $errorCode - $errorBody"))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ object DatabaseModule {
|
|||||||
context,
|
context,
|
||||||
NewPlanetDatabase::class.java,
|
NewPlanetDatabase::class.java,
|
||||||
"newplanet_database"
|
"newplanet_database"
|
||||||
).build()
|
)
|
||||||
|
.fallbackToDestructiveMigration() // Пересоздает БД при изменении схемы (для разработки)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ data class Schedule(
|
|||||||
val tasks: List<Task>,
|
val tasks: List<Task>,
|
||||||
val date: LocalDateTime,
|
val date: LocalDateTime,
|
||||||
val createdAt: LocalDateTime,
|
val createdAt: LocalDateTime,
|
||||||
val userId: String
|
val userId: String,
|
||||||
|
val rewardId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package com.novayaplaneta.ui.navigation
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.navArgument
|
||||||
import com.novayaplaneta.ui.screens.ai.AIScreen
|
import com.novayaplaneta.ui.screens.ai.AIScreen
|
||||||
import com.novayaplaneta.ui.screens.auth.ForgotPasswordScreen
|
import com.novayaplaneta.ui.screens.auth.ForgotPasswordScreen
|
||||||
import com.novayaplaneta.ui.screens.auth.LoginScreen
|
import com.novayaplaneta.ui.screens.auth.LoginScreen
|
||||||
@@ -42,8 +44,17 @@ fun NewPlanetNavigation(
|
|||||||
composable("schedule") {
|
composable("schedule") {
|
||||||
ScheduleScreen(navController = navController)
|
ScheduleScreen(navController = navController)
|
||||||
}
|
}
|
||||||
composable("tasks") {
|
composable(
|
||||||
TaskScreen()
|
route = "tasks/{scheduleId}",
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("scheduleId") { type = NavType.StringType }
|
||||||
|
)
|
||||||
|
) { backStackEntry ->
|
||||||
|
val scheduleId = backStackEntry.arguments?.getString("scheduleId")
|
||||||
|
TaskScreen(
|
||||||
|
scheduleId = scheduleId,
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable("timer") {
|
composable("timer") {
|
||||||
TimerScreen()
|
TimerScreen()
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ fun AIScreen(
|
|||||||
// Заголовок чата
|
// Заголовок чата
|
||||||
val titleSize = (screenHeightDp * 0.04f).toInt().coerceIn(32, 52).sp
|
val titleSize = (screenHeightDp * 0.04f).toInt().coerceIn(32, 52).sp
|
||||||
Text(
|
Text(
|
||||||
text = "Чат с Землей",
|
text = "Помощник Земля",
|
||||||
fontSize = titleSize,
|
fontSize = titleSize,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = Color.Black,
|
color = Color.Black,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class AIViewModel @Inject constructor(
|
|||||||
|
|
||||||
private var conversationId: String? = null
|
private var conversationId: String? = null
|
||||||
private var currentUserId: String? = null
|
private var currentUserId: String? = null
|
||||||
|
private var sendMessageJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadUserId()
|
loadUserId()
|
||||||
@@ -59,7 +60,13 @@ class AIViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun sendMessage(message: String) {
|
fun sendMessage(message: String) {
|
||||||
val userId = currentUserId ?: return
|
val userId = currentUserId ?: return
|
||||||
viewModelScope.launch {
|
|
||||||
|
// Предотвращаем повторные отправки
|
||||||
|
if (_uiState.value.isLoading || sendMessageJob?.isActive == true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessageJob = viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||||
|
|
||||||
aiRepository.sendMessage(userId, message, conversationId).fold(
|
aiRepository.sendMessage(userId, message, conversationId).fold(
|
||||||
@@ -70,6 +77,34 @@ class AIViewModel @Inject constructor(
|
|||||||
loadChatHistory(userId)
|
loadChatHistory(userId)
|
||||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
},
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
val errorMessage = error.message ?: "Ошибка отправки сообщения"
|
||||||
|
|
||||||
|
// Если ошибка связана с дубликатом conversation_id, сбрасываем его
|
||||||
|
if (errorMessage.contains("duplicate key", ignoreCase = true) ||
|
||||||
|
errorMessage.contains("conversation_id", ignoreCase = true)) {
|
||||||
|
conversationId = null
|
||||||
|
// Пытаемся отправить сообщение снова без conversation_id
|
||||||
|
retrySendMessage(userId, message)
|
||||||
|
} else {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = errorMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun retrySendMessage(userId: String, message: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
aiRepository.sendMessage(userId, message, null).fold(
|
||||||
|
onSuccess = { response ->
|
||||||
|
conversationId = response.conversationId
|
||||||
|
loadChatHistory(userId)
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
},
|
||||||
onFailure = { error ->
|
onFailure = { error ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.novayaplaneta.ui.screens.schedule
|
package com.novayaplaneta.ui.screens.schedule
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -21,6 +22,9 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -212,6 +216,7 @@ fun ScheduleScreen(
|
|||||||
} else {
|
} else {
|
||||||
SchedulesListSection(
|
SchedulesListSection(
|
||||||
schedules = uiState.schedules,
|
schedules = uiState.schedules,
|
||||||
|
availableRewards = uiState.availableRewards,
|
||||||
dateOnly = dateOnly,
|
dateOnly = dateOnly,
|
||||||
dayOfWeek = dayOfWeek,
|
dayOfWeek = dayOfWeek,
|
||||||
dateCardColor = dateCardColor,
|
dateCardColor = dateCardColor,
|
||||||
@@ -220,7 +225,7 @@ fun ScheduleScreen(
|
|||||||
onAddClick = { viewModel.showAddDialog() },
|
onAddClick = { viewModel.showAddDialog() },
|
||||||
onGenerateClick = { viewModel.showGenerateDialog() },
|
onGenerateClick = { viewModel.showGenerateDialog() },
|
||||||
onScheduleClick = { scheduleId ->
|
onScheduleClick = { scheduleId ->
|
||||||
// TODO: Navigate to schedule details
|
navController?.navigate("tasks/$scheduleId")
|
||||||
},
|
},
|
||||||
onDeleteClick = { scheduleId ->
|
onDeleteClick = { scheduleId ->
|
||||||
viewModel.deleteSchedule(scheduleId)
|
viewModel.deleteSchedule(scheduleId)
|
||||||
@@ -236,9 +241,12 @@ fun ScheduleScreen(
|
|||||||
title = uiState.newScheduleTitle,
|
title = uiState.newScheduleTitle,
|
||||||
description = uiState.newScheduleDescription,
|
description = uiState.newScheduleDescription,
|
||||||
date = uiState.newScheduleDate,
|
date = uiState.newScheduleDate,
|
||||||
|
selectedRewardId = uiState.selectedRewardId,
|
||||||
|
availableRewards = uiState.availableRewards,
|
||||||
onTitleChange = { viewModel.updateNewScheduleTitle(it) },
|
onTitleChange = { viewModel.updateNewScheduleTitle(it) },
|
||||||
onDescriptionChange = { viewModel.updateNewScheduleDescription(it) },
|
onDescriptionChange = { viewModel.updateNewScheduleDescription(it) },
|
||||||
onDateChange = { viewModel.updateNewScheduleDate(it) },
|
onDateChange = { viewModel.updateNewScheduleDate(it) },
|
||||||
|
onRewardSelected = { viewModel.updateSelectedReward(it) },
|
||||||
onCreate = { viewModel.createScheduleFromDialog() },
|
onCreate = { viewModel.createScheduleFromDialog() },
|
||||||
onDismiss = { viewModel.hideAddDialog() },
|
onDismiss = { viewModel.hideAddDialog() },
|
||||||
accentGreen = accentGreen,
|
accentGreen = accentGreen,
|
||||||
@@ -308,6 +316,7 @@ fun NavItem(
|
|||||||
@Composable
|
@Composable
|
||||||
fun SchedulesListSection(
|
fun SchedulesListSection(
|
||||||
schedules: List<com.novayaplaneta.domain.model.Schedule>,
|
schedules: List<com.novayaplaneta.domain.model.Schedule>,
|
||||||
|
availableRewards: List<com.novayaplaneta.domain.model.Reward>,
|
||||||
dateOnly: String,
|
dateOnly: String,
|
||||||
dayOfWeek: String,
|
dayOfWeek: String,
|
||||||
dateCardColor: Color,
|
dateCardColor: Color,
|
||||||
@@ -403,8 +412,12 @@ fun SchedulesListSection(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
items(schedules) { schedule ->
|
items(schedules) { schedule ->
|
||||||
|
val reward = schedule.rewardId?.let { rewardId ->
|
||||||
|
availableRewards.find { it.id == rewardId }
|
||||||
|
}
|
||||||
ScheduleCard(
|
ScheduleCard(
|
||||||
schedule = schedule,
|
schedule = schedule,
|
||||||
|
reward = reward,
|
||||||
screenHeightDp = screenHeightDp,
|
screenHeightDp = screenHeightDp,
|
||||||
accentGreen = accentGreen,
|
accentGreen = accentGreen,
|
||||||
dateCardColor = dateCardColor,
|
dateCardColor = dateCardColor,
|
||||||
@@ -420,6 +433,7 @@ fun SchedulesListSection(
|
|||||||
@Composable
|
@Composable
|
||||||
fun ScheduleCard(
|
fun ScheduleCard(
|
||||||
schedule: com.novayaplaneta.domain.model.Schedule,
|
schedule: com.novayaplaneta.domain.model.Schedule,
|
||||||
|
reward: com.novayaplaneta.domain.model.Reward?,
|
||||||
screenHeightDp: Int,
|
screenHeightDp: Int,
|
||||||
accentGreen: Color,
|
accentGreen: Color,
|
||||||
dateCardColor: Color,
|
dateCardColor: Color,
|
||||||
@@ -478,25 +492,60 @@ fun ScheduleCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (schedule.tasks.isNotEmpty()) {
|
Row(
|
||||||
Text(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
text = "Задач: ${schedule.tasks.size}",
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
fontSize = 14.sp,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
color = Color.Gray
|
) {
|
||||||
)
|
if (schedule.tasks.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Задач: ${schedule.tasks.size}",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображение награды
|
||||||
|
reward?.let {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color(0xFFFFD700).copy(alpha = 0.3f)) // Золотистый цвет для награды
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Star,
|
||||||
|
contentDescription = "Награда",
|
||||||
|
tint = Color(0xFFFFB300), // Не сильно оранжевый, золотистый
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = it.title,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFFFF8C00) // Не сильно оранжевый
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CreateScheduleDialog(
|
fun CreateScheduleDialog(
|
||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
date: java.time.LocalDate?,
|
date: java.time.LocalDate?,
|
||||||
|
selectedRewardId: String?,
|
||||||
|
availableRewards: List<com.novayaplaneta.domain.model.Reward>,
|
||||||
onTitleChange: (String) -> Unit,
|
onTitleChange: (String) -> Unit,
|
||||||
onDescriptionChange: (String) -> Unit,
|
onDescriptionChange: (String) -> Unit,
|
||||||
onDateChange: (java.time.LocalDate) -> Unit,
|
onDateChange: (java.time.LocalDate) -> Unit,
|
||||||
|
onRewardSelected: (String?) -> Unit,
|
||||||
onCreate: () -> Unit,
|
onCreate: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
accentGreen: Color,
|
accentGreen: Color,
|
||||||
@@ -548,6 +597,76 @@ fun CreateScheduleDialog(
|
|||||||
enabled = !isLoading
|
enabled = !isLoading
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Выбор награды
|
||||||
|
if (availableRewards.isNotEmpty()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Награда (опционально)",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
|
||||||
|
// Выпадающий список наград
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = !expanded },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = availableRewards.find { it.id == selectedRewardId }?.title ?: "Не выбрано",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor()
|
||||||
|
.fillMaxWidth(),
|
||||||
|
enabled = !isLoading,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
focusedLabelColor = accentGreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
// Опция "Не выбрано"
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Не выбрано") },
|
||||||
|
onClick = {
|
||||||
|
onRewardSelected(null)
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
availableRewards.forEach { reward ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(reward.title, fontWeight = FontWeight.Bold)
|
||||||
|
reward.description?.let {
|
||||||
|
Text(it, fontSize = 12.sp, color = Color.Gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onRewardSelected(reward.id)
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Кнопки внизу
|
// Кнопки внизу
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package com.novayaplaneta.ui.screens.schedule
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.novayaplaneta.domain.model.Reward
|
||||||
import com.novayaplaneta.domain.model.Schedule
|
import com.novayaplaneta.domain.model.Schedule
|
||||||
import com.novayaplaneta.domain.repository.AuthRepository
|
import com.novayaplaneta.domain.repository.AuthRepository
|
||||||
import com.novayaplaneta.domain.usecase.CreateScheduleUseCase
|
import com.novayaplaneta.domain.usecase.CreateScheduleUseCase
|
||||||
import com.novayaplaneta.domain.usecase.DeleteScheduleUseCase
|
import com.novayaplaneta.domain.usecase.DeleteScheduleUseCase
|
||||||
import com.novayaplaneta.domain.usecase.GenerateScheduleUseCase
|
import com.novayaplaneta.domain.usecase.GenerateScheduleUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.GetRewardsUseCase
|
||||||
import com.novayaplaneta.domain.usecase.GetSchedulesUseCase
|
import com.novayaplaneta.domain.usecase.GetSchedulesUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -29,6 +31,7 @@ class ScheduleViewModel @Inject constructor(
|
|||||||
private val createScheduleUseCase: CreateScheduleUseCase,
|
private val createScheduleUseCase: CreateScheduleUseCase,
|
||||||
private val deleteScheduleUseCase: DeleteScheduleUseCase,
|
private val deleteScheduleUseCase: DeleteScheduleUseCase,
|
||||||
private val generateScheduleUseCase: GenerateScheduleUseCase,
|
private val generateScheduleUseCase: GenerateScheduleUseCase,
|
||||||
|
private val getRewardsUseCase: GetRewardsUseCase,
|
||||||
private val authRepository: AuthRepository
|
private val authRepository: AuthRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -37,6 +40,25 @@ class ScheduleViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
loadSchedules()
|
loadSchedules()
|
||||||
|
loadRewards()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadRewards() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val user = authRepository.getCurrentUser().first()
|
||||||
|
val userId = user?.id ?: return@launch
|
||||||
|
|
||||||
|
val loadResult = getRewardsUseCase.loadRewards()
|
||||||
|
if (loadResult.isSuccess) {
|
||||||
|
getRewardsUseCase(userId).collect { rewards ->
|
||||||
|
_uiState.value = _uiState.value.copy(availableRewards = rewards)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Игнорируем ошибки загрузки наград
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadSchedules(scheduleDate: String? = null) {
|
fun loadSchedules(scheduleDate: String? = null) {
|
||||||
@@ -90,7 +112,8 @@ class ScheduleViewModel @Inject constructor(
|
|||||||
showAddDialog = true,
|
showAddDialog = true,
|
||||||
newScheduleTitle = "",
|
newScheduleTitle = "",
|
||||||
newScheduleDescription = "",
|
newScheduleDescription = "",
|
||||||
newScheduleDate = LocalDate.now()
|
newScheduleDate = LocalDate.now(),
|
||||||
|
selectedRewardId = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +122,15 @@ class ScheduleViewModel @Inject constructor(
|
|||||||
showAddDialog = false,
|
showAddDialog = false,
|
||||||
newScheduleTitle = "",
|
newScheduleTitle = "",
|
||||||
newScheduleDescription = "",
|
newScheduleDescription = "",
|
||||||
newScheduleDate = null
|
newScheduleDate = null,
|
||||||
|
selectedRewardId = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateSelectedReward(rewardId: String?) {
|
||||||
|
_uiState.value = _uiState.value.copy(selectedRewardId = rewardId)
|
||||||
|
}
|
||||||
|
|
||||||
fun updateNewScheduleTitle(title: String) {
|
fun updateNewScheduleTitle(title: String) {
|
||||||
_uiState.value = _uiState.value.copy(newScheduleTitle = title)
|
_uiState.value = _uiState.value.copy(newScheduleTitle = title)
|
||||||
}
|
}
|
||||||
@@ -121,12 +149,13 @@ class ScheduleViewModel @Inject constructor(
|
|||||||
createSchedule(
|
createSchedule(
|
||||||
title = state.newScheduleTitle,
|
title = state.newScheduleTitle,
|
||||||
description = state.newScheduleDescription,
|
description = state.newScheduleDescription,
|
||||||
date = state.newScheduleDate
|
date = state.newScheduleDate,
|
||||||
|
rewardId = state.selectedRewardId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createSchedule(title: String, description: String, date: LocalDate) {
|
fun createSchedule(title: String, description: String, date: LocalDate, rewardId: String? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
@@ -141,7 +170,8 @@ class ScheduleViewModel @Inject constructor(
|
|||||||
tasks = emptyList(),
|
tasks = emptyList(),
|
||||||
date = date.atStartOfDay(),
|
date = date.atStartOfDay(),
|
||||||
createdAt = java.time.LocalDateTime.now(),
|
createdAt = java.time.LocalDateTime.now(),
|
||||||
userId = userId
|
userId = userId,
|
||||||
|
rewardId = rewardId
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = createScheduleUseCase(schedule)
|
val result = createScheduleUseCase(schedule)
|
||||||
@@ -261,6 +291,8 @@ data class ScheduleUiState(
|
|||||||
val newScheduleTitle: String = "",
|
val newScheduleTitle: String = "",
|
||||||
val newScheduleDescription: String = "",
|
val newScheduleDescription: String = "",
|
||||||
val newScheduleDate: LocalDate? = null,
|
val newScheduleDate: LocalDate? = null,
|
||||||
|
val selectedRewardId: String? = null,
|
||||||
|
val availableRewards: List<Reward> = emptyList(),
|
||||||
val showGenerateDialog: Boolean = false,
|
val showGenerateDialog: Boolean = false,
|
||||||
val generateChildAge: Int = 5,
|
val generateChildAge: Int = 5,
|
||||||
val generatePreferences: List<String> = emptyList(),
|
val generatePreferences: List<String> = emptyList(),
|
||||||
|
|||||||
@@ -1,23 +1,58 @@
|
|||||||
package com.novayaplaneta.ui.screens.task
|
package com.novayaplaneta.ui.screens.task
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import com.novayaplaneta.ui.theme.LoginBackgroundTurquoise
|
||||||
|
import com.novayaplaneta.ui.theme.LoginGreenAccent
|
||||||
|
import com.novayaplaneta.ui.theme.LoginInputLightBlue
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TaskScreen(
|
fun TaskScreen(
|
||||||
scheduleId: String? = null,
|
scheduleId: String? = null,
|
||||||
@@ -26,6 +61,13 @@ fun TaskScreen(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val screenHeightDp = configuration.screenHeightDp
|
||||||
|
|
||||||
|
// Цвета из autism-friendly палитры
|
||||||
|
val backgroundColor = LoginBackgroundTurquoise
|
||||||
|
val accentGreen = LoginGreenAccent
|
||||||
|
val dateCardColor = LoginInputLightBlue
|
||||||
|
|
||||||
// Загружаем задачи при открытии экрана
|
// Загружаем задачи при открытии экрана
|
||||||
LaunchedEffect(scheduleId) {
|
LaunchedEffect(scheduleId) {
|
||||||
@@ -34,108 +76,368 @@ fun TaskScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Box(
|
||||||
topBar = {
|
modifier = modifier
|
||||||
TopAppBar(
|
.fillMaxSize()
|
||||||
title = { Text("Задания") },
|
.background(color = backgroundColor)
|
||||||
actions = {
|
) {
|
||||||
if (scheduleId != null) {
|
|
||||||
IconButton(onClick = { /* TODO: Show create task dialog */ }) {
|
|
||||||
Icon(Icons.Default.Add, contentDescription = "Добавить задачу")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { paddingValues ->
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(24.dp)
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
) {
|
||||||
// Отображение ошибок
|
// Верхняя панель с кнопкой назад и кнопкой добавления
|
||||||
uiState.errorMessage?.let { error ->
|
Row(
|
||||||
Card(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
.fillMaxWidth()
|
verticalAlignment = Alignment.CenterVertically
|
||||||
.padding(bottom = 16.dp),
|
) {
|
||||||
colors = CardDefaults.cardColors(
|
// Кнопка назад
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
if (navController != null) {
|
||||||
)
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
) {
|
Icon(
|
||||||
Text(
|
Icons.Filled.ArrowBack,
|
||||||
text = error,
|
contentDescription = "Назад",
|
||||||
modifier = Modifier.padding(16.dp),
|
tint = accentGreen,
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer
|
modifier = Modifier.size(32.dp)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.size(48.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заголовок
|
||||||
|
Text(
|
||||||
|
text = "Задания",
|
||||||
|
fontSize = (screenHeightDp * 0.04f).toInt().coerceIn(32, 48).sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = accentGreen
|
||||||
|
)
|
||||||
|
|
||||||
|
// Кнопка добавления
|
||||||
|
if (scheduleId != null) {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { viewModel.showAddDialog() },
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
containerColor = accentGreen,
|
||||||
|
contentColor = Color.White
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Add,
|
||||||
|
contentDescription = "Добавить задачу",
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.size(56.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// Контент
|
||||||
if (uiState.isLoading && uiState.tasks.isEmpty()) {
|
if (uiState.isLoading && uiState.tasks.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator(color = accentGreen)
|
||||||
}
|
}
|
||||||
} else if (uiState.tasks.isEmpty() && !uiState.isLoading) {
|
} else if (uiState.tasks.isEmpty() && !uiState.isLoading) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text("Нет задач")
|
Text(
|
||||||
|
text = "Нет задач",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
color = Color.Gray,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
items(uiState.tasks) { task ->
|
items(uiState.tasks) { task ->
|
||||||
Card(
|
// Получаем состояние таймера из Flow
|
||||||
modifier = Modifier.fillMaxWidth()
|
val timerStates by viewModel.timerStates.collectAsState()
|
||||||
) {
|
val timerState = timerStates[task.id]
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
TaskCard(
|
||||||
.fillMaxWidth()
|
task = task,
|
||||||
.padding(16.dp),
|
timerState = timerState,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
accentGreen = accentGreen,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
dateCardColor = dateCardColor,
|
||||||
) {
|
screenHeightDp = screenHeightDp,
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
onStartTimer = { duration ->
|
||||||
Text(
|
viewModel.startTimer(task.id, duration)
|
||||||
text = task.title,
|
},
|
||||||
style = MaterialTheme.typography.titleMedium
|
onPauseTimer = {
|
||||||
)
|
viewModel.pauseTimer(task.id)
|
||||||
task.description?.let {
|
},
|
||||||
Text(
|
onResumeTimer = {
|
||||||
text = it,
|
viewModel.resumeTimer(task.id)
|
||||||
style = MaterialTheme.typography.bodySmall
|
},
|
||||||
)
|
onStopTimer = {
|
||||||
}
|
viewModel.stopTimer(task.id)
|
||||||
task.duration?.let {
|
},
|
||||||
Text(
|
onDelete = {
|
||||||
text = "Длительность: $it мин",
|
viewModel.deleteTask(task.id)
|
||||||
style = MaterialTheme.typography.bodySmall
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
}
|
||||||
Checkbox(
|
}
|
||||||
checked = task.completed,
|
|
||||||
onCheckedChange = { completed ->
|
// Диалог создания задачи/награды
|
||||||
viewModel.completeTask(task.id, completed)
|
if (uiState.showAddDialog) {
|
||||||
}
|
CreateTaskOrRewardDialog(
|
||||||
)
|
dialogMode = uiState.dialogMode,
|
||||||
IconButton(onClick = { viewModel.deleteTask(task.id) }) {
|
taskTitle = uiState.newTaskTitle,
|
||||||
Icon(
|
taskDescription = uiState.newTaskDescription,
|
||||||
Icons.Default.Delete,
|
taskDuration = uiState.newTaskDuration,
|
||||||
contentDescription = "Удалить",
|
rewardTitle = uiState.newRewardTitle,
|
||||||
tint = MaterialTheme.colorScheme.error
|
rewardDescription = uiState.newRewardDescription,
|
||||||
)
|
rewardPoints = uiState.newRewardPoints,
|
||||||
}
|
onModeChange = { viewModel.setDialogMode(it) },
|
||||||
}
|
onTaskTitleChange = { viewModel.updateNewTaskTitle(it) },
|
||||||
|
onTaskDescriptionChange = { viewModel.updateNewTaskDescription(it) },
|
||||||
|
onTaskDurationChange = { viewModel.updateNewTaskDuration(it) },
|
||||||
|
onRewardTitleChange = { viewModel.updateNewRewardTitle(it) },
|
||||||
|
onRewardDescriptionChange = { viewModel.updateNewRewardDescription(it) },
|
||||||
|
onRewardPointsChange = { viewModel.updateNewRewardPoints(it) },
|
||||||
|
onCreate = {
|
||||||
|
when (uiState.dialogMode) {
|
||||||
|
DialogMode.TASK -> {
|
||||||
|
scheduleId?.let { id ->
|
||||||
|
viewModel.createTask(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DialogMode.REWARD -> {
|
||||||
|
viewModel.createReward()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { viewModel.hideAddDialog() },
|
||||||
|
accentGreen = accentGreen,
|
||||||
|
screenHeightDp = screenHeightDp,
|
||||||
|
isLoading = uiState.isLoading,
|
||||||
|
scheduleId = scheduleId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snackbar для ошибок
|
||||||
|
uiState.errorMessage?.let { error ->
|
||||||
|
LaunchedEffect(error) {
|
||||||
|
// Можно добавить SnackbarHost если нужно
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TaskCard(
|
||||||
|
task: com.novayaplaneta.domain.model.Task,
|
||||||
|
timerState: TimerState?,
|
||||||
|
accentGreen: Color,
|
||||||
|
dateCardColor: Color,
|
||||||
|
screenHeightDp: Int,
|
||||||
|
onStartTimer: (Int) -> Unit,
|
||||||
|
onPauseTimer: () -> Unit,
|
||||||
|
onResumeTimer: () -> Unit,
|
||||||
|
onStopTimer: () -> Unit,
|
||||||
|
onDelete: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = dateCardColor)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = task.title,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
|
||||||
|
task.description?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
task.duration?.let {
|
||||||
|
Text(
|
||||||
|
text = "Длительность: $it мин",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.completed) {
|
||||||
|
Text(
|
||||||
|
text = "✓ Выполнено",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = accentGreen,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Таймер или кнопка удаления
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
if (task.completed) {
|
||||||
|
// Показываем только иконку выполнения
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = "Выполнено",
|
||||||
|
tint = accentGreen,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
} else if (timerState != null) {
|
||||||
|
// Показываем визуальный таймер
|
||||||
|
TaskTimer(
|
||||||
|
timerState = timerState,
|
||||||
|
accentGreen = accentGreen,
|
||||||
|
onPause = onPauseTimer,
|
||||||
|
onResume = onResumeTimer,
|
||||||
|
onStop = onStopTimer
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Показываем кнопку запуска таймера
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
task.duration?.let { onStartTimer(it) }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = "Запустить таймер",
|
||||||
|
tint = accentGreen,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Delete,
|
||||||
|
contentDescription = "Удалить",
|
||||||
|
tint = Color.Red,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TaskTimer(
|
||||||
|
timerState: TimerState,
|
||||||
|
accentGreen: Color,
|
||||||
|
onPause: () -> Unit,
|
||||||
|
onResume: () -> Unit,
|
||||||
|
onStop: () -> Unit
|
||||||
|
) {
|
||||||
|
val totalSeconds = timerState.durationMinutes * 60
|
||||||
|
val progress = if (totalSeconds > 0) {
|
||||||
|
timerState.remainingSeconds.toFloat() / totalSeconds
|
||||||
|
} else 0f
|
||||||
|
|
||||||
|
val timeText = formatTime(timerState.remainingSeconds)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(100.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val strokeWidth = 10.dp.toPx()
|
||||||
|
val radius = size.minDimension / 2 - strokeWidth / 2
|
||||||
|
|
||||||
|
// Фоновый круг
|
||||||
|
drawCircle(
|
||||||
|
color = Color.Gray.copy(alpha = 0.2f),
|
||||||
|
radius = radius,
|
||||||
|
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Прогресс таймера (обратный - показываем оставшееся время)
|
||||||
|
val sweepAngle = 360f * progress
|
||||||
|
|
||||||
|
drawArc(
|
||||||
|
color = accentGreen,
|
||||||
|
startAngle = -90f,
|
||||||
|
sweepAngle = sweepAngle,
|
||||||
|
useCenter = false,
|
||||||
|
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = timeText,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
|
) {
|
||||||
|
if (timerState.isRunning) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onPause,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Pause,
|
||||||
|
contentDescription = "Пауза",
|
||||||
|
tint = accentGreen,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(
|
||||||
|
onClick = onResume,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = "Продолжить",
|
||||||
|
tint = accentGreen,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,3 +445,264 @@ fun TaskScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatTime(seconds: Int): String {
|
||||||
|
val minutes = seconds / 60
|
||||||
|
val remainingSeconds = seconds % 60
|
||||||
|
return String.format("%02d:%02d", minutes, remainingSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CreateTaskOrRewardDialog(
|
||||||
|
dialogMode: DialogMode,
|
||||||
|
taskTitle: String,
|
||||||
|
taskDescription: String,
|
||||||
|
taskDuration: String,
|
||||||
|
rewardTitle: String,
|
||||||
|
rewardDescription: String,
|
||||||
|
rewardPoints: Int,
|
||||||
|
onModeChange: (DialogMode) -> Unit,
|
||||||
|
onTaskTitleChange: (String) -> Unit,
|
||||||
|
onTaskDescriptionChange: (String) -> Unit,
|
||||||
|
onTaskDurationChange: (String) -> Unit,
|
||||||
|
onRewardTitleChange: (String) -> Unit,
|
||||||
|
onRewardDescriptionChange: (String) -> Unit,
|
||||||
|
onRewardPointsChange: (Int) -> Unit,
|
||||||
|
onCreate: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
accentGreen: Color,
|
||||||
|
screenHeightDp: Int,
|
||||||
|
isLoading: Boolean,
|
||||||
|
scheduleId: String?
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.9f)
|
||||||
|
.wrapContentHeight(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color.White
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Переключатель режимов
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
// Кнопка "Задача"
|
||||||
|
Button(
|
||||||
|
onClick = { onModeChange(DialogMode.TASK) },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(48.dp),
|
||||||
|
enabled = scheduleId != null && !isLoading,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = if (dialogMode == DialogMode.TASK) accentGreen else Color.LightGray,
|
||||||
|
contentColor = if (dialogMode == DialogMode.TASK) Color.White else Color.Black,
|
||||||
|
disabledContainerColor = Color.LightGray,
|
||||||
|
disabledContentColor = Color.Gray
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Задача",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Награда"
|
||||||
|
Button(
|
||||||
|
onClick = { onModeChange(DialogMode.REWARD) },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(48.dp),
|
||||||
|
enabled = !isLoading,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = if (dialogMode == DialogMode.REWARD) accentGreen else Color.LightGray,
|
||||||
|
contentColor = if (dialogMode == DialogMode.REWARD) Color.White else Color.Black
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Награда",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заголовок
|
||||||
|
val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp
|
||||||
|
Text(
|
||||||
|
text = if (dialogMode == DialogMode.TASK) "Создать задачу" else "Создать награду",
|
||||||
|
fontSize = titleSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
|
||||||
|
when (dialogMode) {
|
||||||
|
DialogMode.TASK -> {
|
||||||
|
// Поле названия задачи
|
||||||
|
OutlinedTextField(
|
||||||
|
value = taskTitle,
|
||||||
|
onValueChange = onTaskTitleChange,
|
||||||
|
label = { Text("Название") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
focusedLabelColor = accentGreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле описания задачи
|
||||||
|
OutlinedTextField(
|
||||||
|
value = taskDescription,
|
||||||
|
onValueChange = onTaskDescriptionChange,
|
||||||
|
label = { Text("Описание") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 3,
|
||||||
|
enabled = !isLoading,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
focusedLabelColor = accentGreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле длительности
|
||||||
|
OutlinedTextField(
|
||||||
|
value = taskDuration,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
if (newValue.all { it.isDigit() }) {
|
||||||
|
onTaskDurationChange(newValue)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Длительность (минуты)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
focusedLabelColor = accentGreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogMode.REWARD -> {
|
||||||
|
// Поле названия награды
|
||||||
|
OutlinedTextField(
|
||||||
|
value = rewardTitle,
|
||||||
|
onValueChange = onRewardTitleChange,
|
||||||
|
label = { Text("Название") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
focusedLabelColor = accentGreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле описания награды
|
||||||
|
OutlinedTextField(
|
||||||
|
value = rewardDescription,
|
||||||
|
onValueChange = onRewardDescriptionChange,
|
||||||
|
label = { Text("Описание") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 3,
|
||||||
|
enabled = !isLoading,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
focusedLabelColor = accentGreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле баллов
|
||||||
|
OutlinedTextField(
|
||||||
|
value = rewardPoints.toString(),
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
if (newValue.all { it.isDigit() }) {
|
||||||
|
onRewardPointsChange(newValue.toIntOrNull() ?: 10)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Баллы") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
focusedLabelColor = accentGreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопки внизу
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Кнопка "Отмена"
|
||||||
|
Button(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color.LightGray,
|
||||||
|
contentColor = Color.Black
|
||||||
|
),
|
||||||
|
enabled = !isLoading
|
||||||
|
) {
|
||||||
|
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
||||||
|
Text(
|
||||||
|
text = "Отмена",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Создать"
|
||||||
|
Button(
|
||||||
|
onClick = onCreate,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(56.dp),
|
||||||
|
enabled = when (dialogMode) {
|
||||||
|
DialogMode.TASK -> taskTitle.isNotBlank() && !isLoading
|
||||||
|
DialogMode.REWARD -> rewardTitle.isNotBlank() && !isLoading
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = accentGreen,
|
||||||
|
contentColor = Color.White,
|
||||||
|
disabledContainerColor = Color.LightGray,
|
||||||
|
disabledContentColor = Color.Gray
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val buttonTextSize =
|
||||||
|
(screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
||||||
|
Text(
|
||||||
|
text = "Создать",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,18 +2,29 @@ package com.novayaplaneta.ui.screens.task
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.novayaplaneta.data.local.TimerStateManager
|
||||||
|
import com.novayaplaneta.data.local.SavedTimerState
|
||||||
|
import com.novayaplaneta.domain.model.Reward
|
||||||
import com.novayaplaneta.domain.model.Task
|
import com.novayaplaneta.domain.model.Task
|
||||||
|
import com.novayaplaneta.domain.repository.AuthRepository
|
||||||
|
import com.novayaplaneta.domain.usecase.ClaimRewardUseCase
|
||||||
import com.novayaplaneta.domain.usecase.CompleteTaskUseCase
|
import com.novayaplaneta.domain.usecase.CompleteTaskUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.CreateRewardUseCase
|
||||||
import com.novayaplaneta.domain.usecase.CreateTaskUseCase
|
import com.novayaplaneta.domain.usecase.CreateTaskUseCase
|
||||||
import com.novayaplaneta.domain.usecase.DeleteTaskUseCase
|
import com.novayaplaneta.domain.usecase.DeleteTaskUseCase
|
||||||
|
import com.novayaplaneta.domain.usecase.GetSchedulesUseCase
|
||||||
import com.novayaplaneta.domain.usecase.GetTaskByIdUseCase
|
import com.novayaplaneta.domain.usecase.GetTaskByIdUseCase
|
||||||
import com.novayaplaneta.domain.usecase.GetTasksUseCase
|
import com.novayaplaneta.domain.usecase.GetTasksUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -21,14 +32,23 @@ class TaskViewModel @Inject constructor(
|
|||||||
private val getTasksUseCase: GetTasksUseCase,
|
private val getTasksUseCase: GetTasksUseCase,
|
||||||
private val getTaskByIdUseCase: GetTaskByIdUseCase,
|
private val getTaskByIdUseCase: GetTaskByIdUseCase,
|
||||||
private val createTaskUseCase: CreateTaskUseCase,
|
private val createTaskUseCase: CreateTaskUseCase,
|
||||||
|
private val createRewardUseCase: CreateRewardUseCase,
|
||||||
private val deleteTaskUseCase: DeleteTaskUseCase,
|
private val deleteTaskUseCase: DeleteTaskUseCase,
|
||||||
private val completeTaskUseCase: CompleteTaskUseCase
|
private val completeTaskUseCase: CompleteTaskUseCase,
|
||||||
|
private val claimRewardUseCase: ClaimRewardUseCase,
|
||||||
|
private val getSchedulesUseCase: GetSchedulesUseCase,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val timerStateManager: TimerStateManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(TaskUiState())
|
private val _uiState = MutableStateFlow(TaskUiState())
|
||||||
val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _timerStates = MutableStateFlow<Map<String, TimerState>>(emptyMap())
|
||||||
|
val timerStates: StateFlow<Map<String, TimerState>> = _timerStates.asStateFlow()
|
||||||
|
|
||||||
private var currentScheduleId: String? = null
|
private var currentScheduleId: String? = null
|
||||||
|
private val timerJobs = mutableMapOf<String, Job>()
|
||||||
|
|
||||||
fun loadTasks(scheduleId: String) {
|
fun loadTasks(scheduleId: String) {
|
||||||
currentScheduleId = scheduleId
|
currentScheduleId = scheduleId
|
||||||
@@ -36,6 +56,24 @@ class TaskViewModel @Inject constructor(
|
|||||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Загружаем сохраненные состояния таймеров
|
||||||
|
val savedStates = timerStateManager.getAllTimerStates()
|
||||||
|
savedStates.forEach { (taskId, savedState) ->
|
||||||
|
val timerState = TimerState(
|
||||||
|
isRunning = savedState.isRunning,
|
||||||
|
durationMinutes = savedState.durationMinutes,
|
||||||
|
remainingSeconds = savedState.remainingSeconds
|
||||||
|
)
|
||||||
|
val currentStates = _timerStates.value.toMutableMap()
|
||||||
|
currentStates[taskId] = timerState
|
||||||
|
_timerStates.value = currentStates
|
||||||
|
|
||||||
|
// Перезапускаем таймер если он был запущен
|
||||||
|
if (savedState.isRunning && savedState.remainingSeconds > 0) {
|
||||||
|
resumeTimer(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val loadResult = getTasksUseCase.loadTasks(scheduleId)
|
val loadResult = getTasksUseCase.loadTasks(scheduleId)
|
||||||
if (loadResult.isFailure) {
|
if (loadResult.isFailure) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
@@ -82,15 +120,37 @@ class TaskViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createTask(task: Task) {
|
fun createTask(scheduleId: String) {
|
||||||
|
val title = _uiState.value.newTaskTitle
|
||||||
|
if (title.isBlank()) return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
val task = Task(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
title = title,
|
||||||
|
description = _uiState.value.newTaskDescription.takeIf { it.isNotBlank() },
|
||||||
|
imageUrl = null,
|
||||||
|
completed = false,
|
||||||
|
scheduledTime = null,
|
||||||
|
duration = _uiState.value.newTaskDuration.toIntOrNull(),
|
||||||
|
scheduleId = scheduleId,
|
||||||
|
order = _uiState.value.tasks.size,
|
||||||
|
category = null,
|
||||||
|
createdAt = LocalDateTime.now(),
|
||||||
|
updatedAt = LocalDateTime.now()
|
||||||
|
)
|
||||||
|
|
||||||
val result = createTaskUseCase(task)
|
val result = createTaskUseCase(task)
|
||||||
result.onSuccess {
|
result.onSuccess {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = null
|
errorMessage = null,
|
||||||
|
showAddDialog = false,
|
||||||
|
newTaskTitle = "",
|
||||||
|
newTaskDescription = "",
|
||||||
|
newTaskDuration = ""
|
||||||
)
|
)
|
||||||
// Список задач обновится автоматически через Flow
|
// Список задач обновится автоматически через Flow
|
||||||
}.onFailure { exception ->
|
}.onFailure { exception ->
|
||||||
@@ -102,6 +162,98 @@ class TaskViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createReward() {
|
||||||
|
val title = _uiState.value.newRewardTitle
|
||||||
|
if (title.isBlank()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
val userId = authRepository.getCurrentUser().first()?.id
|
||||||
|
if (userId == null) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = "Пользователь не найден"
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val reward = Reward(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
title = title,
|
||||||
|
description = _uiState.value.newRewardDescription.takeIf { it.isNotBlank() },
|
||||||
|
imageUrl = null,
|
||||||
|
pointsRequired = _uiState.value.newRewardPoints,
|
||||||
|
isClaimed = false,
|
||||||
|
earnedAt = null,
|
||||||
|
userId = userId,
|
||||||
|
createdAt = LocalDateTime.now(),
|
||||||
|
updatedAt = LocalDateTime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = createRewardUseCase(reward)
|
||||||
|
result.onSuccess {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = null,
|
||||||
|
showAddDialog = false,
|
||||||
|
newRewardTitle = "",
|
||||||
|
newRewardDescription = "",
|
||||||
|
newRewardPoints = 10
|
||||||
|
)
|
||||||
|
}.onFailure { exception ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = exception.message ?: "Ошибка создания награды"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showAddDialog() {
|
||||||
|
_uiState.value = _uiState.value.copy(showAddDialog = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideAddDialog() {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
showAddDialog = false,
|
||||||
|
newTaskTitle = "",
|
||||||
|
newTaskDescription = "",
|
||||||
|
newTaskDuration = "",
|
||||||
|
newRewardTitle = "",
|
||||||
|
newRewardDescription = "",
|
||||||
|
newRewardPoints = 10
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDialogMode(mode: DialogMode) {
|
||||||
|
_uiState.value = _uiState.value.copy(dialogMode = mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewTaskTitle(title: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newTaskTitle = title)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewTaskDescription(description: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newTaskDescription = description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewTaskDuration(duration: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newTaskDuration = duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewRewardTitle(title: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newRewardTitle = title)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewRewardDescription(description: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newRewardDescription = description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewRewardPoints(points: Int) {
|
||||||
|
_uiState.value = _uiState.value.copy(newRewardPoints = points)
|
||||||
|
}
|
||||||
|
|
||||||
fun deleteTask(taskId: String) {
|
fun deleteTask(taskId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
@@ -121,6 +273,11 @@ class TaskViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun completeTask(taskId: String, completed: Boolean) {
|
fun completeTask(taskId: String, completed: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
// Останавливаем таймер если задача завершена
|
||||||
|
if (completed) {
|
||||||
|
stopTimer(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
// Оптимистичное обновление UI
|
// Оптимистичное обновление UI
|
||||||
val currentTasks = _uiState.value.tasks.toMutableList()
|
val currentTasks = _uiState.value.tasks.toMutableList()
|
||||||
val taskIndex = currentTasks.indexOfFirst { it.id == taskId }
|
val taskIndex = currentTasks.indexOfFirst { it.id == taskId }
|
||||||
@@ -131,7 +288,12 @@ class TaskViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val result = completeTaskUseCase(taskId, completed)
|
val result = completeTaskUseCase(taskId, completed)
|
||||||
result.onFailure { exception ->
|
result.onSuccess {
|
||||||
|
// Проверяем, все ли задачи выполнены, и выдаем награду если нужно
|
||||||
|
if (completed && currentScheduleId != null) {
|
||||||
|
checkAndAwardReward(currentScheduleId!!)
|
||||||
|
}
|
||||||
|
}.onFailure { exception ->
|
||||||
// Откатываем изменение в случае ошибки
|
// Откатываем изменение в случае ошибки
|
||||||
if (taskIndex >= 0) {
|
if (taskIndex >= 0) {
|
||||||
val oldTask = currentTasks[taskIndex]
|
val oldTask = currentTasks[taskIndex]
|
||||||
@@ -145,15 +307,196 @@ class TaskViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun checkAndAwardReward(scheduleId: String) {
|
||||||
|
try {
|
||||||
|
val user = authRepository.getCurrentUser().first()
|
||||||
|
val userId = user?.id ?: return
|
||||||
|
|
||||||
|
// Получаем расписание
|
||||||
|
val schedules = getSchedulesUseCase(userId).first()
|
||||||
|
val schedule = schedules.find { it.id == scheduleId } ?: return
|
||||||
|
|
||||||
|
// Проверяем, есть ли награда в расписании
|
||||||
|
val rewardId = schedule.rewardId ?: return
|
||||||
|
|
||||||
|
// Проверяем, все ли задачи выполнены
|
||||||
|
val allTasksCompleted = schedule.tasks.isNotEmpty() &&
|
||||||
|
schedule.tasks.all { it.completed }
|
||||||
|
|
||||||
|
if (allTasksCompleted) {
|
||||||
|
// Выдаем награду
|
||||||
|
claimRewardUseCase(rewardId)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Игнорируем ошибки при проверке награды
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startTimer(taskId: String, durationMinutes: Int) {
|
||||||
|
// Останавливаем предыдущий таймер для этой задачи если есть
|
||||||
|
stopTimer(taskId)
|
||||||
|
|
||||||
|
val newState = TimerState(
|
||||||
|
isRunning = true,
|
||||||
|
durationMinutes = durationMinutes,
|
||||||
|
remainingSeconds = durationMinutes * 60
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentStates = _timerStates.value.toMutableMap()
|
||||||
|
currentStates[taskId] = newState
|
||||||
|
_timerStates.value = currentStates
|
||||||
|
|
||||||
|
timerJobs[taskId] = viewModelScope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(1000)
|
||||||
|
val currentStates = _timerStates.value
|
||||||
|
val state = currentStates[taskId] ?: break
|
||||||
|
|
||||||
|
if (!state.isRunning) continue
|
||||||
|
|
||||||
|
if (state.remainingSeconds <= 0) {
|
||||||
|
// Таймер истек, завершаем задачу
|
||||||
|
completeTask(taskId, true)
|
||||||
|
val updatedStates = currentStates.toMutableMap()
|
||||||
|
updatedStates.remove(taskId)
|
||||||
|
_timerStates.value = updatedStates
|
||||||
|
timerJobs.remove(taskId)
|
||||||
|
timerStateManager.removeTimerState(taskId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
val newRemaining = state.remainingSeconds - 1
|
||||||
|
val updatedState = state.copy(remainingSeconds = newRemaining)
|
||||||
|
val updatedStates = currentStates.toMutableMap()
|
||||||
|
updatedStates[taskId] = updatedState
|
||||||
|
_timerStates.value = updatedStates
|
||||||
|
|
||||||
|
// Сохраняем состояние таймера
|
||||||
|
timerStateManager.saveTimerState(
|
||||||
|
taskId,
|
||||||
|
SavedTimerState(
|
||||||
|
isRunning = updatedState.isRunning,
|
||||||
|
durationMinutes = updatedState.durationMinutes,
|
||||||
|
remainingSeconds = updatedState.remainingSeconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pauseTimer(taskId: String) {
|
||||||
|
val currentStates = _timerStates.value.toMutableMap()
|
||||||
|
currentStates[taskId]?.let { state ->
|
||||||
|
val pausedState = state.copy(isRunning = false)
|
||||||
|
currentStates[taskId] = pausedState
|
||||||
|
_timerStates.value = currentStates
|
||||||
|
|
||||||
|
// Сохраняем состояние таймера
|
||||||
|
viewModelScope.launch {
|
||||||
|
timerStateManager.saveTimerState(
|
||||||
|
taskId,
|
||||||
|
SavedTimerState(
|
||||||
|
isRunning = pausedState.isRunning,
|
||||||
|
durationMinutes = pausedState.durationMinutes,
|
||||||
|
remainingSeconds = pausedState.remainingSeconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resumeTimer(taskId: String) {
|
||||||
|
val currentStates = _timerStates.value.toMutableMap()
|
||||||
|
currentStates[taskId]?.let { state ->
|
||||||
|
if (state.remainingSeconds > 0) {
|
||||||
|
currentStates[taskId] = state.copy(isRunning = true)
|
||||||
|
_timerStates.value = currentStates
|
||||||
|
|
||||||
|
// Перезапускаем таймер
|
||||||
|
timerJobs[taskId]?.cancel()
|
||||||
|
timerJobs[taskId] = viewModelScope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(1000)
|
||||||
|
val currentStates = _timerStates.value
|
||||||
|
val currentState = currentStates[taskId] ?: break
|
||||||
|
|
||||||
|
if (!currentState.isRunning) continue
|
||||||
|
|
||||||
|
if (currentState.remainingSeconds <= 0) {
|
||||||
|
completeTask(taskId, true)
|
||||||
|
val updatedStates = currentStates.toMutableMap()
|
||||||
|
updatedStates.remove(taskId)
|
||||||
|
_timerStates.value = updatedStates
|
||||||
|
timerJobs.remove(taskId)
|
||||||
|
timerStateManager.removeTimerState(taskId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
val newRemaining = currentState.remainingSeconds - 1
|
||||||
|
val updatedState = currentState.copy(remainingSeconds = newRemaining)
|
||||||
|
val updatedStates = currentStates.toMutableMap()
|
||||||
|
updatedStates[taskId] = updatedState
|
||||||
|
_timerStates.value = updatedStates
|
||||||
|
|
||||||
|
// Сохраняем состояние таймера
|
||||||
|
timerStateManager.saveTimerState(
|
||||||
|
taskId,
|
||||||
|
SavedTimerState(
|
||||||
|
isRunning = updatedState.isRunning,
|
||||||
|
durationMinutes = updatedState.durationMinutes,
|
||||||
|
remainingSeconds = updatedState.remainingSeconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopTimer(taskId: String) {
|
||||||
|
timerJobs[taskId]?.cancel()
|
||||||
|
timerJobs.remove(taskId)
|
||||||
|
val currentStates = _timerStates.value.toMutableMap()
|
||||||
|
currentStates.remove(taskId)
|
||||||
|
_timerStates.value = currentStates
|
||||||
|
|
||||||
|
// Удаляем сохраненное состояние
|
||||||
|
viewModelScope.launch {
|
||||||
|
timerStateManager.removeTimerState(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTimerState(taskId: String): TimerState? {
|
||||||
|
return _timerStates.value[taskId]
|
||||||
|
}
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
_uiState.value = _uiState.value.copy(errorMessage = null)
|
_uiState.value = _uiState.value.copy(errorMessage = null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class DialogMode {
|
||||||
|
TASK, REWARD
|
||||||
|
}
|
||||||
|
|
||||||
data class TaskUiState(
|
data class TaskUiState(
|
||||||
val tasks: List<Task> = emptyList(),
|
val tasks: List<Task> = emptyList(),
|
||||||
val selectedTask: Task? = null,
|
val selectedTask: Task? = null,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val errorMessage: String? = null
|
val errorMessage: String? = null,
|
||||||
|
val showAddDialog: Boolean = false,
|
||||||
|
val dialogMode: DialogMode = DialogMode.TASK,
|
||||||
|
val newTaskTitle: String = "",
|
||||||
|
val newTaskDescription: String = "",
|
||||||
|
val newTaskDuration: String = "",
|
||||||
|
val newRewardTitle: String = "",
|
||||||
|
val newRewardDescription: String = "",
|
||||||
|
val newRewardPoints: Int = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TimerState(
|
||||||
|
val isRunning: Boolean = false,
|
||||||
|
val durationMinutes: Int = 0,
|
||||||
|
val remainingSeconds: Int = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user