Polishing UI/UX

This commit is contained in:
OmniaX-Dev 2026-06-24 12:02:53 +02:00
parent d4e6b2e8cc
commit 592ceb4b2f
15 changed files with 225 additions and 142 deletions

View file

@ -4,7 +4,7 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-06-23T12:53:26.691218732Z"> <DropdownSelection timestamp="2026-06-24T07:49:03.527980803Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCY20YA30P" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=RFCY20YA30P" />

View file

@ -53,7 +53,10 @@ class MusicRepository(context: Context) {
/** Toggle favorite state for a recording. */ /** Toggle favorite state for a recording. */
suspend fun setFavorite(id: String, favorite: Boolean) = withContext(Dispatchers.IO) { suspend fun setFavorite(id: String, favorite: Boolean) = withContext(Dispatchers.IO) {
dao.setFavorite(id, favorite) // Record WHEN it was favorited so the favorites group can be ordered in
// insertion order. 0 when unfavorited.
val timestamp = if (favorite) System.currentTimeMillis() else 0L
dao.setFavorite(id, favorite, timestamp)
} }
/** Clear the "new" badge for a recording (called when play is first pressed). */ /** Clear the "new" badge for a recording (called when play is first pressed). */

View file

@ -149,7 +149,8 @@ class SyncManager(private val context: Context) {
cachedAudioPath = if (keepPaths) existingEntity?.cachedAudioPath else null, cachedAudioPath = if (keepPaths) existingEntity?.cachedAudioPath else null,
cachedMidiPath = if (keepPaths) existingEntity?.cachedMidiPath else null, cachedMidiPath = if (keepPaths) existingEntity?.cachedMidiPath else null,
isFavorite = existingEntity?.isFavorite ?: false, isFavorite = existingEntity?.isFavorite ?: false,
isNew = existingEntity?.isNew ?: false isNew = existingEntity?.isNew ?: false,
favoritedAt = existingEntity?.favoritedAt ?: 0L
) )
} }
dao.upsert(entitiesToUpsert) dao.upsert(entitiesToUpsert)

View file

@ -5,7 +5,7 @@ import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
@Database(entities = [RecordingEntity::class], version = 2, exportSchema = false) @Database(entities = [RecordingEntity::class], version = 3, exportSchema = false)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun recordingDao(): RecordingDao abstract fun recordingDao(): RecordingDao

View file

@ -27,7 +27,8 @@ fun RecordingEntity.toDomainOrNull(): Recording? {
soundStartSeconds = soundStartSeconds, soundStartSeconds = soundStartSeconds,
fileSize = fileSize, fileSize = fileSize,
isFavorite = isFavorite, isFavorite = isFavorite,
isNew = isNew isNew = isNew,
favoritedAt = favoritedAt
) )
} }
@ -39,7 +40,8 @@ fun Recording.toEntity(
cachedAudioPath: String? = null, cachedAudioPath: String? = null,
cachedMidiPath: String? = null, cachedMidiPath: String? = null,
isFavorite: Boolean = false, isFavorite: Boolean = false,
isNew: Boolean = false isNew: Boolean = false,
favoritedAt: Long = 0L
): RecordingEntity = RecordingEntity( ): RecordingEntity = RecordingEntity(
id = id, id = id,
date = date.format(DATE_FORMAT), date = date.format(DATE_FORMAT),
@ -56,5 +58,6 @@ fun Recording.toEntity(
cachedAudioPath = cachedAudioPath, cachedAudioPath = cachedAudioPath,
cachedMidiPath = cachedMidiPath, cachedMidiPath = cachedMidiPath,
isFavorite = isFavorite, isFavorite = isFavorite,
isNew = isNew isNew = isNew,
favoritedAt = favoritedAt
) )

View file

@ -50,8 +50,8 @@ interface RecordingDao {
// -- favorite / new state -- // -- favorite / new state --
@Query("UPDATE recordings SET isFavorite = :favorite WHERE id = :id") @Query("UPDATE recordings SET isFavorite = :favorite, favoritedAt = :favoritedAt WHERE id = :id")
suspend fun setFavorite(id: String, favorite: Boolean) suspend fun setFavorite(id: String, favorite: Boolean, favoritedAt: Long)
@Query("UPDATE recordings SET isNew = :isNew WHERE id = :id") @Query("UPDATE recordings SET isNew = :isNew WHERE id = :id")
suspend fun setNew(id: String, isNew: Boolean) suspend fun setNew(id: String, isNew: Boolean)

View file

@ -21,5 +21,8 @@ data class RecordingEntity(
val cachedMidiPath: String? = null, val cachedMidiPath: String? = null,
// -- local-only state (never sent by server) -- // -- local-only state (never sent by server) --
val isFavorite: Boolean = false, val isFavorite: Boolean = false,
val isNew: Boolean = false val isNew: Boolean = false,
// Epoch millis when this was favorited; 0 when not favorited.
// Used to order the favorites group in insertion order (oldest first).
val favoritedAt: Long = 0L
) )

View file

@ -22,7 +22,8 @@ data class Recording(
val soundStartSeconds: Double, val soundStartSeconds: Double,
val fileSize: Long, val fileSize: Long,
val isFavorite: Boolean = false, val isFavorite: Boolean = false,
val isNew: Boolean = false val isNew: Boolean = false,
val favoritedAt: Long = 0L
) { ) {
val hasMidi: Boolean get() = midiFileName != null val hasMidi: Boolean get() = midiFileName != null

View file

@ -63,6 +63,7 @@ fun MiniPlayer(
Surface( Surface(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
tonalElevation = 3.dp, tonalElevation = 3.dp,
shadowElevation = 8.dp shadowElevation = 8.dp
) { ) {
@ -218,7 +219,7 @@ fun MiniPlayer(
} }
} }
/** Milliseconds "m:ss". Negative/unknown durations render as 0:00. */ /** Milliseconds to "m:ss". Negative/unknown durations render as 0:00. */
private fun formatTime(ms: Long): String { private fun formatTime(ms: Long): String {
val totalSeconds = (ms.coerceAtLeast(0L) / 1000L).toInt() val totalSeconds = (ms.coerceAtLeast(0L) / 1000L).toInt()
val minutes = totalSeconds / 60 val minutes = totalSeconds / 60

View file

@ -3,21 +3,23 @@ package com.keylightpiano.pianotimeline.ui
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarBorder import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material3.Card import androidx.compose.material3.Card
@ -25,10 +27,11 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -38,6 +41,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment 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.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@ -60,13 +64,14 @@ fun RecordingItem(
isPlaying: Boolean, isPlaying: Boolean,
onPlayClick: () -> Unit, onPlayClick: () -> Unit,
onFavoriteClick: () -> Unit, onFavoriteClick: () -> Unit,
onNotesClick: (() -> Unit)?, onNotesClick: (() -> Unit)?, // null = no MIDI -> Notes button disabled
onShareAudio: () -> Unit, onShareAudio: () -> Unit,
onShareVisualization: (() -> Unit)?, // null when no MIDI onShareVisualization: (() -> Unit)?, // null when no MIDI
onCopyLink: () -> Unit, onCopyLink: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var menuExpanded by remember { mutableStateOf(false) } var menuExpanded by remember { mutableStateOf(false) }
val hasMidi = onNotesClick != null
Box(modifier = modifier.fillMaxWidth()) { Box(modifier = modifier.fillMaxWidth()) {
Card( Card(
@ -81,142 +86,174 @@ fun RecordingItem(
), ),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) { ) {
Row( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 10.dp)
verticalAlignment = Alignment.CenterVertically
) { ) {
FilledTonalIconButton( // Date row (own line at the very top) — favorite star on the right
onClick = onPlayClick, Row(
modifier = Modifier.size(44.dp) verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp)
) { ) {
Icon(
painter = painterResource(
if (isPlaying) android.R.drawable.ic_media_pause
else android.R.drawable.ic_media_play
),
contentDescription = stringResource(if (isPlaying) R.string.cd_pause else R.string.cd_play),
modifier = Modifier.size(22.dp)
)
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = recording.artist, text = recording.formattedDate,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.bodySmall,
color = Color(0xFFE6A900), color = Color(0xFFA3D1FF)
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
Text(
text = recording.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = if (isPlaying) PlayingRed
else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
modifier = Modifier.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = 2000,
initialDelayMillis = 3000,
velocity = 30.dp
)
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = recording.formattedDate,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFA3D1FF)
)
Text("\u00B7", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = recording.formattedDuration,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFA3FFBC)
)
Text("\u00B7", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = recording.performanceLabel,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF999999)
)
}
}
// Favorite star with a spring "pop" on each tap. // "NEW" badge — shown until the recording is first played.
val starScale = remember { Animatable(1f) } if (recording.isNew) {
val starScope = rememberCoroutineScope() Spacer(Modifier.width(8.dp))
IconButton( Box(
onClick = { modifier = Modifier
onFavoriteClick() .clip(RoundedCornerShape(4.dp))
starScope.launch { .background(NewBadgeRed)
// Pop up then settle back with a springy overshoot. .padding(horizontal = 6.dp, vertical = 1.dp)
starScale.snapTo(0.6f) ) {
starScale.animateTo( Text(
targetValue = 1f, text = stringResource(R.string.badge_new),
animationSpec = spring( style = MaterialTheme.typography.labelSmall,
dampingRatio = Spring.DampingRatioMediumBouncy, fontWeight = FontWeight.Bold,
stiffness = Spring.StiffnessMedium color = Color.White
)
) )
} }
}, }
modifier = Modifier.size(36.dp)
) { Spacer(Modifier.weight(1f))
Icon(
imageVector = if (recording.isFavorite) Icons.Filled.Star // Favorite star with a spring "pop" on each tap.
else Icons.Outlined.StarBorder, val starScale = remember { Animatable(1f) }
contentDescription = stringResource(if (recording.isFavorite) R.string.cd_unfavorite else R.string.cd_favorite), val starScope = rememberCoroutineScope()
tint = if (recording.isFavorite) Color(0xFFFFC107) IconButton(
else MaterialTheme.colorScheme.onSurfaceVariant, onClick = {
modifier = Modifier onFavoriteClick()
.size(20.dp) starScope.launch {
.graphicsLayer { starScale.snapTo(0.6f)
scaleX = starScale.value starScale.animateTo(
scaleY = starScale.value targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
)
} }
) },
modifier = Modifier.size(28.dp)
) {
Icon(
imageVector = if (recording.isFavorite) Icons.Filled.Star
else Icons.Outlined.StarBorder,
contentDescription = stringResource(if (recording.isFavorite) R.string.cd_unfavorite else R.string.cd_favorite),
tint = if (recording.isFavorite) Color(0xFFFFC107)
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(20.dp)
.graphicsLayer {
scaleX = starScale.value
scaleY = starScale.value
}
)
}
} }
if (onNotesClick != null) { // Main row: play - artist/title - star - notes
Spacer(Modifier.width(4.dp)) Row(
OutlinedButton( modifier = Modifier.fillMaxWidth(),
onClick = onNotesClick, verticalAlignment = Alignment.CenterVertically
modifier = Modifier.size(width = 60.dp, height = 32.dp), ) {
contentPadding = PaddingValues(0.dp), FilledTonalIconButton(
border = BorderStroke(1.5.dp, NotesGreen) onClick = onPlayClick,
modifier = Modifier.size(44.dp)
) { ) {
Icon(
painter = painterResource(
if (isPlaying) android.R.drawable.ic_media_pause
else android.R.drawable.ic_media_play
),
contentDescription = stringResource(if (isPlaying) R.string.cd_pause else R.string.cd_play),
modifier = Modifier.size(22.dp)
)
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text( Text(
stringResource(R.string.notes), text = recording.artist,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelMedium,
color = NotesGreen color = Color(0xFFE6A900),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = recording.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = if (isPlaying) PlayingRed
else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
modifier = Modifier.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = 2000,
initialDelayMillis = 3000,
velocity = 30.dp
)
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = recording.formattedDuration,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFA3FFBC)
)
Text("\u00B7", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = recording.performanceLabel,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF999999)
)
}
}
Spacer(Modifier.width(4.dp))
// Notes button - round, always present. Enabled only when a MIDI
// file exists; disabled with a "blocked" icon otherwise.
FilledTonalIconButton(
onClick = { onNotesClick?.invoke() },
enabled = hasMidi,
modifier = Modifier.size(44.dp),
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = if (hasMidi) NotesGreen.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceVariant
)
) {
Icon(
imageVector = if (hasMidi) Icons.Filled.MusicNote else Icons.Filled.Block,
contentDescription = stringResource(R.string.notes),
tint = if (hasMidi) NotesGreen
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(22.dp)
) )
} }
} }
} }
} }
// "New" badge: a small red dot in the top-right corner // Bottom divider between cards
if (recording.isNew) { HorizontalDivider(
androidx.compose.foundation.Canvas( modifier = Modifier.align(Alignment.BottomCenter),
modifier = Modifier thickness = 1.dp,
.align(Alignment.TopEnd) color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
.padding(6.dp) )
.size(10.dp)
) {
drawCircle(color = NewBadgeRed)
}
}
// Long-press context menu (extensible — more actions can be added later) // Long-press context menu (extensible - more actions can be added later)
DropdownMenu( DropdownMenu(
expanded = menuExpanded, expanded = menuExpanded,
onDismissRequest = { menuExpanded = false } onDismissRequest = { menuExpanded = false }

View file

@ -48,7 +48,6 @@ import androidx.compose.runtime.LaunchedEffect
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.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -62,7 +61,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.keylightpiano.pianotimeline.R import com.keylightpiano.pianotimeline.R
import kotlinx.coroutines.launch
import com.keylightpiano.pianotimeline.domain.Recording import com.keylightpiano.pianotimeline.domain.Recording
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@ -110,15 +108,45 @@ fun RecordingListScreen(
wasRefreshing = isRefreshing wasRefreshing = isRefreshing
} }
// Favoriting reorders the list (favorites float to the top). We let the // Favoriting/unfavoriting reorders the list. animateItem() animates the
// LazyColumn's animateItem() handle the visual feedback — the toggled card // toggled card to its new spot, but we also want the VIEWPORT to stay put so
// visibly slides to its new position rather than vanishing under the finger. // the list doesn't jump to follow an item that moved far away (e.g.
// We intentionally do NOT force-restore scroll here; the viewport stays put // unfavoriting something near the top sends it way down).
// while items animate to their new spots. //
// Strategy: just before toggling, remember the id + pixel offset of the item
// currently at the top of the viewport. After the list reorders, scroll back
// to that same item. Because the anchor is a STABLE item (not the one that
// moved), the restore lands cleanly and the animation still plays.
var pendingAnchor by remember { mutableStateOf<Pair<String, Int>?>(null) }
val onFavoriteToggle: (Recording) -> Unit = { recording -> val onFavoriteToggle: (Recording) -> Unit = { recording ->
// Anchor to the first visible item that ISN'T the one being toggled —
// anchoring to the toggled item would make the viewport chase it as it
// moves. Fall back to no anchor if the toggled item is the only visible one.
val firstVisibleIndex = listState.firstVisibleItemIndex
val anchorId = (firstVisibleIndex until recordings.size)
.asSequence()
.map { recordings[it].id }
.firstOrNull { it != recording.id }
if (anchorId != null) {
val offset = if (recordings.getOrNull(firstVisibleIndex)?.id == anchorId)
listState.firstVisibleItemScrollOffset else 0
pendingAnchor = anchorId to offset
}
vm.toggleFavorite(recording) vm.toggleFavorite(recording)
} }
// Restore the viewport to the anchored item once the reordered list arrives.
LaunchedEffect(recordings) {
val anchor = pendingAnchor ?: return@LaunchedEffect
val (anchorId, offset) = anchor
val newIndex = recordings.indexOfFirst { it.id == anchorId }
if (newIndex >= 0) {
listState.scrollToItem(newIndex, offset)
}
pendingAnchor = null
}
// Deep link: when the VM requests scrolling to a specific entry, find its // Deep link: when the VM requests scrolling to a specific entry, find its
// index in the current visible list and scroll there. // index in the current visible list and scroll there.
val scrollToId by vm.scrollToId.collectAsStateWithLifecycle() val scrollToId by vm.scrollToId.collectAsStateWithLifecycle()

View file

@ -148,13 +148,16 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
_hiddenArtists, _hiddenArtists,
_hiddenYears _hiddenYears
) { all, query, sort, hidden, hiddenYears -> ) { all, query, sort, hidden, hiddenYears ->
all.filterByArtist(hidden) val filteredSorted = all.filterByArtist(hidden)
.filterByYear(hiddenYears) .filterByYear(hiddenYears)
.filterBySearch(query) .filterBySearch(query)
.sortedWith(sort.comparator()) .sortedWith(sort.comparator())
// Favorites float to the top, preserving the sort order within each group. // Split into favorites and the rest. Favorites sit at the top, ordered by
// sortedBy is a STABLE sort, so this keeps the existing relative order. // WHEN they were favorited (oldest first → newest favorite at the bottom
.sortedByDescending { it.isFavorite } // of the group), which reads as insertion order. Non-favorites keep the
// user's chosen sort.
val (favorites, others) = filteredSorted.partition { it.isFavorite }
favorites.sortedBy { it.favoritedAt } + others
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// ── Init: run sync on creation ───────────────────────────────────── // ── Init: run sync on creation ─────────────────────────────────────

View file

@ -32,6 +32,7 @@
<string name="cd_favorite">A\u00F1adir a favoritos</string> <string name="cd_favorite">A\u00F1adir a favoritos</string>
<string name="cd_unfavorite">Quitar de favoritos</string> <string name="cd_unfavorite">Quitar de favoritos</string>
<string name="notes">Notas</string> <string name="notes">Notas</string>
<string name="badge_new">NUEVO</string>
<!-- Long-press menu --> <!-- Long-press menu -->
<string name="menu_share_audio">Compartir audio</string> <string name="menu_share_audio">Compartir audio</string>

View file

@ -32,6 +32,7 @@
<string name="cd_favorite">Aggiungi ai preferiti</string> <string name="cd_favorite">Aggiungi ai preferiti</string>
<string name="cd_unfavorite">Rimuovi dai preferiti</string> <string name="cd_unfavorite">Rimuovi dai preferiti</string>
<string name="notes">Note</string> <string name="notes">Note</string>
<string name="badge_new">NUOVO</string>
<!-- Long-press menu --> <!-- Long-press menu -->
<string name="menu_share_audio">Condividi audio</string> <string name="menu_share_audio">Condividi audio</string>

View file

@ -32,6 +32,7 @@
<string name="cd_favorite">Favorite</string> <string name="cd_favorite">Favorite</string>
<string name="cd_unfavorite">Unfavorite</string> <string name="cd_unfavorite">Unfavorite</string>
<string name="notes">Notes</string> <string name="notes">Notes</string>
<string name="badge_new">NEW</string>
<!-- Long-press menu --> <!-- Long-press menu -->
<string name="menu_share_audio">Share audio</string> <string name="menu_share_audio">Share audio</string>