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>
|
<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" />
|
||||||
|
|
|
||||||
|
|
@ -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). */
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 ─────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue