Пока просто накидал форму чата

This commit is contained in:
2025-12-23 23:11:08 +03:00
parent 5105e68970
commit b41de4aaf5
3 changed files with 362 additions and 80 deletions

View File

@@ -22,10 +22,6 @@ class AIRepositoryImpl @Inject constructor(
override suspend fun sendMessage(userId: String, message: String): Result<String> { override suspend fun sendMessage(userId: String, message: String): Result<String> {
return try { return try {
// Get token from current user
val user = authRepository.getCurrentUser().first()
val token = user?.token ?: ""
// Save user message // Save user message
val userMessage = ChatMessage( val userMessage = ChatMessage(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
@@ -36,29 +32,22 @@ class AIRepositoryImpl @Inject constructor(
) )
chatMessageDao.insertMessage(userMessage.toEntity()) chatMessageDao.insertMessage(userMessage.toEntity())
// Send to API (only if token exists) // Тестовый ответ для демонстрации
if (token.isNotEmpty()) { kotlinx.coroutines.delay(1500) // Небольшая задержка для анимации
val response = api.chatWithAI("Bearer $token", ChatRequest(message))
if (response.isSuccessful && response.body() != null) { val aiResponse = "!!!!"
val aiResponse = response.body()!!.message
// Save AI response
// Save AI response val aiMessage = ChatMessage(
val aiMessage = ChatMessage( id = UUID.randomUUID().toString(),
id = UUID.randomUUID().toString(), message = aiResponse,
message = aiResponse, isFromAI = true,
isFromAI = true, timestamp = LocalDateTime.now(),
timestamp = LocalDateTime.now(), userId = userId
userId = userId )
) chatMessageDao.insertMessage(aiMessage.toEntity())
chatMessageDao.insertMessage(aiMessage.toEntity())
Result.success(aiResponse)
Result.success(aiResponse)
} else {
Result.failure(Exception("Failed to get AI response"))
}
} else {
Result.failure(Exception("User not authenticated"))
}
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
} }

View File

@@ -48,7 +48,7 @@ fun NewPlanetNavigation(
RewardsScreen(navController = navController) RewardsScreen(navController = navController)
} }
composable("ai") { composable("ai") {
AIScreen() AIScreen(navController = navController)
} }
composable("settings") { composable("settings") {
SettingsScreen(navController = navController) SettingsScreen(navController = navController)

View File

@@ -1,94 +1,387 @@
package com.novayaplaneta.ui.screens.ai 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.layout.*
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.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.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
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.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.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel 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 @Composable
fun AIScreen( fun AIScreen(
navController: androidx.navigation.NavController? = null,
viewModel: AIViewModel = hiltViewModel(), viewModel: AIViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
var messageText by remember { mutableStateOf("") } var messageText by remember { mutableStateOf("") }
Scaffold( // Загружаем историю чата при первом открытии экрана
topBar = { LaunchedEffect(Unit) {
TopAppBar( viewModel.loadChatHistory("default")
title = { Text("ИИ-помощник \"Земля\"") } }
)
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 Box(
.fillMaxSize() modifier = modifier
.padding(paddingValues) .fillMaxSize()
.background(color = backgroundColor)
) {
Row(
modifier = Modifier.fillMaxSize()
) { ) {
LazyColumn( // Левая панель навигации с логотипом над ней
Column(
modifier = Modifier modifier = Modifier
.weight(1f) .width((screenWidthDp * 0.22f).dp.coerceIn(160.dp, 240.dp))
.padding(16.dp), .fillMaxHeight()
verticalArrangement = Arrangement.spacedBy(8.dp) .padding(vertical = 20.dp, horizontal = 16.dp)
) { ) {
items(uiState.messages) { message -> // Логотип над панелью навигации
Card( Box(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
colors = CardDefaults.cardColors( .fillMaxWidth()
containerColor = if (message.isFromAI) { .padding(bottom = 16.dp),
MaterialTheme.colorScheme.primaryContainer contentAlignment = Alignment.Center
} else { ) {
MaterialTheme.colorScheme.surfaceVariant val logoSize = (screenHeightDp * 0.22f).toInt().coerceIn(140, 240).dp
} NovayaPlanetaLogo(size = logoSize)
) }
) {
Text( // Панель навигации
text = message.message, Column(
modifier = Modifier.padding(16.dp), modifier = Modifier
style = MaterialTheme.typography.bodyLarge .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) { // Основная область с AI агентом и чатом
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .weight(1f)
.padding(16.dp), .fillMaxHeight()
horizontalArrangement = Arrangement.spacedBy(8.dp) .padding(24.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) { ) {
OutlinedTextField( // Левая часть: Анимированная картинка AI агента
value = messageText, Column(
onValueChange = { messageText = it }, modifier = Modifier
modifier = Modifier.weight(1f), .width((screenWidthDp * 0.3f).dp.coerceIn(200.dp, 400.dp))
placeholder = { Text("Введите сообщение...") } .fillMaxHeight(),
) horizontalAlignment = Alignment.CenterHorizontally,
Button( verticalArrangement = Arrangement.Center
onClick = { ) {
if (messageText.isNotBlank()) { AnimatedAIAgent(
viewModel.sendMessage("userId", messageText) isSpeaking = uiState.isLoading,
messageText = "" 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
)
}
}