Пока просто накидал форму чата
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user