feat(ui): рестайл общих компонентов под дизайн-систему

- StationCard: обложка/иконка-заглушка, анимированное сердечко, pressScale
- MiniPlayer: elevated-бар, метка «СЕЙЧАС ИГРАЕТ», Crossfade play/pause
- SearchBar: surface-поле, акцентный курсор, скругление 14
- FilterChips: акцентный активный чип с анимацией цвета
- EmptyState: иконка-плашка + текст
- TrackListItem: thumb-заглушка, pressScale
This commit is contained in:
nk
2026-06-02 21:17:28 +03:00
parent a614ac3764
commit f81dc52e92
6 changed files with 276 additions and 122 deletions

View File

@@ -1,27 +1,49 @@
package com.radiola.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Radio
import com.radiola.ui.theme.RadiolaTheme
@Composable
fun EmptyState(
message: String,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
icon: ImageVector = Lucide.Radio
) {
val colors = RadiolaTheme.colors
Box(
modifier = modifier.fillMaxSize(),
modifier = modifier.fillMaxSize().padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF888888)
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(24.dp))
.background(colors.surface2),
contentAlignment = Alignment.Center
) {
Icon(icon, contentDescription = null, tint = colors.textMuted, modifier = Modifier.size(32.dp))
}
Spacer(Modifier.height(16.dp))
Text(
text = message,
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
color = colors.textSecondary,
textAlign = TextAlign.Center
)
}
}
}

View File

@@ -1,13 +1,28 @@
package com.radiola.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
@Composable
fun FilterChips(
@@ -18,22 +33,49 @@ fun FilterChips(
) {
LazyRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(9.dp),
contentPadding = PaddingValues(horizontal = 16.dp)
) {
item {
FilterChip(
selected = selectedTag == null,
onClick = { onTagSelected(null) },
label = { Text("Все") }
)
Chip(label = "Все", selected = selectedTag == null) { onTagSelected(null) }
}
items(tags) { tag ->
FilterChip(
selected = selectedTag == tag,
onClick = { onTagSelected(tag) },
label = { Text(tag) }
)
Chip(label = tag, selected = selectedTag == tag) { onTagSelected(tag) }
}
}
}
@Composable
private fun Chip(label: String, selected: Boolean, onClick: () -> Unit) {
val colors = RadiolaTheme.colors
val bg by animateColorAsState(
targetValue = if (selected) colors.accent else colors.surface2,
animationSpec = tween(Motion.Medium),
label = "chipBg"
)
val fg by animateColorAsState(
targetValue = if (selected) colors.bgBase else colors.textSecondary,
animationSpec = tween(Motion.Medium),
label = "chipFg"
)
Text(
text = label,
color = fg,
fontWeight = FontWeight.SemiBold,
style = androidx.compose.material3.MaterialTheme.typography.labelLarge,
modifier = Modifier
.clip(RoundedCornerShape(18.dp))
.background(bg)
.border(
width = if (selected) 0.dp else 1.dp,
color = if (selected) Color.Transparent else colors.border,
shape = RoundedCornerShape(18.dp)
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
)
.padding(horizontal = 16.dp, vertical = 9.dp)
)
}

View File

@@ -1,24 +1,33 @@
package com.radiola.ui.components
import androidx.compose.animation.Crossfade
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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Pause
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.Radio
import com.radiola.domain.model.Track
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
@Composable
fun MiniPlayer(
@@ -29,42 +38,76 @@ fun MiniPlayer(
onPlayPause: () -> Unit,
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
Row(
modifier = modifier
.fillMaxWidth()
.height(64.dp)
.background(Color(0xFF1E1E1E))
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(18.dp))
.background(colors.elevated)
.border(1.dp, colors.border, RoundedCornerShape(18.dp))
.clickable(onClick = onClick)
.padding(horizontal = 16.dp),
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = track?.coverUrl,
contentDescription = null,
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(6.dp))
)
Spacer(modifier = Modifier.width(12.dp))
.clip(RoundedCornerShape(12.dp))
.background(colors.surface2),
contentAlignment = Alignment.Center
) {
if (track?.coverUrl != null) {
AsyncImage(
model = track.coverUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(Lucide.Radio, null, tint = colors.textSecondary, modifier = Modifier.size(20.dp))
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stationName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1
text = "СЕЙЧАС ИГРАЕТ",
color = colors.accent,
fontSize = 9.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.sp
)
Spacer(Modifier.height(2.dp))
Text(
text = track?.let { "${it.artist}${it.song}" } ?: "",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
maxLines = 1
text = track?.let { "${it.artist}${it.song}" } ?: stationName,
style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
color = colors.textPrimary,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
}
IconButton(onClick = onPlayPause) {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = Color.White
)
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.pressScale()
.size(44.dp)
.clip(RoundedCornerShape(22.dp))
.background(colors.accent)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onPlayPause
),
contentAlignment = Alignment.Center
) {
Crossfade(targetState = isPlaying, animationSpec = tween(Motion.Fast), label = "miniPlay") { playing ->
Icon(
imageVector = if (playing) Lucide.Pause else Lucide.Play,
contentDescription = if (playing) "Пауза" else "Играть",
tint = colors.bgBase,
modifier = Modifier.size(20.dp)
)
}
}
}
}

View File

@@ -1,10 +1,8 @@
package com.radiola.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
@@ -14,6 +12,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import com.radiola.ui.theme.RadiolaTheme
@Composable
fun SearchBar(
@@ -22,22 +21,23 @@ fun SearchBar(
placeholder: String = "Поиск станции...",
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
TextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier
.fillMaxWidth()
.background(Color(0xFF2A2A2A), RoundedCornerShape(8.dp)),
placeholder = { Text(placeholder, color = Color(0xFF888888)) },
leadingIcon = { Icon(Lucide.Search, contentDescription = null, tint = Color(0xFF888888)) },
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
placeholder = { Text(placeholder, color = colors.textMuted) },
leadingIcon = { Icon(Lucide.Search, contentDescription = null, tint = colors.textMuted) },
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color(0xFF2A2A2A),
unfocusedContainerColor = Color(0xFF2A2A2A),
focusedContainerColor = colors.surface,
unfocusedContainerColor = colors.surface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
cursorColor = colors.accent,
focusedTextColor = colors.textPrimary,
unfocusedTextColor = colors.textPrimary
)
)
}

View File

@@ -1,27 +1,31 @@
package com.radiola.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Radio
import com.radiola.domain.model.Station
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
@Composable
fun StationCard(
@@ -31,61 +35,76 @@ fun StationCard(
onFavoriteClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
val colors = RadiolaTheme.colors
val interaction = remember { MutableInteractionSource() }
val heartTint by animateColorAsState(
targetValue = if (isFavorite) colors.accent else colors.textPrimary,
animationSpec = tween(Motion.Medium),
label = "heartTint"
)
Column(
modifier = modifier
.aspectRatio(1f)
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E))
.pressScale(interactionSource = interaction)
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(
Brush.linearGradient(
colors = listOf(
Color(0xFF667eea),
Color(0xFF764ba2)
)
)
)
) {
AsyncImage(
model = station.coverUrl,
contentDescription = station.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
Text(
text = station.name,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(12.dp),
maxLines = 1
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(16.dp))
.background(colors.surface2)
) {
if (!station.coverUrl.isNullOrBlank()) {
AsyncImage(
model = station.coverUrl,
contentDescription = station.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(
Lucide.Radio,
contentDescription = null,
tint = colors.textMuted,
modifier = Modifier.align(Alignment.Center).size(34.dp)
)
}
IconButton(
onClick = onFavoriteClick,
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.padding(10.dp)
.size(32.dp)
.background(
color = Color.Black.copy(alpha = 0.4f),
shape = RoundedCornerShape(8.dp)
)
.clip(RoundedCornerShape(16.dp))
.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f))
.clickable(onClick = onFavoriteClick),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Lucide.Heart,
contentDescription = if (isFavorite) "В избранном" else "Добавить в избранное",
tint = if (isFavorite) Color(0xFFFF4081) else Color.White,
modifier = Modifier.size(18.dp)
contentDescription = if (isFavorite) "В избранном" else "В избранное",
tint = heartTint,
modifier = Modifier.size(17.dp)
)
}
}
Spacer(Modifier.height(10.dp))
Text(
text = station.name,
style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
color = colors.textPrimary,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
if (station.genre.isNotBlank()) {
Text(
text = station.genre,
style = androidx.compose.material3.MaterialTheme.typography.labelMedium,
color = colors.textSecondary,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
fontWeight = FontWeight.Normal
)
}
}
}

View File

@@ -1,17 +1,26 @@
package com.radiola.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.radiola.domain.model.Track
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -23,38 +32,57 @@ fun TrackListItem(
onClick: () -> Unit = {},
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
val interaction = remember { MutableInteractionSource() }
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
.pressScale(interactionSource = interaction)
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = track.coverUrl,
contentDescription = null,
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(8.dp))
)
.size(44.dp)
.clip(RoundedCornerShape(10.dp))
.background(colors.surface2),
contentAlignment = Alignment.Center
) {
if (!track.coverUrl.isNullOrBlank()) {
AsyncImage(
model = track.coverUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(Lucide.Music, null, tint = colors.textSecondary, modifier = Modifier.size(20.dp))
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "${track.artist}${track.song}",
style = MaterialTheme.typography.bodyMedium,
maxLines = 1
style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
color = colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = track.stationName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
style = androidx.compose.material3.MaterialTheme.typography.labelMedium,
color = colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
timestamp?.let {
Spacer(Modifier.width(8.dp))
Text(
text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
style = androidx.compose.material3.MaterialTheme.typography.labelMedium,
color = colors.textMuted
)
}
}