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:
nk
2026-06-02 21:31:16 +03:00
parent f81dc52e92
commit bdace2d5b9
16 changed files with 1195 additions and 499 deletions

View File

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