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>
<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" />

View file

@ -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). */

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,10 +86,82 @@ fun RecordingItem(
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
.padding(horizontal = 12.dp, vertical = 10.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)
) {
Text(
text = recording.formattedDate,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFA3D1FF)
)
// "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
)
}
}
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
}
)
}
}
// Main row: play - artist/title - star - notes
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
FilledTonalIconButton(
@ -129,13 +206,6 @@ fun RecordingItem(
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,
@ -151,72 +221,39 @@ fun RecordingItem(
}
}
// 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
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
)
)
}
},
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
}
)
}
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)
) {
Text(
stringResource(R.string.notes),
style = MaterialTheme.typography.labelSmall,
color = NotesGreen
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 }

View file

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

View file

@ -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 ─────────────────────────────────────

View file

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

View file

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

View file

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