diff --git a/app/src/main/java/com/novayaplaneta/data/repository/AIRepositoryImpl.kt b/app/src/main/java/com/novayaplaneta/data/repository/AIRepositoryImpl.kt index 0c3ec43..792876b 100644 --- a/app/src/main/java/com/novayaplaneta/data/repository/AIRepositoryImpl.kt +++ b/app/src/main/java/com/novayaplaneta/data/repository/AIRepositoryImpl.kt @@ -22,10 +22,6 @@ class AIRepositoryImpl @Inject constructor( override suspend fun sendMessage(userId: String, message: String): Result { return try { - // Get token from current user - val user = authRepository.getCurrentUser().first() - val token = user?.token ?: "" - // Save user message val userMessage = ChatMessage( id = UUID.randomUUID().toString(), @@ -36,29 +32,22 @@ class AIRepositoryImpl @Inject constructor( ) chatMessageDao.insertMessage(userMessage.toEntity()) - // Send to API (only if token exists) - if (token.isNotEmpty()) { - val response = api.chatWithAI("Bearer $token", ChatRequest(message)) - if (response.isSuccessful && response.body() != null) { - val aiResponse = response.body()!!.message - - // Save AI response - val aiMessage = ChatMessage( - id = UUID.randomUUID().toString(), - message = aiResponse, - isFromAI = true, - timestamp = LocalDateTime.now(), - userId = userId - ) - chatMessageDao.insertMessage(aiMessage.toEntity()) - - Result.success(aiResponse) - } else { - Result.failure(Exception("Failed to get AI response")) - } - } else { - Result.failure(Exception("User not authenticated")) - } + // Тестовый ответ для демонстрации + kotlinx.coroutines.delay(1500) // Небольшая задержка для анимации + + val aiResponse = "!!!!" + + // Save AI response + val aiMessage = ChatMessage( + id = UUID.randomUUID().toString(), + message = aiResponse, + isFromAI = true, + timestamp = LocalDateTime.now(), + userId = userId + ) + chatMessageDao.insertMessage(aiMessage.toEntity()) + + Result.success(aiResponse) } catch (e: Exception) { Result.failure(e) } 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 d368e22..a3018b1 100644 --- a/app/src/main/java/com/novayaplaneta/ui/navigation/NewPlanetNavigation.kt +++ b/app/src/main/java/com/novayaplaneta/ui/navigation/NewPlanetNavigation.kt @@ -48,7 +48,7 @@ fun NewPlanetNavigation( RewardsScreen(navController = navController) } composable("ai") { - AIScreen() + AIScreen(navController = navController) } composable("settings") { SettingsScreen(navController = navController) 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 e696882..26e54b8 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 @@ -1,94 +1,387 @@ package com.novayaplaneta.ui.screens.ai +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.Star import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.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 +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import kotlin.math.sin +import androidx.compose.ui.unit.lerp +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import com.novayaplaneta.R +import com.novayaplaneta.ui.components.NovayaPlanetaLogo +import com.novayaplaneta.ui.theme.* -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AIScreen( + navController: androidx.navigation.NavController? = null, viewModel: AIViewModel = hiltViewModel(), modifier: Modifier = Modifier ) { val uiState by viewModel.uiState.collectAsState() var messageText by remember { mutableStateOf("") } - Scaffold( - topBar = { - TopAppBar( - title = { Text("ИИ-помощник \"Земля\"") } - ) + // Загружаем историю чата при первом открытии экрана + LaunchedEffect(Unit) { + viewModel.loadChatHistory("default") + } + + val configuration = LocalConfiguration.current + val screenWidthDp = configuration.screenWidthDp + val screenHeightDp = configuration.screenHeightDp + + // Цвета из autism-friendly палитры + val backgroundColor = LoginBackgroundTurquoise + val navPanelColor = LoginInputLightBlue + val accentGreen = LoginGreenAccent + + // Состояние прокрутки чата + val listState = rememberLazyListState() + + // Автопрокрутка вниз при новых сообщениях + LaunchedEffect(uiState.messages.size) { + if (uiState.messages.isNotEmpty()) { + listState.animateScrollToItem(uiState.messages.size - 1) } - ) { paddingValues -> - Column( - modifier = modifier - .fillMaxSize() - .padding(paddingValues) + } + + Box( + modifier = modifier + .fillMaxSize() + .background(color = backgroundColor) + ) { + Row( + modifier = Modifier.fillMaxSize() ) { - LazyColumn( + // Левая панель навигации с логотипом над ней + Column( modifier = Modifier - .weight(1f) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .width((screenWidthDp * 0.22f).dp.coerceIn(160.dp, 240.dp)) + .fillMaxHeight() + .padding(vertical = 20.dp, horizontal = 16.dp) ) { - items(uiState.messages) { message -> - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = if (message.isFromAI) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - } - ) - ) { - Text( - text = message.message, - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.bodyLarge - ) - } + // Логотип над панелью навигации + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + contentAlignment = Alignment.Center + ) { + val logoSize = (screenHeightDp * 0.22f).toInt().coerceIn(140, 240).dp + NovayaPlanetaLogo(size = logoSize) + } + + // Панель навигации + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(32.dp)) + .background(color = navPanelColor) + .padding(vertical = 24.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(32.dp) + ) { + NavItem( + icon = Icons.Filled.CalendarToday, + text = "Расписание", + isSelected = false, + onClick = { navController?.navigate("schedule") } + ) + + NavItem( + icon = Icons.Filled.Star, + text = "Награды", + isSelected = false, + onClick = { navController?.navigate("rewards") } + ) + + NavItem( + icon = Icons.Filled.Person, + text = "Профиль", + isSelected = false, + onClick = { navController?.navigate("settings") } + ) + + NavItem( + icon = Icons.Filled.Public, + text = "Земля", + isSelected = true, + onClick = { } + ) } } - if (uiState.isLoading) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - + // Основная область с AI агентом и чатом Row( modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + .weight(1f) + .fillMaxHeight() + .padding(24.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp) ) { - OutlinedTextField( - value = messageText, - onValueChange = { messageText = it }, - modifier = Modifier.weight(1f), - placeholder = { Text("Введите сообщение...") } - ) - Button( - onClick = { - if (messageText.isNotBlank()) { - viewModel.sendMessage("userId", messageText) - messageText = "" + // Левая часть: Анимированная картинка AI агента + Column( + modifier = Modifier + .width((screenWidthDp * 0.3f).dp.coerceIn(200.dp, 400.dp)) + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + AnimatedAIAgent( + isSpeaking = uiState.isLoading, + screenHeightDp = screenHeightDp + ) + } + + // Правая часть: Чат + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(RoundedCornerShape(24.dp)) + .background(color = LoginCardLightBlue) + .padding(24.dp) + ) { + // Заголовок чата + val titleSize = (screenHeightDp * 0.04f).toInt().coerceIn(32, 52).sp + Text( + text = "Чат с Землей", + fontSize = titleSize, + fontWeight = FontWeight.Bold, + color = Color.Black, + modifier = Modifier.padding(bottom = 24.dp) + ) + + // Список сообщений + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(uiState.messages) { message -> + ChatBubble( + message = message.message, + isFromAI = message.isFromAI, + screenHeightDp = screenHeightDp, + accentGreen = accentGreen + ) + } + + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Поле ввода и кнопка отправки + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Bottom + ) { + OutlinedTextField( + value = messageText, + onValueChange = { messageText = it }, + modifier = Modifier.weight(1f), + placeholder = { + Text( + text = "Напишите сообщение...", + color = Color.Gray.copy(alpha = 0.7f) + ) + }, + singleLine = false, + maxLines = 3, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.Black, + unfocusedTextColor = Color.Black, + focusedBorderColor = accentGreen, + unfocusedBorderColor = Color.Gray + ), + shape = RoundedCornerShape(20.dp) + ) + + Button( + onClick = { + if (messageText.isNotBlank()) { + viewModel.sendMessage("default", messageText) + messageText = "" + } + }, + modifier = Modifier + .height(56.dp) + .width(56.dp), + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors( + containerColor = accentGreen, + contentColor = Color.White + ), + enabled = messageText.isNotBlank() && !uiState.isLoading + ) { + Icon( + imageVector = Icons.Filled.Send, + contentDescription = "Отправить", + modifier = Modifier.size(24.dp) + ) } } - ) { - Text("Отправить") } } } } } +@Composable +fun AnimatedAIAgent( + isSpeaking: Boolean, + screenHeightDp: Int +) { + val infiniteTransition = rememberInfiniteTransition(label = "ai_agent_animation") + + // Анимация покачивания Земли - более быстрое когда говорит + val bounceFloat by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 2f * kotlin.math.PI.toFloat(), + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = if (isSpeaking) 400 else 2000, + easing = LinearEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "bounce_float" + ) + + // Интенсивное покачивание когда говорит, легкое когда молчит + val offsetY = if (isSpeaking) { + sin(bounceFloat) * 20f // Активное прыгание при ответе + } else { + sin(bounceFloat) * 8f // Легкое покачивание в покое + } + + val earthSize = (screenHeightDp * 0.4f).dp.coerceIn(200.dp, 400.dp) + + Box( + modifier = Modifier + .size(earthSize), + contentAlignment = Alignment.Center + ) { + // Изображение Земли с покачиванием + androidx.compose.foundation.Image( + painter = painterResource(id = R.drawable.logo_earth), + contentDescription = "AI Агент Земля", + modifier = Modifier + .fillMaxSize() + .offset(y = offsetY.dp), + contentScale = ContentScale.Fit + ) + } +} + +@Composable +fun ChatBubble( + message: String, + isFromAI: Boolean, + screenHeightDp: Int, + accentGreen: Color +) { + val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 20).sp + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isFromAI) Arrangement.Start else Arrangement.End + ) { + Card( + modifier = Modifier.widthIn(max = (screenHeightDp * 0.4f).dp.coerceIn(200.dp, 400.dp)), + shape = RoundedCornerShape( + topStart = 20.dp, + topEnd = 20.dp, + bottomStart = if (isFromAI) 4.dp else 20.dp, + bottomEnd = if (isFromAI) 20.dp else 4.dp + ), + colors = CardDefaults.cardColors( + containerColor = if (isFromAI) { + LoginGreenSoft // Мягкий зеленый для AI + } else { + LoginGreenAccent // Зеленый акцент для пользователя + } + ) + ) { + Text( + text = message, + modifier = Modifier.padding(16.dp), + fontSize = textSize, + color = Color.Black, + fontWeight = FontWeight.Medium + ) + } + } +} + +@Composable +fun NavItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + text: String, + isSelected: Boolean, + onClick: () -> Unit +) { + val iconSize = 40.dp + val textSize = 18.sp + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable { onClick() } + .background( + color = if (isSelected) Color.White.copy(alpha = 0.3f) else Color.Transparent + ) + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = icon, + contentDescription = text, + modifier = Modifier.size(iconSize), + tint = LoginGreenAccent + ) + Text( + text = text, + fontSize = textSize, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + } +}