- экраны (Станции/Избранное/История/Записи/Настройки/Вход): двухцветные заголовки, токены темы, EmptyState, анимации появления и перестановки - AuthScreen: брендовый локап (AppMark + RadiolaWordmark) - PlayerBottomSheet: живой эфир — LiveEqualizer вместо перемотки, Crossfade трека и play/pause, pressScale, анимация избранного/записи - кнопки музыкальных сервисов: монохромные официальные логотипы (vector drawable из Simple Icons CC0 + Yandex), маппинг serviceLogoRes - DeeplinkBottomSheet: сетка сервисов с логотипами
462 lines
20 KiB
Kotlin
462 lines
20 KiB
Kotlin
package com.radiola.ui.settings
|
||
|
||
import androidx.compose.animation.animateColorAsState
|
||
import androidx.compose.animation.core.tween
|
||
import androidx.compose.foundation.background
|
||
import androidx.compose.foundation.border
|
||
import androidx.compose.foundation.clickable
|
||
import androidx.compose.foundation.layout.*
|
||
import androidx.compose.foundation.lazy.LazyColumn
|
||
import androidx.compose.foundation.lazy.items
|
||
import androidx.compose.foundation.shape.CircleShape
|
||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||
import androidx.compose.material3.*
|
||
import androidx.compose.runtime.*
|
||
import androidx.compose.ui.Alignment
|
||
import androidx.compose.ui.Modifier
|
||
import androidx.compose.ui.draw.clip
|
||
import androidx.compose.ui.graphics.Color
|
||
import androidx.compose.ui.text.SpanStyle
|
||
import androidx.compose.ui.text.buildAnnotatedString
|
||
import androidx.compose.ui.text.font.FontWeight
|
||
import androidx.compose.ui.text.style.TextAlign
|
||
import androidx.compose.ui.text.withStyle
|
||
import androidx.compose.ui.unit.dp
|
||
import androidx.compose.ui.unit.sp
|
||
import androidx.hilt.navigation.compose.hiltViewModel
|
||
import com.composables.icons.lucide.Lucide
|
||
import com.composables.icons.lucide.User
|
||
import com.radiola.domain.model.DeeplinkService
|
||
import com.radiola.domain.model.StationTestStatus
|
||
import com.radiola.ui.theme.Motion
|
||
import com.radiola.ui.theme.RadiolaTheme
|
||
|
||
@OptIn(ExperimentalMaterial3Api::class)
|
||
@Composable
|
||
fun SettingsScreen(
|
||
onNavigateToAuth: () -> Unit,
|
||
modifier: Modifier = Modifier,
|
||
viewModel: SettingsViewModel = hiltViewModel()
|
||
) {
|
||
val sleepTimer by viewModel.sleepTimerMinutes.collectAsState()
|
||
val enabledServices by viewModel.enabledServices.collectAsState()
|
||
val equalizerPreset by viewModel.equalizerPreset.collectAsState()
|
||
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
|
||
val isTesting by viewModel.isTesting.collectAsState()
|
||
val testProgress by viewModel.testProgress.collectAsState()
|
||
val testTotal by viewModel.testTotal.collectAsState()
|
||
val testResults by viewModel.testResults.collectAsState()
|
||
val isLoggedIn by viewModel.isLoggedIn.collectAsState()
|
||
val currentUser by viewModel.currentUser.collectAsState()
|
||
val presets = listOf("Flat", "Rock", "Pop", "Jazz", "Bass")
|
||
var showReport by remember { mutableStateOf(false) }
|
||
val colors = RadiolaTheme.colors
|
||
|
||
LazyColumn(
|
||
modifier = modifier
|
||
.fillMaxSize()
|
||
.padding(horizontal = 20.dp),
|
||
contentPadding = PaddingValues(bottom = 32.dp),
|
||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||
) {
|
||
item {
|
||
// Двухцветный заголовок
|
||
Text(
|
||
text = buildAnnotatedString {
|
||
withStyle(SpanStyle(color = colors.textPrimary)) { append("Нас") }
|
||
withStyle(SpanStyle(color = colors.accent)) { append("тройки") }
|
||
},
|
||
style = MaterialTheme.typography.headlineLarge,
|
||
modifier = Modifier.padding(top = 20.dp)
|
||
)
|
||
}
|
||
|
||
// --- Профиль ---
|
||
item {
|
||
SectionLabel("ПРОФИЛЬ")
|
||
Spacer(Modifier.height(8.dp))
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.clip(RoundedCornerShape(16.dp))
|
||
.background(colors.surface)
|
||
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
|
||
.padding(16.dp),
|
||
verticalAlignment = Alignment.CenterVertically,
|
||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||
) {
|
||
// Аватар-заглушка
|
||
Box(
|
||
modifier = Modifier
|
||
.size(48.dp)
|
||
.clip(CircleShape)
|
||
.background(colors.surface2),
|
||
contentAlignment = Alignment.Center
|
||
) {
|
||
Icon(Lucide.User, null, tint = colors.textMuted, modifier = Modifier.size(22.dp))
|
||
}
|
||
Column(modifier = Modifier.weight(1f)) {
|
||
if (isLoggedIn && currentUser != null) {
|
||
Text(
|
||
text = currentUser?.email ?: "",
|
||
style = MaterialTheme.typography.titleMedium,
|
||
color = colors.textPrimary
|
||
)
|
||
Text(
|
||
text = "Аккаунт активен",
|
||
style = MaterialTheme.typography.labelMedium,
|
||
color = colors.textSecondary
|
||
)
|
||
} else {
|
||
Text(
|
||
text = "Вы не вошли",
|
||
style = MaterialTheme.typography.titleMedium,
|
||
color = colors.textPrimary
|
||
)
|
||
Text(
|
||
text = "Вход не обязателен — для синхронизации",
|
||
style = MaterialTheme.typography.labelMedium,
|
||
color = colors.textSecondary
|
||
)
|
||
}
|
||
}
|
||
if (isLoggedIn && currentUser != null) {
|
||
OutlinedButton(
|
||
onClick = { viewModel.logout() },
|
||
colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.textSecondary),
|
||
border = androidx.compose.foundation.BorderStroke(1.dp, colors.border)
|
||
) {
|
||
Text("Выйти")
|
||
}
|
||
} else {
|
||
Button(
|
||
onClick = onNavigateToAuth,
|
||
colors = ButtonDefaults.buttonColors(
|
||
containerColor = colors.accent,
|
||
contentColor = colors.bgBase
|
||
),
|
||
shape = RoundedCornerShape(10.dp)
|
||
) {
|
||
Text("Войти", fontWeight = FontWeight.SemiBold)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Таймер сна ---
|
||
item {
|
||
SectionLabel("ТАЙМЕР СНА")
|
||
Spacer(Modifier.height(8.dp))
|
||
Column(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.clip(RoundedCornerShape(16.dp))
|
||
.background(colors.surface)
|
||
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
|
||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||
) {
|
||
Row(
|
||
modifier = Modifier.fillMaxWidth(),
|
||
horizontalArrangement = Arrangement.SpaceBetween,
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
Text(
|
||
text = "Отключить через",
|
||
style = MaterialTheme.typography.titleMedium,
|
||
color = colors.textPrimary
|
||
)
|
||
Text(
|
||
text = "$sleepTimer мин",
|
||
style = MaterialTheme.typography.titleMedium,
|
||
color = colors.accent,
|
||
fontWeight = FontWeight.SemiBold
|
||
)
|
||
}
|
||
Slider(
|
||
value = sleepTimer.toFloat(),
|
||
onValueChange = { viewModel.setSleepTimer(it.toInt()) },
|
||
valueRange = 5f..120f,
|
||
steps = 22,
|
||
colors = SliderDefaults.colors(
|
||
thumbColor = colors.accent,
|
||
activeTrackColor = colors.accent,
|
||
inactiveTrackColor = colors.surface2
|
||
)
|
||
)
|
||
}
|
||
}
|
||
|
||
// --- Эквалайзер ---
|
||
item {
|
||
SectionLabel("ЭКВАЛАЙЗЕР")
|
||
Spacer(Modifier.height(8.dp))
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.clip(RoundedCornerShape(16.dp))
|
||
.background(colors.surface)
|
||
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
|
||
.padding(12.dp),
|
||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||
) {
|
||
presets.forEach { preset ->
|
||
val selected = equalizerPreset == preset
|
||
val bgColor by animateColorAsState(
|
||
targetValue = if (selected) colors.accent else colors.surface2,
|
||
animationSpec = tween(Motion.Medium),
|
||
label = "eqSegment"
|
||
)
|
||
val textColor by animateColorAsState(
|
||
targetValue = if (selected) colors.bgBase else colors.textSecondary,
|
||
animationSpec = tween(Motion.Medium),
|
||
label = "eqText"
|
||
)
|
||
Box(
|
||
modifier = Modifier
|
||
.weight(1f)
|
||
.clip(RoundedCornerShape(8.dp))
|
||
.background(bgColor)
|
||
.clickable { viewModel.setEqualizerPreset(preset) }
|
||
.padding(vertical = 8.dp),
|
||
contentAlignment = Alignment.Center
|
||
) {
|
||
Text(
|
||
text = preset,
|
||
style = MaterialTheme.typography.labelLarge,
|
||
color = textColor,
|
||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Музыкальные сервисы ---
|
||
item {
|
||
SectionLabel("МУЗЫКАЛЬНЫЕ СЕРВИСЫ")
|
||
Spacer(Modifier.height(8.dp))
|
||
Column(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.clip(RoundedCornerShape(16.dp))
|
||
.background(colors.surface)
|
||
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
|
||
) {
|
||
DeeplinkService.entries.forEachIndexed { index, service ->
|
||
val checked = service.serviceId in enabledServices
|
||
val trackColor by animateColorAsState(
|
||
targetValue = if (checked) colors.accent else colors.surface2,
|
||
animationSpec = tween(Motion.Medium),
|
||
label = "switchTrack"
|
||
)
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.clickable { viewModel.toggleService(service.serviceId, !checked) }
|
||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||
horizontalArrangement = Arrangement.SpaceBetween,
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
Text(
|
||
text = service.displayName,
|
||
style = MaterialTheme.typography.titleMedium,
|
||
color = colors.textPrimary
|
||
)
|
||
Switch(
|
||
checked = checked,
|
||
onCheckedChange = { viewModel.toggleService(service.serviceId, it) },
|
||
colors = SwitchDefaults.colors(
|
||
checkedThumbColor = colors.bgBase,
|
||
checkedTrackColor = colors.accent,
|
||
uncheckedThumbColor = colors.textMuted,
|
||
uncheckedTrackColor = colors.surface2
|
||
)
|
||
)
|
||
}
|
||
if (index < DeeplinkService.entries.size - 1) {
|
||
HorizontalDivider(
|
||
color = colors.border,
|
||
modifier = Modifier.padding(horizontal = 16.dp)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Запись эфира ---
|
||
item {
|
||
Column(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.clip(RoundedCornerShape(16.dp))
|
||
.background(colors.surface)
|
||
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
|
||
) {
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.clickable { viewModel.setRecordingEnabled(!isRecordingEnabled) }
|
||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||
horizontalArrangement = Arrangement.SpaceBetween,
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
Column {
|
||
Text(
|
||
text = "Запись эфира",
|
||
style = MaterialTheme.typography.titleMedium,
|
||
color = colors.textPrimary
|
||
)
|
||
Text(
|
||
text = "Сохранять в файл при воспроизведении",
|
||
style = MaterialTheme.typography.labelMedium,
|
||
color = colors.textSecondary
|
||
)
|
||
}
|
||
Switch(
|
||
checked = isRecordingEnabled,
|
||
onCheckedChange = { viewModel.setRecordingEnabled(it) },
|
||
colors = SwitchDefaults.colors(
|
||
checkedThumbColor = colors.bgBase,
|
||
checkedTrackColor = colors.accent,
|
||
uncheckedThumbColor = colors.textMuted,
|
||
uncheckedTrackColor = colors.surface2
|
||
)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Тестирование станций ---
|
||
item {
|
||
SectionLabel("ТЕСТИРОВАНИЕ СТАНЦИЙ")
|
||
Spacer(Modifier.height(8.dp))
|
||
Column(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.clip(RoundedCornerShape(16.dp))
|
||
.background(colors.surface)
|
||
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
|
||
.padding(16.dp),
|
||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||
) {
|
||
if (isTesting) {
|
||
Text(
|
||
text = "Проверено $testProgress из $testTotal",
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
color = colors.textSecondary
|
||
)
|
||
LinearProgressIndicator(
|
||
progress = { if (testTotal > 0) testProgress.toFloat() / testTotal else 0f },
|
||
modifier = Modifier.fillMaxWidth(),
|
||
color = colors.accent,
|
||
trackColor = colors.surface2
|
||
)
|
||
} else if (testResults.isNotEmpty()) {
|
||
val ok = testResults.count { it.status == StationTestStatus.OK }
|
||
val okNoMeta = testResults.count { it.status == StationTestStatus.OK_NO_META }
|
||
val offline = testResults.count { it.status == StationTestStatus.OFFLINE }
|
||
val error = testResults.count { it.status == StationTestStatus.ERROR }
|
||
|
||
Text("Всего: ${testResults.size}", color = colors.textPrimary, style = MaterialTheme.typography.bodyMedium)
|
||
Text("Работают + метаданные: $ok", color = Color(0xFF4CAF50), style = MaterialTheme.typography.bodyMedium)
|
||
Text("Работают без метаданных: $okNoMeta", color = Color(0xFFFF9800), style = MaterialTheme.typography.bodyMedium)
|
||
Text("Оффлайн: $offline", color = colors.live, style = MaterialTheme.typography.bodyMedium)
|
||
Text("Ошибки: $error", color = colors.live, style = MaterialTheme.typography.bodyMedium)
|
||
Spacer(Modifier.height(4.dp))
|
||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||
Button(
|
||
onClick = { showReport = true },
|
||
colors = ButtonDefaults.buttonColors(
|
||
containerColor = colors.accent,
|
||
contentColor = colors.bgBase
|
||
)
|
||
) {
|
||
Text("Подробный отчёт")
|
||
}
|
||
OutlinedButton(
|
||
onClick = { viewModel.clearTestResults() },
|
||
colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.textSecondary),
|
||
border = androidx.compose.foundation.BorderStroke(1.dp, colors.border)
|
||
) {
|
||
Text("Очистить")
|
||
}
|
||
}
|
||
} else {
|
||
OutlinedButton(
|
||
onClick = { viewModel.startTesting() },
|
||
modifier = Modifier.fillMaxWidth(),
|
||
colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.accent),
|
||
border = androidx.compose.foundation.BorderStroke(1.dp, colors.accent)
|
||
) {
|
||
Text("Провести тестирование")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Диалог отчёта
|
||
if (showReport) {
|
||
AlertDialog(
|
||
onDismissRequest = { showReport = false },
|
||
containerColor = colors.elevated,
|
||
title = {
|
||
Text(
|
||
"Результаты тестирования",
|
||
color = colors.textPrimary,
|
||
style = MaterialTheme.typography.titleLarge
|
||
)
|
||
},
|
||
text = {
|
||
LazyColumn(modifier = Modifier.heightIn(max = 400.dp)) {
|
||
items(testResults) { result ->
|
||
val color = when (result.status) {
|
||
StationTestStatus.OK -> Color(0xFF4CAF50)
|
||
StationTestStatus.OK_NO_META -> Color(0xFFFF9800)
|
||
StationTestStatus.OFFLINE -> colors.live
|
||
StationTestStatus.ERROR -> colors.live
|
||
}
|
||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||
Text(
|
||
text = result.stationName,
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
color = color
|
||
)
|
||
Text(
|
||
text = buildString {
|
||
append("${result.status.name}")
|
||
result.httpCode?.let { append(" | HTTP $it") }
|
||
result.icyTitle?.let { append(" | Icy: $it") }
|
||
result.nowPlayingTrack?.let { append(" | NP: $it") }
|
||
result.errorMessage?.let { append(" | $it") }
|
||
},
|
||
style = MaterialTheme.typography.labelSmall,
|
||
color = colors.textMuted
|
||
)
|
||
}
|
||
}
|
||
}
|
||
},
|
||
confirmButton = {
|
||
TextButton(
|
||
onClick = { showReport = false },
|
||
colors = ButtonDefaults.textButtonColors(contentColor = colors.accent)
|
||
) {
|
||
Text("Закрыть")
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
/** Подпись секции: заглавные буквы, textMuted, labelSmall. */
|
||
@Composable
|
||
private fun SectionLabel(text: String) {
|
||
Text(
|
||
text = text,
|
||
style = MaterialTheme.typography.labelSmall,
|
||
color = RadiolaTheme.colors.textMuted,
|
||
letterSpacing = 1.sp
|
||
)
|
||
}
|