feat(ui): рестайл всех экранов + плеер + официальные mono-логотипы сервисов
- экраны (Станции/Избранное/История/Записи/Настройки/Вход): двухцветные заголовки, токены темы, EmptyState, анимации появления и перестановки - AuthScreen: брендовый локап (AppMark + RadiolaWordmark) - PlayerBottomSheet: живой эфир — LiveEqualizer вместо перемотки, Crossfade трека и play/pause, pressScale, анимация избранного/записи - кнопки музыкальных сервисов: монохромные официальные логотипы (vector drawable из Simple Icons CC0 + Yandex), маппинг serviceLogoRes - DeeplinkBottomSheet: сетка сервисов с логотипами
This commit is contained in:
@@ -1,18 +1,35 @@
|
||||
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
|
||||
@@ -33,156 +50,343 @@ fun SettingsScreen(
|
||||
val currentUser by viewModel.currentUser.collectAsState()
|
||||
val presets = listOf("Flat", "Rock", "Pop", "Jazz", "Bass")
|
||||
var showReport by remember { mutableStateOf(false) }
|
||||
val colors = RadiolaTheme.colors
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Настройки") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
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)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Text("Профиль", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
if (isLoggedIn && currentUser != null) {
|
||||
Column {
|
||||
|
||||
// --- Профиль ---
|
||||
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.bodyLarge
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colors.textPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.logout() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Выйти")
|
||||
}
|
||||
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,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = colors.accent,
|
||||
contentColor = colors.bgBase
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
) {
|
||||
Text("Войти")
|
||||
Text("Войти", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Divider()
|
||||
}
|
||||
|
||||
item {
|
||||
Text("Таймер сна", style = MaterialTheme.typography.titleMedium)
|
||||
// --- Таймер сна ---
|
||||
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
|
||||
steps = 22,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = colors.accent,
|
||||
activeTrackColor = colors.accent,
|
||||
inactiveTrackColor = colors.surface2
|
||||
)
|
||||
)
|
||||
Text("$sleepTimer мин", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
item {
|
||||
Text("Эквалайзер", style = MaterialTheme.typography.titleMedium)
|
||||
SingleChoiceSegmentedButtonRow {
|
||||
presets.forEach { preset ->
|
||||
SegmentedButton(
|
||||
selected = equalizerPreset == preset,
|
||||
onClick = { viewModel.setEqualizerPreset(preset) },
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(preset)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Эквалайзер ---
|
||||
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 {
|
||||
Text("Музыкальные сервисы", style = MaterialTheme.typography.titleMedium)
|
||||
Column {
|
||||
DeeplinkService.entries.forEach { service ->
|
||||
val checked = service.serviceId in enabledServices
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { viewModel.toggleService(service.serviceId, !checked) }
|
||||
.padding(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(service.displayName)
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = { viewModel.toggleService(service.serviceId, it) }
|
||||
}
|
||||
|
||||
// --- Музыкальные сервисы ---
|
||||
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 {
|
||||
}
|
||||
|
||||
// --- Запись эфира ---
|
||||
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(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Запись эфира")
|
||||
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) }
|
||||
onCheckedChange = { viewModel.setRecordingEnabled(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = colors.bgBase,
|
||||
checkedTrackColor = colors.accent,
|
||||
uncheckedThumbColor = colors.textMuted,
|
||||
uncheckedTrackColor = colors.surface2
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Divider()
|
||||
}
|
||||
item {
|
||||
Text("Тестирование станций", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// --- Тестирование станций ---
|
||||
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) {
|
||||
Column {
|
||||
LinearProgressIndicator(
|
||||
progress = { if (testTotal > 0) testProgress.toFloat() / testTotal else 0f },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text("Проверено $testProgress из $testTotal")
|
||||
}
|
||||
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 }
|
||||
|
||||
Column {
|
||||
Text("Всего: ${testResults.size}")
|
||||
Text("Работают + метаданные: $ok", color = Color(0xFF4CAF50))
|
||||
Text("Работают без метаданных: $okNoMeta", color = Color(0xFFFF9800))
|
||||
Text("Оффлайн: $offline", color = Color(0xFFFF5252))
|
||||
Text("Ошибки: $error", color = Color(0xFFFF5252))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { showReport = true }) {
|
||||
Text("Подробный отчёт")
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.clearTestResults() }) {
|
||||
Text("Очистить")
|
||||
}
|
||||
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 {
|
||||
Button(
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.startTesting() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.accent),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, colors.accent)
|
||||
) {
|
||||
Text("Провести тестирование")
|
||||
}
|
||||
@@ -191,18 +395,26 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Диалог отчёта
|
||||
if (showReport) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showReport = false },
|
||||
title = { Text("Результаты тестирования") },
|
||||
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 -> Color(0xFFFF5252)
|
||||
StationTestStatus.ERROR -> Color(0xFFFF5252)
|
||||
StationTestStatus.OFFLINE -> colors.live
|
||||
StationTestStatus.ERROR -> colors.live
|
||||
}
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
@@ -219,17 +431,31 @@ fun SettingsScreen(
|
||||
result.errorMessage?.let { append(" | $it") }
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = colors.textMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showReport = false }) {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user