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

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> {
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)
}

View File

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

View File

@@ -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
)
}
}