Compare commits
8 Commits
3a990a1caa
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b41de4aaf5 | |||
| 5105e68970 | |||
| 59087fe6d9 | |||
| c56399dde9 | |||
| b809b1e6e6 | |||
| 103c69d7d1 | |||
| 62c4fdb997 | |||
| 98327bb9f4 |
2
.idea/gradle.xml
generated
2
.idea/gradle.xml
generated
@@ -4,7 +4,6 @@
|
|||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
@@ -13,6 +12,7 @@
|
|||||||
<option value="$PROJECT_DIR$/app" />
|
<option value="$PROJECT_DIR$/app" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
|
<option name="resolveExternalAnnotations" value="false" />
|
||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -28,39 +28,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
|
|
||||||
// Нижняя панель не показывается на экранах входа, регистрации и восстановления пароля
|
// Нижняя панель скрыта везде (используется левая панель навигации)
|
||||||
val showBottomBar = currentRoute != null &&
|
|
||||||
currentRoute != "login" &&
|
|
||||||
currentRoute != "registration" &&
|
|
||||||
currentRoute != "forgot_password"
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize()
|
||||||
bottomBar = {
|
|
||||||
if (showBottomBar) {
|
|
||||||
BottomNavigationBar(
|
|
||||||
currentRoute = currentRoute,
|
|
||||||
onNavigate = { route ->
|
|
||||||
navController.navigate(route) {
|
|
||||||
popUpTo(navController.graph.startDestinationId) {
|
|
||||||
saveState = true
|
|
||||||
}
|
|
||||||
launchSingleTop = true
|
|
||||||
restoreState = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
NewPlanetNavigation(
|
NewPlanetNavigation(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(
|
|
||||||
// Убираем нижний отступ на экране входа
|
|
||||||
bottom = if (showBottomBar) innerPadding.calculateBottomPadding() else 0.dp
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = response.body()!!.message
|
|
||||||
|
|
||||||
// Save AI response
|
val aiResponse = "!!!!"
|
||||||
val aiMessage = ChatMessage(
|
|
||||||
id = UUID.randomUUID().toString(),
|
|
||||||
message = aiResponse,
|
|
||||||
isFromAI = true,
|
|
||||||
timestamp = LocalDateTime.now(),
|
|
||||||
userId = userId
|
|
||||||
)
|
|
||||||
chatMessageDao.insertMessage(aiMessage.toEntity())
|
|
||||||
|
|
||||||
Result.success(aiResponse)
|
// Save AI response
|
||||||
} else {
|
val aiMessage = ChatMessage(
|
||||||
Result.failure(Exception("Failed to get AI response"))
|
id = UUID.randomUUID().toString(),
|
||||||
}
|
message = aiResponse,
|
||||||
} else {
|
isFromAI = true,
|
||||||
Result.failure(Exception("User not authenticated"))
|
timestamp = LocalDateTime.now(),
|
||||||
}
|
userId = userId
|
||||||
|
)
|
||||||
|
chatMessageDao.insertMessage(aiMessage.toEntity())
|
||||||
|
|
||||||
|
Result.success(aiResponse)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import androidx.navigation.NavHostController
|
|||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
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.LoginScreen
|
import com.novayaplaneta.ui.screens.auth.LoginScreen
|
||||||
|
import com.novayaplaneta.ui.screens.auth.RegistrationScreen
|
||||||
import com.novayaplaneta.ui.screens.rewards.RewardsScreen
|
import com.novayaplaneta.ui.screens.rewards.RewardsScreen
|
||||||
import com.novayaplaneta.ui.screens.schedule.ScheduleScreen
|
import com.novayaplaneta.ui.screens.schedule.ScheduleScreen
|
||||||
import com.novayaplaneta.ui.screens.settings.SettingsScreen
|
import com.novayaplaneta.ui.screens.settings.SettingsScreen
|
||||||
@@ -27,14 +29,14 @@ fun NewPlanetNavigation(
|
|||||||
composable("login") {
|
composable("login") {
|
||||||
LoginScreen(navController = navController)
|
LoginScreen(navController = navController)
|
||||||
}
|
}
|
||||||
// composable("registration") {
|
composable("registration") {
|
||||||
// RegistrationScreen(navController = navController)
|
RegistrationScreen(navController = navController)
|
||||||
// }
|
}
|
||||||
// composable("forgot_password") {
|
composable("forgot_password") {
|
||||||
// ForgotPasswordScreen(navController = navController)
|
ForgotPasswordScreen(navController = navController)
|
||||||
// }
|
}
|
||||||
composable("schedule") {
|
composable("schedule") {
|
||||||
ScheduleScreen()
|
ScheduleScreen(navController = navController)
|
||||||
}
|
}
|
||||||
composable("tasks") {
|
composable("tasks") {
|
||||||
TaskScreen()
|
TaskScreen()
|
||||||
@@ -43,13 +45,13 @@ fun NewPlanetNavigation(
|
|||||||
TimerScreen()
|
TimerScreen()
|
||||||
}
|
}
|
||||||
composable("rewards") {
|
composable("rewards") {
|
||||||
RewardsScreen()
|
RewardsScreen(navController = navController)
|
||||||
}
|
}
|
||||||
composable("ai") {
|
composable("ai") {
|
||||||
AIScreen()
|
AIScreen(navController = navController)
|
||||||
}
|
}
|
||||||
composable("settings") {
|
composable("settings") {
|
||||||
SettingsScreen()
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.novayaplaneta.ui.screens.auth
|
||||||
|
|
||||||
|
object EmailValidator {
|
||||||
|
// Стандартный паттерн для валидации email
|
||||||
|
private val EMAIL_PATTERN = android.util.Patterns.EMAIL_ADDRESS
|
||||||
|
|
||||||
|
fun isValid(email: String): Boolean {
|
||||||
|
return email.isNotBlank() && EMAIL_PATTERN.matcher(email).matches()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
package com.novayaplaneta.ui.screens.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
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.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.novayaplaneta.ui.components.NovayaPlanetaLogo
|
||||||
|
import com.novayaplaneta.ui.theme.*
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ForgotPasswordScreen(
|
||||||
|
navController: NavController,
|
||||||
|
viewModel: ForgotPasswordViewModel = hiltViewModel(),
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.errorMessage) {
|
||||||
|
val errorMsg = uiState.errorMessage
|
||||||
|
if (errorMsg != null) {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
message = errorMsg,
|
||||||
|
actionLabel = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = {
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
snackbar = { snackbarData ->
|
||||||
|
Snackbar(
|
||||||
|
snackbarData = snackbarData,
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
containerColor = LoginBackgroundTurquoise
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.background(color = LoginBackgroundTurquoise)
|
||||||
|
) {
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val screenWidthDp: Int = configuration.screenWidthDp
|
||||||
|
val screenHeightDp: Int = configuration.screenHeightDp
|
||||||
|
val isLandscape = screenWidthDp > screenHeightDp
|
||||||
|
|
||||||
|
// Адаптивные отступы
|
||||||
|
val horizontalPadding = (screenWidthDp * 0.04f).toInt().coerceIn(24, 48).dp
|
||||||
|
val verticalPadding = (screenHeightDp * 0.03f).toInt().coerceIn(16, 32).dp
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = horizontalPadding, vertical = verticalPadding)
|
||||||
|
) {
|
||||||
|
// Логотип вверху слева - увеличенный размер
|
||||||
|
val logoSizeByHeight = (screenHeightDp * 0.28f).toInt()
|
||||||
|
val logoSizeByWidth = screenWidthDp / 3
|
||||||
|
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(180, 280).dp
|
||||||
|
|
||||||
|
NovayaPlanetaLogo(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.padding(bottom = 4.dp)
|
||||||
|
.clickable {
|
||||||
|
navController.navigate("login") {
|
||||||
|
popUpTo("login") { inclusive = false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
size = logoSize
|
||||||
|
)
|
||||||
|
|
||||||
|
// Центрированный контент - адаптивная ширина (50-70% экрана)
|
||||||
|
val contentWidthRatio = if (isLandscape) 0.5f else 0.7f
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.fillMaxWidth(contentWidthRatio),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок "Восстановление пароля"
|
||||||
|
val titleFontSize = (screenHeightDp * 0.06f).toInt().coerceIn(44, 76).sp
|
||||||
|
Text(
|
||||||
|
text = "Восстановление пароля",
|
||||||
|
fontSize = titleFontSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = LoginGreenAccent,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поля ввода - адаптивные размеры
|
||||||
|
val inputHeightValue = (screenHeightDp * 0.075f).toInt().coerceIn(64, 100)
|
||||||
|
val inputHeight: androidx.compose.ui.unit.Dp = inputHeightValue.dp
|
||||||
|
val inputTextSizeValue = (screenHeightDp * 0.026f).toInt().coerceIn(20, 28)
|
||||||
|
val inputTextSize: androidx.compose.ui.unit.TextUnit = inputTextSizeValue.sp
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Поле email (всегда видимо)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height = inputHeight)
|
||||||
|
.background(
|
||||||
|
color = if (uiState.emailError != null) {
|
||||||
|
Color(0xFFFFEBEE) // Светло-красный фон при ошибке
|
||||||
|
} else {
|
||||||
|
LoginInputLightBlue
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(20.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = uiState.email,
|
||||||
|
onValueChange = { viewModel.onEmailChange(it) },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "Введи email",
|
||||||
|
fontSize = inputTextSize,
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedTextColor = Color.Black
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
|
isError = uiState.emailError != null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сообщение об ошибке email
|
||||||
|
if (uiState.emailError != null) {
|
||||||
|
Text(
|
||||||
|
text = uiState.emailError!!,
|
||||||
|
fontSize = (inputTextSizeValue * 0.8f).toInt().sp,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поле кода (показывается после ввода email)
|
||||||
|
if (uiState.showCodeField) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height = inputHeight)
|
||||||
|
.background(
|
||||||
|
color = LoginInputLightBlue,
|
||||||
|
shape = RoundedCornerShape(20.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = uiState.code,
|
||||||
|
onValueChange = { viewModel.onCodeChange(it) },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "Введи код из письма",
|
||||||
|
fontSize = inputTextSize,
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedTextColor = Color.Black
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поля пароля (показываются после ввода кода)
|
||||||
|
if (uiState.showPasswordFields) {
|
||||||
|
// Новый пароль
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height = inputHeight)
|
||||||
|
.background(
|
||||||
|
color = LoginInputLightBlue,
|
||||||
|
shape = RoundedCornerShape(20.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = uiState.newPassword,
|
||||||
|
onValueChange = { viewModel.onNewPasswordChange(it) },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "Введи новый пароль",
|
||||||
|
fontSize = inputTextSize,
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedTextColor = Color.Black
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подтверждение пароля
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height = inputHeight)
|
||||||
|
.background(
|
||||||
|
color = LoginInputLightBlue,
|
||||||
|
shape = RoundedCornerShape(20.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = uiState.confirmPassword,
|
||||||
|
onValueChange = { viewModel.onConfirmPasswordChange(it) },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "Повтори новый пароль",
|
||||||
|
fontSize = inputTextSize,
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedTextColor = Color.Black
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Готово!" - адаптивный размер
|
||||||
|
val isButtonEnabled = when {
|
||||||
|
!uiState.showCodeField -> uiState.isBasicFormValid
|
||||||
|
!uiState.showPasswordFields -> uiState.isCodeFormValid
|
||||||
|
else -> uiState.isFormValid
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.onReadyClick {
|
||||||
|
navController.navigate("login") {
|
||||||
|
popUpTo("login") { inclusive = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height = inputHeight),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
enabled = isButtonEnabled && !uiState.isLoading,
|
||||||
|
colors = if (isButtonEnabled) {
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = LoginGreenAccent,
|
||||||
|
contentColor = Color.White
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = LoginInputLightBlue,
|
||||||
|
contentColor = Color.Gray,
|
||||||
|
disabledContainerColor = LoginInputLightBlue,
|
||||||
|
disabledContentColor = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val buttonTextSize = (screenHeightDp * 0.027f).toInt().coerceIn(22, 34).sp
|
||||||
|
Text(
|
||||||
|
text = "Готово!",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package com.novayaplaneta.ui.screens.auth
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ForgotPasswordViewModel @Inject constructor() : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(ForgotPasswordUiState())
|
||||||
|
val uiState: StateFlow<ForgotPasswordUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun onEmailChange(email: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(email = email)
|
||||||
|
// Если email стал невалидным, скрываем поле кода и сбрасываем его значение
|
||||||
|
if (!EmailValidator.isValid(email) && _uiState.value.showCodeField) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
showCodeField = false,
|
||||||
|
code = "",
|
||||||
|
showPasswordFields = false,
|
||||||
|
newPassword = "",
|
||||||
|
confirmPassword = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onCodeChange(code: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(code = code)
|
||||||
|
// Если код был удален, скрываем поля пароля
|
||||||
|
if (code.isBlank() && _uiState.value.showPasswordFields) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
showPasswordFields = false,
|
||||||
|
newPassword = "",
|
||||||
|
confirmPassword = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onNewPasswordChange(password: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newPassword = password)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onConfirmPasswordChange(password: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(confirmPassword = password)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onReadyClick(onSuccess: () -> Unit) {
|
||||||
|
val currentState = _uiState.value
|
||||||
|
|
||||||
|
// Шаг 1: Если поле кода еще не показано, но email валиден - показываем поле кода
|
||||||
|
if (!currentState.showCodeField && currentState.isEmailValid) {
|
||||||
|
_uiState.value = currentState.copy(showCodeField = true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 2: Если код введен, но поля пароля еще не показаны - показываем поля пароля
|
||||||
|
if (currentState.showCodeField && currentState.code.isNotBlank() && !currentState.showPasswordFields) {
|
||||||
|
_uiState.value = currentState.copy(showPasswordFields = true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 3: Если форма полностью валидна (включая код и пароли), выполняем восстановление пароля
|
||||||
|
if (currentState.isFormValid) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
// TODO: Реализовать вызов API для восстановления пароля
|
||||||
|
// Пока что просто эмулируем успешное восстановление
|
||||||
|
delay(1000)
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ForgotPasswordUiState(
|
||||||
|
val email: String = "",
|
||||||
|
val code: String = "",
|
||||||
|
val newPassword: String = "",
|
||||||
|
val confirmPassword: String = "",
|
||||||
|
val showCodeField: Boolean = false,
|
||||||
|
val showPasswordFields: Boolean = false,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val errorMessage: String? = null
|
||||||
|
) {
|
||||||
|
// Валидация email
|
||||||
|
val isEmailValid: Boolean
|
||||||
|
get() = EmailValidator.isValid(email)
|
||||||
|
|
||||||
|
// Сообщение об ошибке email
|
||||||
|
val emailError: String?
|
||||||
|
get() = if (email.isNotBlank() && !isEmailValid) {
|
||||||
|
"Введите корректный email"
|
||||||
|
} else null
|
||||||
|
|
||||||
|
// Валидация для первой кнопки (только email)
|
||||||
|
val isBasicFormValid: Boolean
|
||||||
|
get() = isEmailValid
|
||||||
|
|
||||||
|
// Валидация для второй кнопки (email + код)
|
||||||
|
val isCodeFormValid: Boolean
|
||||||
|
get() = isEmailValid && code.isNotBlank()
|
||||||
|
|
||||||
|
// Валидация полной формы (включая код и пароли)
|
||||||
|
val isFormValid: Boolean
|
||||||
|
get() = isEmailValid &&
|
||||||
|
code.isNotBlank() &&
|
||||||
|
newPassword.isNotBlank() &&
|
||||||
|
confirmPassword.isNotBlank() &&
|
||||||
|
newPassword == confirmPassword
|
||||||
|
}
|
||||||
|
|
||||||
@@ -76,33 +76,28 @@ fun LoginScreen(
|
|||||||
val horizontalPadding = (screenWidthDp * 0.04f).toInt().coerceIn(24, 48).dp
|
val horizontalPadding = (screenWidthDp * 0.04f).toInt().coerceIn(24, 48).dp
|
||||||
val verticalPadding = (screenHeightDp * 0.03f).toInt().coerceIn(16, 32).dp
|
val verticalPadding = (screenHeightDp * 0.03f).toInt().coerceIn(16, 32).dp
|
||||||
|
|
||||||
Column(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
.padding(horizontal = horizontalPadding, vertical = verticalPadding)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
) {
|
||||||
// Логотип вверху слева - уменьшенный размер
|
// Логотип вверху слева - большой размер
|
||||||
val logoSizeByHeight = (screenHeightDp * 0.15f).toInt()
|
val logoSizeByHeight = (screenHeightDp * 0.28f).toInt()
|
||||||
val logoSizeByWidth = screenWidthDp / 5
|
val logoSizeByWidth = screenWidthDp / 3
|
||||||
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(100, 160).dp
|
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(180, 280).dp
|
||||||
|
|
||||||
Row(
|
NovayaPlanetaLogo(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
horizontalArrangement = Arrangement.Start
|
.align(Alignment.TopStart)
|
||||||
) {
|
.padding(bottom = 4.dp),
|
||||||
NovayaPlanetaLogo(
|
size = logoSize
|
||||||
modifier = Modifier.padding(bottom = 4.dp),
|
)
|
||||||
size = logoSize
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(0.05f))
|
|
||||||
|
|
||||||
// Центрированный контент - адаптивная ширина (50-70% экрана)
|
// Центрированный контент - адаптивная ширина (50-70% экрана)
|
||||||
val contentWidthRatio = if (isLandscape) 0.5f else 0.7f
|
val contentWidthRatio = if (isLandscape) 0.5f else 0.7f
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
.fillMaxWidth(contentWidthRatio),
|
.fillMaxWidth(contentWidthRatio),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
@@ -212,10 +207,13 @@ fun LoginScreen(
|
|||||||
// Кнопка "Войти" - адаптивный размер
|
// Кнопка "Войти" - адаптивный размер
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.login {
|
if (uiState.isFormValid) {
|
||||||
|
// Переход на экран расписания при заполненных полях
|
||||||
navController.navigate("schedule") {
|
navController.navigate("schedule") {
|
||||||
popUpTo(0) { inclusive = true }
|
popUpTo(0) { inclusive = true }
|
||||||
}
|
}
|
||||||
|
// Также вызываем логин для проверки через API (в фоне)
|
||||||
|
viewModel.login { }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -225,7 +223,7 @@ fun LoginScreen(
|
|||||||
enabled = uiState.isFormValid && !uiState.isLoading,
|
enabled = uiState.isFormValid && !uiState.isLoading,
|
||||||
colors = if (uiState.isFormValid) {
|
colors = if (uiState.isFormValid) {
|
||||||
ButtonDefaults.buttonColors(
|
ButtonDefaults.buttonColors(
|
||||||
containerColor = LoginButtonBlue,
|
containerColor = LoginGreenAccent,
|
||||||
contentColor = Color.White
|
contentColor = Color.White
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -258,12 +256,12 @@ fun LoginScreen(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
val linkTextSize = (screenHeightDp * 0.024f).toInt().coerceIn(18, 26).sp
|
val linkTextSize = (screenHeightDp * 0.032f).toInt().coerceIn(24, 32).sp
|
||||||
Text(
|
Text(
|
||||||
text = "Нет логина и пароля?",
|
text = "Нет логина и пароля?",
|
||||||
fontSize = linkTextSize,
|
fontSize = linkTextSize,
|
||||||
color = LoginGreenAccent,
|
color = LoginGreenAccent,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
navController.navigate("registration")
|
navController.navigate("registration")
|
||||||
}
|
}
|
||||||
@@ -273,15 +271,13 @@ fun LoginScreen(
|
|||||||
text = "Не помнишь пароль?",
|
text = "Не помнишь пароль?",
|
||||||
fontSize = linkTextSize,
|
fontSize = linkTextSize,
|
||||||
color = LoginGreenAccent,
|
color = LoginGreenAccent,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
navController.navigate("forgot_password")
|
navController.navigate("forgot_password")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(0.1f))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
package com.novayaplaneta.ui.screens.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
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.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.novayaplaneta.ui.components.NovayaPlanetaLogo
|
||||||
|
import com.novayaplaneta.ui.theme.*
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RegistrationScreen(
|
||||||
|
navController: NavController,
|
||||||
|
viewModel: RegistrationViewModel = hiltViewModel(),
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.errorMessage) {
|
||||||
|
val errorMsg = uiState.errorMessage
|
||||||
|
if (errorMsg != null) {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
message = errorMsg,
|
||||||
|
actionLabel = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = {
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
snackbar = { snackbarData ->
|
||||||
|
Snackbar(
|
||||||
|
snackbarData = snackbarData,
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
containerColor = LoginBackgroundTurquoise
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.background(color = LoginBackgroundTurquoise)
|
||||||
|
) {
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val screenWidthDp: Int = configuration.screenWidthDp
|
||||||
|
val screenHeightDp: Int = configuration.screenHeightDp
|
||||||
|
val isLandscape = screenWidthDp > screenHeightDp
|
||||||
|
|
||||||
|
// Адаптивные отступы
|
||||||
|
val horizontalPadding = (screenWidthDp * 0.04f).toInt().coerceIn(24, 48).dp
|
||||||
|
val verticalPadding = (screenHeightDp * 0.03f).toInt().coerceIn(16, 32).dp
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = horizontalPadding, vertical = verticalPadding)
|
||||||
|
) {
|
||||||
|
// Логотип вверху слева - увеличенный размер
|
||||||
|
val logoSizeByHeight = (screenHeightDp * 0.28f).toInt()
|
||||||
|
val logoSizeByWidth = screenWidthDp / 3
|
||||||
|
val logoSize = min(logoSizeByHeight, logoSizeByWidth).coerceIn(180, 280).dp
|
||||||
|
|
||||||
|
NovayaPlanetaLogo(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.padding(bottom = 4.dp)
|
||||||
|
.clickable {
|
||||||
|
navController.navigate("login") {
|
||||||
|
popUpTo("login") { inclusive = false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
size = logoSize
|
||||||
|
)
|
||||||
|
|
||||||
|
// Центрированный контент - адаптивная ширина (50-70% экрана)
|
||||||
|
val contentWidthRatio = if (isLandscape) 0.5f else 0.7f
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.fillMaxWidth(contentWidthRatio),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок "Регистрация"
|
||||||
|
val titleFontSize = (screenHeightDp * 0.06f).toInt().coerceIn(44, 76).sp
|
||||||
|
Text(
|
||||||
|
text = "Регистрация",
|
||||||
|
fontSize = titleFontSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = LoginGreenAccent,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поля ввода - адаптивные размеры
|
||||||
|
val inputHeightValue = (screenHeightDp * 0.075f).toInt().coerceIn(64, 100)
|
||||||
|
val inputHeight: androidx.compose.ui.unit.Dp = inputHeightValue.dp
|
||||||
|
val inputTextSizeValue = (screenHeightDp * 0.026f).toInt().coerceIn(20, 28)
|
||||||
|
val inputTextSize: androidx.compose.ui.unit.TextUnit = inputTextSizeValue.sp
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Поле логина
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height = inputHeight)
|
||||||
|
.background(
|
||||||
|
color = LoginInputLightBlue,
|
||||||
|
shape = RoundedCornerShape(20.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = uiState.login,
|
||||||
|
onValueChange = { viewModel.onLoginChange(it) },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "Введи логин",
|
||||||
|
fontSize = inputTextSize,
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedTextColor = Color.Black
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поле пароля
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height = inputHeight)
|
||||||
|
.background(
|
||||||
|
color = LoginInputLightBlue,
|
||||||
|
shape = RoundedCornerShape(20.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = uiState.password,
|
||||||
|
onValueChange = { viewModel.onPasswordChange(it) },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "Введи пароль",
|
||||||
|
fontSize = inputTextSize,
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedTextColor = Color.Black
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поле повторения пароля
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height = inputHeight)
|
||||||
|
.background(
|
||||||
|
color = LoginInputLightBlue,
|
||||||
|
shape = RoundedCornerShape(20.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = uiState.confirmPassword,
|
||||||
|
onValueChange = { viewModel.onConfirmPasswordChange(it) },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "Повтори пароль",
|
||||||
|
fontSize = inputTextSize,
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedTextColor = Color.Black
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поле email
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height = inputHeight)
|
||||||
|
.background(
|
||||||
|
color = if (uiState.emailError != null) {
|
||||||
|
Color(0xFFFFEBEE) // Светло-красный фон при ошибке
|
||||||
|
} else {
|
||||||
|
LoginInputLightBlue
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(20.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = uiState.email,
|
||||||
|
onValueChange = { viewModel.onEmailChange(it) },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "Введи email",
|
||||||
|
fontSize = inputTextSize,
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedTextColor = Color.Black
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
|
isError = uiState.emailError != null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сообщение об ошибке email
|
||||||
|
if (uiState.emailError != null) {
|
||||||
|
Text(
|
||||||
|
text = uiState.emailError!!,
|
||||||
|
fontSize = (inputTextSizeValue * 0.8f).toInt().sp,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поле кода (показывается после нажатия "Готово!" когда все 4 поля заполнены)
|
||||||
|
if (uiState.showCodeField) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height = inputHeight)
|
||||||
|
.background(
|
||||||
|
color = LoginInputLightBlue,
|
||||||
|
shape = RoundedCornerShape(20.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = uiState.code,
|
||||||
|
onValueChange = { viewModel.onCodeChange(it) },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "Введи код из письма на почте",
|
||||||
|
fontSize = inputTextSize,
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedTextColor = Color.Black
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Готово!" - адаптивный размер
|
||||||
|
// Активна когда заполнены все 4 поля (до показа кода) или когда заполнен код
|
||||||
|
val isButtonEnabled = if (!uiState.showCodeField) {
|
||||||
|
uiState.isBasicFormValid && !uiState.isLoading
|
||||||
|
} else {
|
||||||
|
uiState.isFormValid && !uiState.isLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
val buttonColor = if (isButtonEnabled) {
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = LoginGreenAccent,
|
||||||
|
contentColor = Color.White
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = LoginInputLightBlue,
|
||||||
|
contentColor = Color.Gray,
|
||||||
|
disabledContainerColor = LoginInputLightBlue,
|
||||||
|
disabledContentColor = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.onReadyClick {
|
||||||
|
navController.navigate("login") {
|
||||||
|
popUpTo("login") { inclusive = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height = inputHeight),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
enabled = isButtonEnabled,
|
||||||
|
colors = buttonColor
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val buttonTextSize = (screenHeightDp * 0.027f).toInt().coerceIn(22, 34).sp
|
||||||
|
Text(
|
||||||
|
text = "Готово!",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.novayaplaneta.ui.screens.auth
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class RegistrationViewModel @Inject constructor() : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(RegistrationUiState())
|
||||||
|
val uiState: StateFlow<RegistrationUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun onLoginChange(login: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(login = login)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPasswordChange(password: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(password = password)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onConfirmPasswordChange(password: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(confirmPassword = password)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEmailChange(email: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(email = email)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onCodeChange(code: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(code = code)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onReadyClick(onSuccess: () -> Unit) {
|
||||||
|
val currentState = _uiState.value
|
||||||
|
|
||||||
|
// Если поле кода еще не показано, но все 4 поля заполнены - показываем поле кода
|
||||||
|
if (!currentState.showCodeField && currentState.isBasicFormValid) {
|
||||||
|
_uiState.value = currentState.copy(showCodeField = true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если форма полностью валидна (включая код), выполняем регистрацию
|
||||||
|
if (currentState.isFormValid) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
|
// TODO: Реализовать вызов API для регистрации
|
||||||
|
// Пока что просто эмулируем успешную регистрацию
|
||||||
|
delay(1000)
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RegistrationUiState(
|
||||||
|
val login: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
val confirmPassword: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
val code: String = "",
|
||||||
|
val showCodeField: Boolean = false,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val errorMessage: String? = null
|
||||||
|
) {
|
||||||
|
// Валидация email
|
||||||
|
val isEmailValid: Boolean
|
||||||
|
get() = EmailValidator.isValid(email)
|
||||||
|
|
||||||
|
// Сообщение об ошибке email
|
||||||
|
val emailError: String?
|
||||||
|
get() = if (email.isNotBlank() && !isEmailValid) {
|
||||||
|
"Введите корректный email"
|
||||||
|
} else null
|
||||||
|
|
||||||
|
// Валидация первых 4 полей (без кода)
|
||||||
|
val isBasicFormValid: Boolean
|
||||||
|
get() = login.isNotBlank() &&
|
||||||
|
password.isNotBlank() &&
|
||||||
|
confirmPassword.isNotBlank() &&
|
||||||
|
isEmailValid &&
|
||||||
|
password == confirmPassword
|
||||||
|
|
||||||
|
// Валидация полной формы (включая код)
|
||||||
|
val isFormValid: Boolean
|
||||||
|
get() = isBasicFormValid &&
|
||||||
|
code.isNotBlank()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,82 +1,465 @@
|
|||||||
package com.novayaplaneta.ui.screens.rewards
|
package com.novayaplaneta.ui.screens.rewards
|
||||||
|
|
||||||
|
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.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
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.Star
|
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.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.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
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.compose.ui.window.Dialog
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.novayaplaneta.ui.components.NovayaPlanetaLogo
|
||||||
|
import com.novayaplaneta.ui.theme.*
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RewardsScreen(
|
fun RewardsScreen(
|
||||||
|
navController: androidx.navigation.NavController? = null,
|
||||||
viewModel: RewardsViewModel = hiltViewModel(),
|
viewModel: RewardsViewModel = hiltViewModel(),
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val screenWidthDp = configuration.screenWidthDp
|
||||||
|
val screenHeightDp = configuration.screenHeightDp
|
||||||
|
|
||||||
Scaffold(
|
// Цвета из autism-friendly палитры
|
||||||
topBar = {
|
val backgroundColor = LoginBackgroundTurquoise
|
||||||
TopAppBar(
|
val navPanelColor = LoginInputLightBlue
|
||||||
title = { Text("Награды") }
|
val accentGreen = LoginGreenAccent
|
||||||
)
|
val dateCardColor = LoginInputLightBlue
|
||||||
}
|
|
||||||
) { paddingValues ->
|
// Форматирование даты и времени по московскому времени
|
||||||
Column(
|
val moscowTimeZone = TimeZone.getTimeZone("Europe/Moscow")
|
||||||
modifier = modifier
|
val dateFormat = SimpleDateFormat("dd.MM EEEE", Locale("ru")).apply {
|
||||||
.fillMaxSize()
|
timeZone = moscowTimeZone
|
||||||
.padding(paddingValues)
|
}
|
||||||
.padding(16.dp)
|
val timeFormat = SimpleDateFormat("HH:mm", Locale("ru")).apply {
|
||||||
|
timeZone = moscowTimeZone
|
||||||
|
}
|
||||||
|
val currentDate = dateFormat.format(Date())
|
||||||
|
val currentTime = timeFormat.format(Date())
|
||||||
|
val dayOfWeek = currentDate.split(" ").getOrNull(1) ?: ""
|
||||||
|
val dateOnly = currentDate.split(" ").getOrNull(0) ?: ""
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(color = backgroundColor)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
// Левая панель навигации с логотипом над ней (с отступами и закругленными краями)
|
||||||
CircularProgressIndicator(
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.width((screenWidthDp * 0.22f).dp.coerceIn(160.dp, 240.dp))
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(vertical = 20.dp, horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
// Логотип над панелью навигации
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.wrapContentWidth()
|
.padding(bottom = 16.dp),
|
||||||
)
|
contentAlignment = Alignment.Center
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
) {
|
||||||
items(uiState.rewards) { reward ->
|
val logoSize = (screenHeightDp * 0.22f).toInt().coerceIn(140, 240).dp
|
||||||
Card(
|
NovayaPlanetaLogo(size = logoSize)
|
||||||
modifier = Modifier.fillMaxWidth()
|
}
|
||||||
) {
|
|
||||||
Row(
|
// Панель навигации (закругленная со всех сторон)
|
||||||
modifier = Modifier
|
Column(
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.padding(16.dp)
|
.fillMaxWidth()
|
||||||
) {
|
.weight(1f)
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
.clip(RoundedCornerShape(32.dp))
|
||||||
Text(
|
.background(color = navPanelColor)
|
||||||
text = reward.title,
|
.padding(vertical = 24.dp, horizontal = 16.dp),
|
||||||
style = MaterialTheme.typography.titleLarge
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
)
|
verticalArrangement = Arrangement.spacedBy(32.dp)
|
||||||
reward.description?.let {
|
) {
|
||||||
Text(
|
// Расписание
|
||||||
text = it,
|
NavItem(
|
||||||
style = MaterialTheme.typography.bodyMedium
|
icon = Icons.Filled.CalendarToday,
|
||||||
)
|
text = "Расписание",
|
||||||
}
|
isSelected = false,
|
||||||
Text(
|
onClick = { navController?.navigate("schedule") }
|
||||||
text = "${reward.points} очков",
|
)
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
// Награды (активный)
|
||||||
}
|
NavItem(
|
||||||
if (reward.earnedAt != null) {
|
icon = Icons.Filled.Star,
|
||||||
Icon(
|
text = "Награды",
|
||||||
imageVector = androidx.compose.material.icons.Icons.Default.Star,
|
isSelected = true,
|
||||||
contentDescription = "Получено",
|
onClick = { }
|
||||||
tint = MaterialTheme.colorScheme.primary
|
)
|
||||||
)
|
|
||||||
}
|
// Профиль
|
||||||
}
|
NavItem(
|
||||||
|
icon = Icons.Filled.Person,
|
||||||
|
text = "Профиль",
|
||||||
|
isSelected = false,
|
||||||
|
onClick = { navController?.navigate("settings") }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Земля
|
||||||
|
NavItem(
|
||||||
|
icon = Icons.Filled.Public,
|
||||||
|
text = "Земля",
|
||||||
|
isSelected = false,
|
||||||
|
onClick = { navController?.navigate("ai") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Основная область
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
// Верхняя панель: только дата/время справа
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Дата и время (зеленый цвет)
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.End
|
||||||
|
) {
|
||||||
|
val dateTextSize = (screenHeightDp * 0.06f).toInt().coerceIn(48, 80).sp
|
||||||
|
Text(
|
||||||
|
text = "$dateOnly $dayOfWeek",
|
||||||
|
fontSize = dateTextSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = accentGreen
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = currentTime,
|
||||||
|
fontSize = dateTextSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = accentGreen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(48.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)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Список наград в сетке (несколько колонок)
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = accentGreen)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(minSize = 200.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(uiState.rewards) { reward ->
|
||||||
|
RewardCard(
|
||||||
|
reward = reward,
|
||||||
|
screenHeightDp = screenHeightDp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Кнопка добавления в конце списка
|
||||||
|
item {
|
||||||
|
AddRewardButton(
|
||||||
|
onClick = { viewModel.showAddDialog() },
|
||||||
|
screenHeightDp = screenHeightDp,
|
||||||
|
accentGreen = accentGreen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог добавления награды
|
||||||
|
if (uiState.showAddDialog) {
|
||||||
|
AddRewardDialog(
|
||||||
|
rewardTitle = uiState.newRewardTitle,
|
||||||
|
onTitleChange = { viewModel.updateNewRewardTitle(it) },
|
||||||
|
onAdd = { viewModel.addReward("default") },
|
||||||
|
onDismiss = { viewModel.hideAddDialog() },
|
||||||
|
accentGreen = accentGreen,
|
||||||
|
screenHeightDp = screenHeightDp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RewardCard(
|
||||||
|
reward: com.novayaplaneta.domain.model.Reward,
|
||||||
|
screenHeightDp: Int
|
||||||
|
) {
|
||||||
|
val cardHeight = 180.dp
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(cardHeight)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(color = Color.LightGray)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
// Верхняя часть (для изображения) - серая область со звездой
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(0.6f)
|
||||||
|
.background(color = Color.White.copy(alpha = 0.5f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Star,
|
||||||
|
contentDescription = reward.title,
|
||||||
|
modifier = Modifier.size(60.dp),
|
||||||
|
tint = AccentGold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нижняя часть (для текста) - темно-серая область с названием
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(0.4f)
|
||||||
|
.background(color = Color.LightGray)
|
||||||
|
.padding(12.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp
|
||||||
|
Text(
|
||||||
|
text = reward.title,
|
||||||
|
fontSize = textSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddRewardButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
screenHeightDp: Int,
|
||||||
|
accentGreen: Color
|
||||||
|
) {
|
||||||
|
val cardHeight = 180.dp
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(cardHeight)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(color = accentGreen)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Add,
|
||||||
|
contentDescription = "Добавить награду",
|
||||||
|
modifier = Modifier.size(60.dp),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddRewardDialog(
|
||||||
|
rewardTitle: String,
|
||||||
|
onTitleChange: (String) -> Unit,
|
||||||
|
onAdd: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
accentGreen: Color,
|
||||||
|
screenHeightDp: Int
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.8f)
|
||||||
|
.wrapContentHeight(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color.White
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок
|
||||||
|
val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp
|
||||||
|
Text(
|
||||||
|
text = "Добавить награду",
|
||||||
|
fontSize = titleSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле ввода названия награды
|
||||||
|
val inputTextSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 22).sp
|
||||||
|
OutlinedTextField(
|
||||||
|
value = rewardTitle,
|
||||||
|
onValueChange = onTitleChange,
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "Введите название награды",
|
||||||
|
fontSize = inputTextSize,
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = Color.Black,
|
||||||
|
unfocusedTextColor = Color.Black,
|
||||||
|
focusedBorderColor = accentGreen,
|
||||||
|
unfocusedBorderColor = Color.Gray
|
||||||
|
),
|
||||||
|
textStyle = androidx.compose.ui.text.TextStyle(
|
||||||
|
fontSize = inputTextSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Кнопки внизу
|
||||||
|
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
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
||||||
|
Text(
|
||||||
|
text = "Назад",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Добавить"
|
||||||
|
Button(
|
||||||
|
onClick = onAdd,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(56.dp),
|
||||||
|
enabled = rewardTitle.isNotBlank(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = accentGreen,
|
||||||
|
contentColor = Color.White,
|
||||||
|
disabledContainerColor = Color.LightGray,
|
||||||
|
disabledContentColor = Color.Gray
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
||||||
|
Text(
|
||||||
|
text = "Добавить",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.novayaplaneta.ui.screens.rewards
|
|||||||
|
|
||||||
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.repository.RewardRepository
|
import com.novayaplaneta.domain.repository.RewardRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -18,12 +19,96 @@ class RewardsViewModel @Inject constructor(
|
|||||||
private val _uiState = MutableStateFlow(RewardsUiState())
|
private val _uiState = MutableStateFlow(RewardsUiState())
|
||||||
val uiState: StateFlow<RewardsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<RewardsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadDefaultRewards()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadDefaultRewards() {
|
||||||
|
// Создаем список наград по умолчанию
|
||||||
|
val defaultRewards = listOf(
|
||||||
|
Reward(
|
||||||
|
id = "reward_1",
|
||||||
|
title = "Золотая звезда",
|
||||||
|
description = "За выполнение 3 задач",
|
||||||
|
imageUrl = null,
|
||||||
|
points = 10,
|
||||||
|
earnedAt = null,
|
||||||
|
userId = "default"
|
||||||
|
),
|
||||||
|
Reward(
|
||||||
|
id = "reward_2",
|
||||||
|
title = "Подарочная коробка",
|
||||||
|
description = "За выполнение 5 задач",
|
||||||
|
imageUrl = null,
|
||||||
|
points = 20,
|
||||||
|
earnedAt = null,
|
||||||
|
userId = "default"
|
||||||
|
),
|
||||||
|
Reward(
|
||||||
|
id = "reward_3",
|
||||||
|
title = "Игрушка",
|
||||||
|
description = "За выполнение всех задач дня",
|
||||||
|
imageUrl = null,
|
||||||
|
points = 30,
|
||||||
|
earnedAt = null,
|
||||||
|
userId = "default"
|
||||||
|
),
|
||||||
|
Reward(
|
||||||
|
id = "reward_4",
|
||||||
|
title = "Мультфильм",
|
||||||
|
description = "За неделю без пропусков",
|
||||||
|
imageUrl = null,
|
||||||
|
points = 50,
|
||||||
|
earnedAt = null,
|
||||||
|
userId = "default"
|
||||||
|
),
|
||||||
|
Reward(
|
||||||
|
id = "reward_5",
|
||||||
|
title = "Поход в парк",
|
||||||
|
description = "За месяц работы",
|
||||||
|
imageUrl = null,
|
||||||
|
points = 100,
|
||||||
|
earnedAt = null,
|
||||||
|
userId = "default"
|
||||||
|
),
|
||||||
|
Reward(
|
||||||
|
id = "reward_6",
|
||||||
|
title = "Новая книга",
|
||||||
|
description = "За выполнение 10 задач",
|
||||||
|
imageUrl = null,
|
||||||
|
points = 40,
|
||||||
|
earnedAt = null,
|
||||||
|
userId = "default"
|
||||||
|
),
|
||||||
|
Reward(
|
||||||
|
id = "reward_7",
|
||||||
|
title = "Любимая игра",
|
||||||
|
description = "За хорошее поведение",
|
||||||
|
imageUrl = null,
|
||||||
|
points = 25,
|
||||||
|
earnedAt = null,
|
||||||
|
userId = "default"
|
||||||
|
),
|
||||||
|
Reward(
|
||||||
|
id = "reward_8",
|
||||||
|
title = "Специальный обед",
|
||||||
|
description = "За старание в учебе",
|
||||||
|
imageUrl = null,
|
||||||
|
points = 35,
|
||||||
|
earnedAt = null,
|
||||||
|
userId = "default"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(rewards = defaultRewards, isLoading = false)
|
||||||
|
}
|
||||||
|
|
||||||
fun loadRewards(userId: String) {
|
fun loadRewards(userId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
rewardRepository.getRewards(userId).collect { rewards ->
|
rewardRepository.getRewards(userId).collect { rewards ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
rewards = rewards,
|
rewards = if (rewards.isEmpty()) _uiState.value.rewards else rewards,
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -35,10 +120,49 @@ class RewardsViewModel @Inject constructor(
|
|||||||
rewardRepository.earnReward(userId, rewardId)
|
rewardRepository.earnReward(userId, rewardId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showAddDialog() {
|
||||||
|
_uiState.value = _uiState.value.copy(showAddDialog = true, newRewardTitle = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideAddDialog() {
|
||||||
|
_uiState.value = _uiState.value.copy(showAddDialog = false, newRewardTitle = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewRewardTitle(title: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newRewardTitle = title)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addReward(userId: String) {
|
||||||
|
val title = _uiState.value.newRewardTitle.trim()
|
||||||
|
if (title.isBlank()) return
|
||||||
|
|
||||||
|
val newReward = Reward(
|
||||||
|
id = "reward_${System.currentTimeMillis()}",
|
||||||
|
title = title,
|
||||||
|
description = null,
|
||||||
|
imageUrl = null,
|
||||||
|
points = 10, // Базовое количество очков
|
||||||
|
earnedAt = null,
|
||||||
|
userId = userId
|
||||||
|
)
|
||||||
|
|
||||||
|
// Добавляем новую награду в список
|
||||||
|
val currentRewards = _uiState.value.rewards.toMutableList()
|
||||||
|
currentRewards.add(newReward)
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
rewards = currentRewards,
|
||||||
|
showAddDialog = false,
|
||||||
|
newRewardTitle = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class RewardsUiState(
|
data class RewardsUiState(
|
||||||
val rewards: List<com.novayaplaneta.domain.model.Reward> = emptyList(),
|
val rewards: List<Reward> = emptyList(),
|
||||||
val isLoading: Boolean = false
|
val isLoading: Boolean = false,
|
||||||
|
val showAddDialog: Boolean = false,
|
||||||
|
val newRewardTitle: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +1,471 @@
|
|||||||
package com.novayaplaneta.ui.screens.schedule
|
package com.novayaplaneta.ui.screens.schedule
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
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.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
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.Star
|
||||||
import androidx.compose.material3.*
|
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.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.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
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.compose.ui.window.Dialog
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.novayaplaneta.ui.components.NovayaPlanetaLogo
|
||||||
|
import com.novayaplaneta.ui.theme.*
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ScheduleScreen(
|
fun ScheduleScreen(
|
||||||
|
navController: androidx.navigation.NavController? = null,
|
||||||
viewModel: ScheduleViewModel = hiltViewModel(),
|
viewModel: ScheduleViewModel = hiltViewModel(),
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val screenWidthDp = configuration.screenWidthDp
|
||||||
|
val screenHeightDp = configuration.screenHeightDp
|
||||||
|
|
||||||
Scaffold(
|
// Цвета из autism-friendly палитры
|
||||||
topBar = {
|
val backgroundColor = LoginBackgroundTurquoise
|
||||||
TopAppBar(
|
val navPanelColor = LoginInputLightBlue
|
||||||
title = { Text("Расписание") }
|
val accentGreen = LoginGreenAccent
|
||||||
)
|
val dateCardColor = LoginInputLightBlue
|
||||||
}
|
|
||||||
) { paddingValues ->
|
// Форматирование даты и времени по московскому времени
|
||||||
Column(
|
val moscowTimeZone = TimeZone.getTimeZone("Europe/Moscow")
|
||||||
modifier = modifier
|
val dateFormat = SimpleDateFormat("dd.MM EEEE", Locale("ru")).apply {
|
||||||
.fillMaxSize()
|
timeZone = moscowTimeZone
|
||||||
.padding(paddingValues)
|
}
|
||||||
.padding(16.dp)
|
val timeFormat = SimpleDateFormat("HH:mm", Locale("ru")).apply {
|
||||||
|
timeZone = moscowTimeZone
|
||||||
|
}
|
||||||
|
val currentDate = dateFormat.format(Date())
|
||||||
|
val currentTime = timeFormat.format(Date())
|
||||||
|
val dayOfWeek = currentDate.split(" ").getOrNull(1) ?: ""
|
||||||
|
val dateOnly = currentDate.split(" ").getOrNull(0) ?: ""
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(color = backgroundColor)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
// Левая панель навигации с логотипом над ней (с отступами и закругленными краями)
|
||||||
CircularProgressIndicator(
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.width((screenWidthDp * 0.22f).dp.coerceIn(160.dp, 240.dp))
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(vertical = 20.dp, horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
// Логотип над панелью навигации
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.wrapContentWidth()
|
.padding(bottom = 16.dp),
|
||||||
)
|
contentAlignment = Alignment.Center
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
) {
|
||||||
items(uiState.schedules) { schedule ->
|
val logoSize = (screenHeightDp * 0.22f).toInt().coerceIn(140, 240).dp
|
||||||
Card(
|
NovayaPlanetaLogo(size = logoSize)
|
||||||
modifier = Modifier.fillMaxWidth()
|
}
|
||||||
) {
|
|
||||||
Column(
|
// Панель навигации (закругленная со всех сторон)
|
||||||
modifier = Modifier.padding(16.dp)
|
Column(
|
||||||
) {
|
modifier = Modifier
|
||||||
Text(
|
.fillMaxWidth()
|
||||||
text = schedule.title,
|
.weight(1f)
|
||||||
style = MaterialTheme.typography.titleLarge
|
.clip(RoundedCornerShape(32.dp))
|
||||||
)
|
.background(color = navPanelColor)
|
||||||
schedule.description?.let {
|
.padding(vertical = 24.dp, horizontal = 16.dp),
|
||||||
Text(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
text = it,
|
verticalArrangement = Arrangement.spacedBy(32.dp)
|
||||||
style = MaterialTheme.typography.bodyMedium
|
) {
|
||||||
)
|
// Расписание (активный)
|
||||||
}
|
NavItem(
|
||||||
}
|
icon = Icons.Filled.CalendarToday,
|
||||||
}
|
text = "Расписание",
|
||||||
|
isSelected = true,
|
||||||
|
onClick = { }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Награды
|
||||||
|
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 = false,
|
||||||
|
onClick = { navController?.navigate("ai") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Основная область
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
// Верхняя панель: только дата/время справа
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Дата и время (увеличены в 2 раза, зеленый цвет)
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.End
|
||||||
|
) {
|
||||||
|
val dateTextSize = (screenHeightDp * 0.06f).toInt().coerceIn(48, 80).sp
|
||||||
|
Text(
|
||||||
|
text = "$dateOnly $dayOfWeek",
|
||||||
|
fontSize = dateTextSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = accentGreen
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = currentTime,
|
||||||
|
fontSize = dateTextSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = accentGreen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
|
||||||
|
// Основной контент: только сегодняшняя дата (опущена ниже)
|
||||||
|
DateSection(
|
||||||
|
date = dateOnly,
|
||||||
|
dayOfWeek = dayOfWeek,
|
||||||
|
dateCardColor = dateCardColor,
|
||||||
|
accentGreen = accentGreen,
|
||||||
|
screenHeightDp = screenHeightDp,
|
||||||
|
tasks = uiState.tasks,
|
||||||
|
onAddClick = { viewModel.showAddDialog() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог выбора задачи
|
||||||
|
if (uiState.showAddDialog) {
|
||||||
|
AddTaskDialog(
|
||||||
|
selectedTaskType = uiState.selectedTaskType,
|
||||||
|
onTaskTypeSelected = { viewModel.selectTaskType(it) },
|
||||||
|
onSelect = { viewModel.addTask() },
|
||||||
|
onDismiss = { viewModel.hideAddDialog() },
|
||||||
|
accentGreen = accentGreen,
|
||||||
|
screenHeightDp = screenHeightDp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 // Черный цвет для лучшей видимости
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DateSection(
|
||||||
|
date: String,
|
||||||
|
dayOfWeek: String,
|
||||||
|
dateCardColor: Color,
|
||||||
|
accentGreen: Color,
|
||||||
|
screenHeightDp: Int,
|
||||||
|
tasks: List<TaskType>,
|
||||||
|
onAddClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Дата и кнопка + в одной строке
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Заголовок даты (в две строки)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.wrapContentWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(color = dateCardColor)
|
||||||
|
.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
val dateTextSize = (screenHeightDp * 0.025f).toInt().coerceIn(20, 32).sp
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.Start
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date,
|
||||||
|
fontSize = dateTextSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = dayOfWeek,
|
||||||
|
fontSize = dateTextSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка добавления (зеленая круглая с плюсом)
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = onAddClick,
|
||||||
|
modifier = Modifier.size(80.dp),
|
||||||
|
containerColor = accentGreen,
|
||||||
|
contentColor = Color.White
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = "Добавить",
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Задачи в ряд
|
||||||
|
if (tasks.isNotEmpty()) {
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
items(tasks) { task ->
|
||||||
|
TaskCard(
|
||||||
|
taskType = task,
|
||||||
|
screenHeightDp = screenHeightDp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TaskCard(
|
||||||
|
taskType: TaskType,
|
||||||
|
screenHeightDp: Int
|
||||||
|
) {
|
||||||
|
val cardWidth = 200.dp
|
||||||
|
val cardHeight = 180.dp
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(cardWidth)
|
||||||
|
.height(cardHeight)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(color = Color.LightGray)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
// Верхняя часть (для изображения)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(0.6f)
|
||||||
|
.background(color = Color.White.copy(alpha = 0.5f))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Нижняя часть (для текста)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(0.4f)
|
||||||
|
.background(color = Color.LightGray)
|
||||||
|
.padding(12.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp
|
||||||
|
Text(
|
||||||
|
text = taskType.title,
|
||||||
|
fontSize = textSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddTaskDialog(
|
||||||
|
selectedTaskType: TaskType?,
|
||||||
|
onTaskTypeSelected: (TaskType) -> Unit,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
accentGreen: Color,
|
||||||
|
screenHeightDp: Int
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.8f)
|
||||||
|
.wrapContentHeight(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color.White
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок
|
||||||
|
val titleSize = (screenHeightDp * 0.03f).toInt().coerceIn(24, 36).sp
|
||||||
|
Text(
|
||||||
|
text = "Выберите задачу",
|
||||||
|
fontSize = titleSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
|
||||||
|
// Опции выбора
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
// Подарок
|
||||||
|
TaskOption(
|
||||||
|
taskType = TaskType.Gift,
|
||||||
|
isSelected = selectedTaskType == TaskType.Gift,
|
||||||
|
onClick = { onTaskTypeSelected(TaskType.Gift) },
|
||||||
|
accentGreen = accentGreen,
|
||||||
|
screenHeightDp = screenHeightDp
|
||||||
|
)
|
||||||
|
|
||||||
|
// Кушать ложкой
|
||||||
|
TaskOption(
|
||||||
|
taskType = TaskType.EatWithSpoon,
|
||||||
|
isSelected = selectedTaskType == TaskType.EatWithSpoon,
|
||||||
|
onClick = { onTaskTypeSelected(TaskType.EatWithSpoon) },
|
||||||
|
accentGreen = accentGreen,
|
||||||
|
screenHeightDp = screenHeightDp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопки внизу
|
||||||
|
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
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
||||||
|
Text(
|
||||||
|
text = "Назад",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Выбрать"
|
||||||
|
Button(
|
||||||
|
onClick = onSelect,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(56.dp),
|
||||||
|
enabled = selectedTaskType != null,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = accentGreen,
|
||||||
|
contentColor = Color.White,
|
||||||
|
disabledContainerColor = Color.LightGray,
|
||||||
|
disabledContentColor = Color.Gray
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp
|
||||||
|
Text(
|
||||||
|
text = "Выбрать",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,3 +473,34 @@ fun ScheduleScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TaskOption(
|
||||||
|
taskType: TaskType,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
accentGreen: Color,
|
||||||
|
screenHeightDp: Int
|
||||||
|
) {
|
||||||
|
val borderWidth = if (isSelected) 4.dp else 2.dp
|
||||||
|
val borderColor = if (isSelected) accentGreen else Color.Gray
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(120.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.border(borderWidth, borderColor, RoundedCornerShape(16.dp))
|
||||||
|
.background(color = if (isSelected) accentGreen.copy(alpha = 0.1f) else Color.Transparent)
|
||||||
|
.clickable { onClick() },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val textSize = (screenHeightDp * 0.025f).toInt().coerceIn(20, 28).sp
|
||||||
|
Text(
|
||||||
|
text = taskType.title,
|
||||||
|
fontSize = textSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (isSelected) accentGreen else Color.Black
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
sealed class TaskType(val title: String) {
|
||||||
|
object Gift : TaskType("Подарок")
|
||||||
|
object EatWithSpoon : TaskType("Кушать ложкой")
|
||||||
|
}
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ScheduleViewModel @Inject constructor(
|
class ScheduleViewModel @Inject constructor(
|
||||||
private val getSchedulesUseCase: GetSchedulesUseCase
|
private val getSchedulesUseCase: GetSchedulesUseCase
|
||||||
@@ -29,10 +34,35 @@ class ScheduleViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showAddDialog() {
|
||||||
|
_uiState.value = _uiState.value.copy(showAddDialog = true, selectedTaskType = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideAddDialog() {
|
||||||
|
_uiState.value = _uiState.value.copy(showAddDialog = false, selectedTaskType = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectTaskType(taskType: TaskType) {
|
||||||
|
_uiState.value = _uiState.value.copy(selectedTaskType = taskType)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addTask() {
|
||||||
|
val selected = _uiState.value.selectedTaskType ?: return
|
||||||
|
val newTasks = _uiState.value.tasks + selected
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
tasks = newTasks,
|
||||||
|
showAddDialog = false,
|
||||||
|
selectedTaskType = null
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ScheduleUiState(
|
data class ScheduleUiState(
|
||||||
val schedules: List<com.novayaplaneta.domain.model.Schedule> = emptyList(),
|
val schedules: List<com.novayaplaneta.domain.model.Schedule> = emptyList(),
|
||||||
val isLoading: Boolean = false
|
val tasks: List<TaskType> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val showAddDialog: Boolean = false,
|
||||||
|
val selectedTaskType: TaskType? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +1,312 @@
|
|||||||
package com.novayaplaneta.ui.screens.settings
|
package com.novayaplaneta.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
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.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.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.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.novayaplaneta.ui.components.NovayaPlanetaLogo
|
||||||
|
import com.novayaplaneta.ui.theme.*
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
|
navController: NavController? = null,
|
||||||
viewModel: SettingsViewModel = hiltViewModel(),
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val screenWidthDp = configuration.screenWidthDp
|
||||||
|
val screenHeightDp = configuration.screenHeightDp
|
||||||
|
|
||||||
Scaffold(
|
// Навигация на экран входа при выходе
|
||||||
topBar = {
|
LaunchedEffect(uiState.isLoggedOut) {
|
||||||
TopAppBar(
|
if (uiState.isLoggedOut) {
|
||||||
title = { Text("Настройки") }
|
navController?.navigate("login") {
|
||||||
)
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
}
|
||||||
Column(
|
|
||||||
modifier = modifier
|
// Цвета из autism-friendly палитры
|
||||||
.fillMaxSize()
|
val backgroundColor = LoginBackgroundTurquoise
|
||||||
.padding(paddingValues)
|
val navPanelColor = LoginInputLightBlue
|
||||||
.padding(16.dp),
|
val accentGreen = LoginGreenAccent
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
val dateCardColor = LoginInputLightBlue
|
||||||
|
|
||||||
|
// Форматирование даты и времени по московскому времени
|
||||||
|
val moscowTimeZone = TimeZone.getTimeZone("Europe/Moscow")
|
||||||
|
val dateFormat = SimpleDateFormat("dd.MM EEEE", Locale("ru")).apply {
|
||||||
|
timeZone = moscowTimeZone
|
||||||
|
}
|
||||||
|
val timeFormat = SimpleDateFormat("HH:mm", Locale("ru")).apply {
|
||||||
|
timeZone = moscowTimeZone
|
||||||
|
}
|
||||||
|
val currentDate = dateFormat.format(Date())
|
||||||
|
val currentTime = timeFormat.format(Date())
|
||||||
|
val dayOfWeek = currentDate.split(" ").getOrNull(1) ?: ""
|
||||||
|
val dateOnly = currentDate.split(" ").getOrNull(0) ?: ""
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(color = backgroundColor)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
uiState.currentUser?.let { user ->
|
// Левая панель навигации с логотипом над ней (с отступами и закругленными краями)
|
||||||
Card(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier
|
||||||
|
.width((screenWidthDp * 0.22f).dp.coerceIn(160.dp, 240.dp))
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(vertical = 20.dp, horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
// Логотип над панелью навигации
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Column(
|
val logoSize = (screenHeightDp * 0.22f).toInt().coerceIn(140, 240).dp
|
||||||
modifier = Modifier.padding(16.dp)
|
NovayaPlanetaLogo(size = logoSize)
|
||||||
) {
|
}
|
||||||
Text(
|
|
||||||
text = "Пользователь",
|
// Панель навигации (закругленная со всех сторон)
|
||||||
style = MaterialTheme.typography.titleLarge
|
Column(
|
||||||
)
|
modifier = Modifier
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
.fillMaxWidth()
|
||||||
Text(
|
.weight(1f)
|
||||||
text = "Имя: ${user.name}",
|
.clip(RoundedCornerShape(32.dp))
|
||||||
style = MaterialTheme.typography.bodyLarge
|
.background(color = navPanelColor)
|
||||||
)
|
.padding(vertical = 24.dp, horizontal = 16.dp),
|
||||||
Text(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
text = "Email: ${user.email}",
|
verticalArrangement = Arrangement.spacedBy(32.dp)
|
||||||
style = MaterialTheme.typography.bodyLarge
|
) {
|
||||||
)
|
// Расписание
|
||||||
Text(
|
NavItem(
|
||||||
text = "Роль: ${user.role.name}",
|
icon = Icons.Filled.CalendarToday,
|
||||||
style = MaterialTheme.typography.bodyLarge
|
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 = true,
|
||||||
|
onClick = { }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Земля
|
||||||
|
NavItem(
|
||||||
|
icon = Icons.Filled.Public,
|
||||||
|
text = "Земля",
|
||||||
|
isSelected = false,
|
||||||
|
onClick = { navController?.navigate("ai") }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(
|
// Основная область
|
||||||
onClick = { viewModel.logout() },
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
colors = ButtonDefaults.buttonColors(
|
.weight(1f)
|
||||||
containerColor = MaterialTheme.colorScheme.error
|
.fillMaxHeight()
|
||||||
)
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Text("Выйти")
|
// Верхняя панель: только дата/время справа
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Дата и время (зеленый цвет)
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.End
|
||||||
|
) {
|
||||||
|
val dateTextSize = (screenHeightDp * 0.06f).toInt().coerceIn(48, 80).sp
|
||||||
|
Text(
|
||||||
|
text = "$dateOnly $dayOfWeek",
|
||||||
|
fontSize = dateTextSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = accentGreen
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = currentTime,
|
||||||
|
fontSize = dateTextSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = accentGreen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
|
||||||
|
// Фото профиля (placeholder)
|
||||||
|
val photoSize = (screenHeightDp * 0.2f).toInt().coerceIn(150, 250).dp
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(photoSize)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color = dateCardColor),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.CameraAlt,
|
||||||
|
contentDescription = "Фото профиля",
|
||||||
|
modifier = Modifier.size(photoSize * 0.4f),
|
||||||
|
tint = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// Карточка с данными пользователя
|
||||||
|
val user = uiState.currentUser
|
||||||
|
if (user != null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.7f)
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(color = dateCardColor)
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
val textSize = (screenHeightDp * 0.025f).toInt().coerceIn(20, 28).sp
|
||||||
|
UserInfoRow(
|
||||||
|
label = "Имя:",
|
||||||
|
value = user.name,
|
||||||
|
textSize = textSize
|
||||||
|
)
|
||||||
|
UserInfoRow(
|
||||||
|
label = "Логин:",
|
||||||
|
value = "${user.name}12", // Используем имя + число как логин
|
||||||
|
textSize = textSize
|
||||||
|
)
|
||||||
|
UserInfoRow(
|
||||||
|
label = "email:",
|
||||||
|
value = user.email,
|
||||||
|
textSize = textSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Кнопка "Выйти"
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.logout() },
|
||||||
|
modifier = Modifier.fillMaxWidth(0.7f)
|
||||||
|
) {
|
||||||
|
val buttonTextSize = (screenHeightDp * 0.025f).toInt().coerceIn(20, 28).sp
|
||||||
|
Text(
|
||||||
|
text = "Выйти",
|
||||||
|
fontSize = buttonTextSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserInfoRow(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
textSize: androidx.compose.ui.unit.TextUnit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Start
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
fontSize = textSize,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
fontSize = textSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package com.novayaplaneta.ui.screens.settings
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.novayaplaneta.domain.model.User
|
||||||
|
import com.novayaplaneta.domain.model.UserRole
|
||||||
import com.novayaplaneta.domain.repository.AuthRepository
|
import com.novayaplaneta.domain.repository.AuthRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
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.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -24,12 +27,34 @@ class SettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun loadCurrentUser() {
|
private fun loadCurrentUser() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
authRepository.getCurrentUser().collect { user ->
|
// Пытаемся получить пользователя из базы
|
||||||
_uiState.value = _uiState.value.copy(currentUser = user)
|
try {
|
||||||
|
val user = authRepository.getCurrentUser().first()
|
||||||
|
|
||||||
|
// Если пользователя нет, используем заглушку
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
currentUser = user ?: getDefaultUser()
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Если произошла ошибка, используем заглушку
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
currentUser = getDefaultUser()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getDefaultUser(): User {
|
||||||
|
// Заглушка с тестовыми данными
|
||||||
|
return User(
|
||||||
|
id = "user_123",
|
||||||
|
name = "Коля",
|
||||||
|
email = "kolya12@mail.ru",
|
||||||
|
role = UserRole.CHILD,
|
||||||
|
token = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun logout() {
|
fun logout() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
authRepository.logout()
|
authRepository.logout()
|
||||||
@@ -39,7 +64,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class SettingsUiState(
|
data class SettingsUiState(
|
||||||
val currentUser: com.novayaplaneta.domain.model.User? = null,
|
val currentUser: User? = null,
|
||||||
val isLoggedOut: Boolean = false
|
val isLoggedOut: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,22 +14,22 @@ val SurfaceDark = Color(0xFF1A1A1A)
|
|||||||
val OnBackgroundDark = Color(0xFFFFFFFF)
|
val OnBackgroundDark = Color(0xFFFFFFFF)
|
||||||
val OnSurfaceDark = Color(0xFFFFFFFF)
|
val OnSurfaceDark = Color(0xFFFFFFFF)
|
||||||
|
|
||||||
// Accent Colors
|
// Accent Colors - очень мягкие пастельные тона для максимального комфорта детей с РАС
|
||||||
val AccentGreen = Color(0xFF4CAF50)
|
val AccentGreen = Color(0xFFA8D5BA) // Очень мягкий мятно-зеленый (низкая насыщенность)
|
||||||
val AccentOrange = Color(0xFFFF6B35)
|
val AccentOrange = Color(0xFFFFD4B3) // Очень мягкий персиковый (теплый, успокаивающий)
|
||||||
val AccentGold = Color(0xFFFFD700)
|
val AccentGold = Color(0xFFFFEEC7) // Очень мягкий бежево-желтый (кремовый оттенок)
|
||||||
|
|
||||||
// Status Colors
|
// Status Colors - очень приглушенные для комфорта
|
||||||
val SuccessColor = Color(0xFF4CAF50)
|
val SuccessColor = Color(0xFFA8D5BA) // Очень мягкий зеленый
|
||||||
val WarningColor = Color(0xFFFF6B35)
|
val WarningColor = Color(0xFFFFD4B3) // Очень мягкий персиковый
|
||||||
val ErrorColor = Color(0xFFE53935)
|
val ErrorColor = Color(0xFFFFC8C8) // Очень мягкий розовый (вместо яркого красного)
|
||||||
|
|
||||||
// Цвета для экрана авторизации (благоприятные для РАС из PDF)
|
// Цвета для экрана авторизации (оптимизированные для РАС - очень мягкие тона)
|
||||||
val LoginBackgroundTurquoise = Color(0xFFDAE7E9) // Мягкий голубой фон
|
val LoginBackgroundTurquoise = Color(0xFFE8F4F5) // Очень мягкий мятный фон (еще мягче)
|
||||||
val LoginCardLightBlue = Color(0xFFBCDAEC) // Спокойный светло-голубой
|
val LoginCardLightBlue = Color(0xFFD4E8F0) // Очень мягкий голубой для карточек
|
||||||
val LoginInputLightBlue = Color(0xFFBCDAEC) // Для полей ввода
|
val LoginInputLightBlue = Color(0xFFD4E8F0) // Очень мягкий голубой для полей ввода
|
||||||
val LoginButtonBlue = Color(0xFFBCDAEC) // Для кнопки
|
val LoginButtonBlue = Color(0xFFD4E8F0) // Очень мягкий голубой для кнопок
|
||||||
val LoginGreenAccent = Color(0xFF80EF80) // Пастельно-зелёный акцент
|
val LoginGreenAccent = Color(0xFF95D5A3) // Очень мягкий зеленый акцент (приглушенный)
|
||||||
val LoginGreenSoft = Color(0xFFC5E6C5) // Мягкий пастельно-зелёный
|
val LoginGreenSoft = Color(0xFFD1E8D7) // Очень мягкий светло-зеленый
|
||||||
val LoginGreenDark = Color(0xFF80EF80) // Пастельно-зелёный темнее
|
val LoginGreenDark = Color(0xFF95D5A3) // Очень мягкий зеленый для темных элементов
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user