Polishing UI/UX
This commit is contained in:
parent
d4e6b2e8cc
commit
592ceb4b2f
15 changed files with 225 additions and 142 deletions
|
|
@ -4,7 +4,7 @@
|
|||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<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">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCY20YA30P" />
|
||||
|
|
|
|||
|
|
@ -53,7 +53,10 @@ class MusicRepository(context: Context) {
|
|||
|
||||
/** Toggle favorite state for a recording. */
|
||||
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). */
|
||||
|
|
|
|||
|
|
@ -149,7 +149,8 @@ class SyncManager(private val context: Context) {
|
|||
cachedAudioPath = if (keepPaths) existingEntity?.cachedAudioPath else null,
|
||||
cachedMidiPath = if (keepPaths) existingEntity?.cachedMidiPath else null,
|
||||
isFavorite = existingEntity?.isFavorite ?: false,
|
||||
isNew = existingEntity?.isNew ?: false
|
||||
isNew = existingEntity?.isNew ?: false,
|
||||
favoritedAt = existingEntity?.favoritedAt ?: 0L
|
||||
)
|
||||
}
|
||||
dao.upsert(entitiesToUpsert)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import androidx.room.Database
|
|||
import androidx.room.Room
|
||||
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 fun recordingDao(): RecordingDao
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ fun RecordingEntity.toDomainOrNull(): Recording? {
|
|||
soundStartSeconds = soundStartSeconds,
|
||||
fileSize = fileSize,
|
||||
isFavorite = isFavorite,
|
||||
isNew = isNew
|
||||
isNew = isNew,
|
||||
favoritedAt = favoritedAt
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +40,8 @@ fun Recording.toEntity(
|
|||
cachedAudioPath: String? = null,
|
||||
cachedMidiPath: String? = null,
|
||||
isFavorite: Boolean = false,
|
||||
isNew: Boolean = false
|
||||
isNew: Boolean = false,
|
||||
favoritedAt: Long = 0L
|
||||
): RecordingEntity = RecordingEntity(
|
||||
id = id,
|
||||
date = date.format(DATE_FORMAT),
|
||||
|
|
@ -56,5 +58,6 @@ fun Recording.toEntity(
|
|||
cachedAudioPath = cachedAudioPath,
|
||||
cachedMidiPath = cachedMidiPath,
|
||||
isFavorite = isFavorite,
|
||||
isNew = isNew
|
||||
isNew = isNew,
|
||||
favoritedAt = favoritedAt
|
||||
)
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ interface RecordingDao {
|
|||
|
||||
// -- favorite / new state --
|
||||
|
||||
@Query("UPDATE recordings SET isFavorite = :favorite WHERE id = :id")
|
||||
suspend fun setFavorite(id: String, favorite: Boolean)
|
||||
@Query("UPDATE recordings SET isFavorite = :favorite, favoritedAt = :favoritedAt WHERE id = :id")
|
||||
suspend fun setFavorite(id: String, favorite: Boolean, favoritedAt: Long)
|
||||
|
||||
@Query("UPDATE recordings SET isNew = :isNew WHERE id = :id")
|
||||
suspend fun setNew(id: String, isNew: Boolean)
|
||||
|
|
|
|||
|
|
@ -21,5 +21,8 @@ data class RecordingEntity(
|
|||
val cachedMidiPath: String? = null,
|
||||
// -- local-only state (never sent by server) --
|
||||
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ data class Recording(
|
|||
val soundStartSeconds: Double,
|
||||
val fileSize: Long,
|
||||
val isFavorite: Boolean = false,
|
||||
val isNew: Boolean = false
|
||||
val isNew: Boolean = false,
|
||||
val favoritedAt: Long = 0L
|
||||
) {
|
||||
val hasMidi: Boolean get() = midiFileName != null
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ fun MiniPlayer(
|
|||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||
tonalElevation = 3.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 {
|
||||
val totalSeconds = (ms.coerceAtLeast(0L) / 1000L).toInt()
|
||||
val minutes = totalSeconds / 60
|
||||
|
|
|
|||
|
|
@ -3,21 +3,23 @@ package com.keylightpiano.pianotimeline.ui
|
|||
import androidx.compose.animation.core.Animatable
|
||||
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.background
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.outlined.StarBorder
|
||||
import androidx.compose.material3.Card
|
||||
|
|
@ -25,10 +27,11 @@ import androidx.compose.material3.CardDefaults
|
|||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -38,6 +41,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.setValue
|
||||
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.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.painterResource
|
||||
|
|
@ -60,13 +64,14 @@ fun RecordingItem(
|
|||
isPlaying: Boolean,
|
||||
onPlayClick: () -> Unit,
|
||||
onFavoriteClick: () -> Unit,
|
||||
onNotesClick: (() -> Unit)?,
|
||||
onNotesClick: (() -> Unit)?, // null = no MIDI -> Notes button disabled
|
||||
onShareAudio: () -> Unit,
|
||||
onShareVisualization: (() -> Unit)?, // null when no MIDI
|
||||
onCopyLink: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val hasMidi = onNotesClick != null
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
Card(
|
||||
|
|
@ -81,142 +86,174 @@ fun RecordingItem(
|
|||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Row(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onPlayClick,
|
||||
modifier = Modifier.size(44.dp)
|
||||
// Date row (own line at the very top) — favorite star on the right
|
||||
Row(
|
||||
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 = recording.artist,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFFE6A900),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
text = recording.formattedDate,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFFA3D1FF)
|
||||
)
|
||||
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.
|
||||
val starScale = remember { Animatable(1f) }
|
||||
val starScope = rememberCoroutineScope()
|
||||
IconButton(
|
||||
onClick = {
|
||||
onFavoriteClick()
|
||||
starScope.launch {
|
||||
// Pop up then settle back with a springy overshoot.
|
||||
starScale.snapTo(0.6f)
|
||||
starScale.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
// "NEW" badge — shown until the recording is first played.
|
||||
if (recording.isNew) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(NewBadgeRed)
|
||||
.padding(horizontal = 6.dp, vertical = 1.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.badge_new),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(36.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
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
// Favorite star with a spring "pop" on each tap.
|
||||
val starScale = remember { Animatable(1f) }
|
||||
val starScope = rememberCoroutineScope()
|
||||
IconButton(
|
||||
onClick = {
|
||||
onFavoriteClick()
|
||||
starScope.launch {
|
||||
starScale.snapTo(0.6f)
|
||||
starScale.animateTo(
|
||||
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) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
onClick = onNotesClick,
|
||||
modifier = Modifier.size(width = 60.dp, height = 32.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
border = BorderStroke(1.5.dp, NotesGreen)
|
||||
// Main row: play - artist/title - star - notes
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
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(
|
||||
stringResource(R.string.notes),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = NotesGreen
|
||||
text = recording.artist,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
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
|
||||
if (recording.isNew) {
|
||||
androidx.compose.foundation.Canvas(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(6.dp)
|
||||
.size(10.dp)
|
||||
) {
|
||||
drawCircle(color = NewBadgeRed)
|
||||
}
|
||||
}
|
||||
// Bottom divider between cards
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
|
||||
)
|
||||
|
||||
// Long-press context menu (extensible — more actions can be added later)
|
||||
// Long-press context menu (extensible - more actions can be added later)
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false }
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -62,7 +61,6 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.keylightpiano.pianotimeline.R
|
||||
import kotlinx.coroutines.launch
|
||||
import com.keylightpiano.pianotimeline.domain.Recording
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
|
|
@ -110,15 +108,45 @@ fun RecordingListScreen(
|
|||
wasRefreshing = isRefreshing
|
||||
}
|
||||
|
||||
// Favoriting reorders the list (favorites float to the top). We let the
|
||||
// LazyColumn's animateItem() handle the visual feedback — the toggled card
|
||||
// visibly slides to its new position rather than vanishing under the finger.
|
||||
// We intentionally do NOT force-restore scroll here; the viewport stays put
|
||||
// while items animate to their new spots.
|
||||
// Favoriting/unfavoriting reorders the list. animateItem() animates the
|
||||
// toggled card to its new spot, but we also want the VIEWPORT to stay put so
|
||||
// the list doesn't jump to follow an item that moved far away (e.g.
|
||||
// unfavoriting something near the top sends it way down).
|
||||
//
|
||||
// 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 ->
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// index in the current visible list and scroll there.
|
||||
val scrollToId by vm.scrollToId.collectAsStateWithLifecycle()
|
||||
|
|
|
|||
|
|
@ -148,13 +148,16 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
_hiddenArtists,
|
||||
_hiddenYears
|
||||
) { all, query, sort, hidden, hiddenYears ->
|
||||
all.filterByArtist(hidden)
|
||||
val filteredSorted = all.filterByArtist(hidden)
|
||||
.filterByYear(hiddenYears)
|
||||
.filterBySearch(query)
|
||||
.sortedWith(sort.comparator())
|
||||
// Favorites float to the top, preserving the sort order within each group.
|
||||
// sortedBy is a STABLE sort, so this keeps the existing relative order.
|
||||
.sortedByDescending { it.isFavorite }
|
||||
// Split into favorites and the rest. Favorites sit at the top, ordered by
|
||||
// WHEN they were favorited (oldest first → newest favorite at the bottom
|
||||
// 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())
|
||||
|
||||
// ── Init: run sync on creation ─────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
<string name="cd_favorite">A\u00F1adir a favoritos</string>
|
||||
<string name="cd_unfavorite">Quitar de favoritos</string>
|
||||
<string name="notes">Notas</string>
|
||||
<string name="badge_new">NUEVO</string>
|
||||
|
||||
<!-- Long-press menu -->
|
||||
<string name="menu_share_audio">Compartir audio</string>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
<string name="cd_favorite">Aggiungi ai preferiti</string>
|
||||
<string name="cd_unfavorite">Rimuovi dai preferiti</string>
|
||||
<string name="notes">Note</string>
|
||||
<string name="badge_new">NUOVO</string>
|
||||
|
||||
<!-- Long-press menu -->
|
||||
<string name="menu_share_audio">Condividi audio</string>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
<string name="cd_favorite">Favorite</string>
|
||||
<string name="cd_unfavorite">Unfavorite</string>
|
||||
<string name="notes">Notes</string>
|
||||
<string name="badge_new">NEW</string>
|
||||
|
||||
<!-- Long-press menu -->
|
||||
<string name="menu_share_audio">Share audio</string>
|
||||
|
|
|
|||
Loading…
Reference in a new issue