Polishing UX

This commit is contained in:
OmniaX-Dev 2026-06-23 11:18:25 +02:00
parent a1ff435e81
commit 02dd3c154a
45 changed files with 417 additions and 284 deletions

View file

@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-06-23T07:28:08.761449449Z">
<DropdownSelection timestamp="2026-06-23T09:15:42.913635127Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCY20YA30P" />

View file

@ -92,4 +92,5 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.material.icons.core)
implementation("androidx.compose.material:material-icons-extended")
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View file

@ -4,17 +4,19 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.keylightpiano.pianotimeline.ui.theme.PianoTimelineTheme
import androidx.compose.foundation.isSystemInDarkTheme
import com.keylightpiano.pianotimeline.ui.RecordingListScreen
import com.keylightpiano.pianotimeline.ui.theme.PianoTimelineTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PianoTimelineTheme {
// Force dark mode regardless of system setting
PianoTimelineTheme(darkTheme = true) {
RecordingListScreen()
}
}
}
}
}

View file

@ -51,6 +51,16 @@ class MusicRepository(context: Context) {
dao.setCachedMidiPath(id, path)
}
/** Toggle favorite state for a recording. */
suspend fun setFavorite(id: String, favorite: Boolean) = withContext(Dispatchers.IO) {
dao.setFavorite(id, favorite)
}
/** Clear the "new" badge for a recording (called when play is first pressed). */
suspend fun clearNew(id: String) = withContext(Dispatchers.IO) {
dao.clearNew(id)
}
/**
* Clear all cached audio/midi files from disk and reset paths in the database.
* Used by the "clear cache" setting.

View file

@ -80,18 +80,30 @@ class SyncManager(private val context: Context) {
val prefs = context.dataStore.data.first()
val cachedRevision = prefs[KEY_REVISION] ?: -1
if (serverRevision == cachedRevision) {
// Short-circuit only if the revision matches AND we actually have data.
// The dao.count() guard protects against a desync where the stored
// revision is current but the DB was wiped — without it, an empty cache
// with a matching revision would never repopulate.
if (serverRevision == cachedRevision && dao.count() > 0) {
Log.d(TAG, "Revision $serverRevision unchanged — cache is current")
return@withContext SyncResult.UpToDate
}
Log.d(TAG, "Revision changed: $cachedRevision$serverRevision — syncing")
Log.d(TAG, "Revision changed: $cachedRevision$serverRevision (or empty cache) — syncing")
// 3. Build lookup maps for diffing
val serverById = serverRecordings.associateBy { it.id }
val cachedIds = dao.getAllIds().toSet()
val serverIds = serverById.keys
// First sync = the cache was actually empty before this run.
// We derive this from the DB itself, NOT the stored revision, because
// the DB and the stored revision can fall out of step (e.g. the DB gets
// wiped by a destructive migration while DataStore keeps the old revision).
// Relying on the revision caused every recording to be flagged "new" on
// a fresh install where a stale revision lingered.
val isFirstSync = cachedIds.isEmpty()
// 4a. New recordings (on server but not in cache)
val addedIds = serverIds - cachedIds
// 4b. Removed recordings (in cache but not on server)
@ -123,21 +135,32 @@ class SyncManager(private val context: Context) {
}
// 7. Upsert ALL server recordings into the database.
// For entries that already exist and haven't changed size,
// we preserve their cached file paths.
// For entries that already exist, we preserve their cached file
// paths (unless size changed) and ALWAYS preserve favorite/new flags.
val entitiesToUpsert = serverRecordings.map { recording ->
val existingEntity = if (recording.id in commonIds && recording.id !in sizeChangedIds) {
// For any common id, read the old row to carry forward local state.
val existingEntity = if (recording.id in commonIds) {
dao.getById(recording.id)
} else {
null
}
val keepPaths = recording.id in commonIds && recording.id !in sizeChangedIds
recording.toEntity(
cachedAudioPath = existingEntity?.cachedAudioPath,
cachedMidiPath = existingEntity?.cachedMidiPath
cachedAudioPath = if (keepPaths) existingEntity?.cachedAudioPath else null,
cachedMidiPath = if (keepPaths) existingEntity?.cachedMidiPath else null,
isFavorite = existingEntity?.isFavorite ?: false,
isNew = existingEntity?.isNew ?: false
)
}
dao.upsert(entitiesToUpsert)
// 7b. Mark genuinely-new entries with the "new" badge — but NOT on the
// very first sync (otherwise every recording would be flagged new).
if (!isFirstSync && addedIds.isNotEmpty()) {
dao.markNew(addedIds)
Log.d(TAG, "Flagged ${addedIds.size} new recordings with badge")
}
// 8. Store the new revision
context.dataStore.edit { it[KEY_REVISION] = serverRevision }

View file

@ -5,15 +5,7 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
/**
* The Room database. version = 1 for our first schema.
* If we ever change RecordingEntity's columns, we bump the version
* and provide a migration (or fallbackToDestructiveMigration for dev).
*
* The companion object provides a thread-safe singleton via Double-Checked Locking.
* This is the standard Room pattern you want exactly one database instance per app.
*/
@Database(entities = [RecordingEntity::class], version = 1, exportSchema = false)
@Database(entities = [RecordingEntity::class], version = 2, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun recordingDao(): RecordingDao
@ -29,7 +21,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java,
"piano_timeline.db"
)
.fallbackToDestructiveMigration() // fine during dev; revisit for production
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
.also { INSTANCE = it }
}

View file

@ -6,11 +6,6 @@ import java.time.format.DateTimeFormatter
private val DATE_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
/**
* Entity Domain. Used when reading from the cache to give the UI clean types.
* Returns null if the date is somehow corrupted (shouldn't happen if we wrote it,
* but defensive coding never hurt).
*/
fun RecordingEntity.toDomainOrNull(): Recording? {
val parsedDate = try {
LocalDate.parse(date.trim(), DATE_FORMAT)
@ -30,17 +25,21 @@ fun RecordingEntity.toDomainOrNull(): Recording? {
midiFileName = midiFileName,
durationSeconds = durationSeconds,
soundStartSeconds = soundStartSeconds,
fileSize = fileSize
fileSize = fileSize,
isFavorite = isFavorite,
isNew = isNew
)
}
/**
* Domain Entity. Used when storing fresh server data into Room.
* cachedAudioPath/cachedMidiPath default to null (not yet downloaded).
* Domain -> Entity. cachedAudioPath/cachedMidiPath and the local flags
* default to their "fresh" values; callers preserve existing ones where needed.
*/
fun Recording.toEntity(
cachedAudioPath: String? = null,
cachedMidiPath: String? = null
cachedMidiPath: String? = null,
isFavorite: Boolean = false,
isNew: Boolean = false
): RecordingEntity = RecordingEntity(
id = id,
date = date.format(DATE_FORMAT),
@ -55,5 +54,7 @@ fun Recording.toEntity(
soundStartSeconds = soundStartSeconds,
fileSize = fileSize,
cachedAudioPath = cachedAudioPath,
cachedMidiPath = cachedMidiPath
cachedMidiPath = cachedMidiPath,
isFavorite = isFavorite,
isNew = isNew
)

View file

@ -6,82 +6,63 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
/**
* DAO = Data Access Object. Room generates the SQL implementation at compile time
* from these annotated functions. Every function is either suspend (one-shot)
* or returns a Flow (live-updating stream).
*
* Convention: suspend for writes and one-shot reads, Flow for reads the UI observes.
*/
@Dao
interface RecordingDao {
// ── Reads the UI will observe ──────────────────────────────────────
/** All recordings, newest first. Returns a Flow so the list auto-updates. */
@Query("SELECT * FROM recordings ORDER BY " +
"substr(date, 7, 4) DESC, " + // year
"substr(date, 4, 2) DESC, " + // month
"substr(date, 1, 2) DESC") // day
"substr(date, 7, 4) DESC, " +
"substr(date, 4, 2) DESC, " +
"substr(date, 1, 2) DESC")
fun observeAllByDate(): Flow<List<RecordingEntity>>
// ── One-shot reads for sync logic ──────────────────────────────────
/** All IDs currently in the cache. Used to diff against the fresh server list. */
@Query("SELECT id FROM recordings")
suspend fun getAllIds(): List<String>
/** IDs paired with their file sizes. Used to detect changed files. */
@Query("SELECT id, fileSize FROM recordings")
suspend fun getIdAndSizes(): List<IdAndSize>
/** Get a single recording by ID. */
@Query("SELECT * FROM recordings WHERE id = :id")
suspend fun getById(id: String): RecordingEntity?
// ── Writes ─────────────────────────────────────────────────────────
/**
* Insert or replace recordings. REPLACE means: if a row with the same
* primary key (id) already exists, delete it and insert the new one.
* This is the simplest upsert strategy and works perfectly for our sync
* because the new row carries the fresh metadata from the server.
*
* IMPORTANT: REPLACE deletes the old row first, which means cachedAudioPath
* and cachedMidiPath would be lost. The sync logic must preserve those
* fields explicitly (copy them from the old row onto the new entity before
* inserting). See SyncManager for how this is handled.
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(recordings: List<RecordingEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(recording: RecordingEntity)
/** Delete specific recordings by ID. Used when the server removed entries. */
@Query("DELETE FROM recordings WHERE id IN (:ids)")
suspend fun deleteByIds(ids: Set<String>)
/** Nuke cached file paths for a recording (e.g. file-size changed, must re-download). */
@Query("UPDATE recordings SET cachedAudioPath = NULL, cachedMidiPath = NULL WHERE id = :id")
suspend fun clearCachedPaths(id: String)
/** Set the cached audio path after a successful download. */
@Query("UPDATE recordings SET cachedAudioPath = :path WHERE id = :id")
suspend fun setCachedAudioPath(id: String, path: String)
/** Set the cached MIDI path after a successful download. */
@Query("UPDATE recordings SET cachedMidiPath = :path WHERE id = :id")
suspend fun setCachedMidiPath(id: String, path: String)
/** Clear ALL cached file paths. Used by the "clear cache" setting. */
@Query("UPDATE recordings SET cachedAudioPath = NULL, cachedMidiPath = NULL")
suspend fun clearAllCachedPaths()
/** Total number of recordings. Handy for the UI or debugging. */
@Query("SELECT COUNT(*) FROM recordings")
suspend fun count(): Int
// -- favorite / new state --
@Query("UPDATE recordings SET isFavorite = :favorite WHERE id = :id")
suspend fun setFavorite(id: String, favorite: Boolean)
@Query("UPDATE recordings SET isNew = :isNew WHERE id = :id")
suspend fun setNew(id: String, isNew: Boolean)
/** Mark several recordings as new in one shot (used by sync for added entries). */
@Query("UPDATE recordings SET isNew = 1 WHERE id IN (:ids)")
suspend fun markNew(ids: Set<String>)
/** Clear the "new" flag for a recording (called when its play button is first pressed). */
@Query("UPDATE recordings SET isNew = 0 WHERE id = :id")
suspend fun clearNew(id: String)
}
/** Lightweight projection for the file-size diff check. */
data class IdAndSize(val id: String, val fileSize: Long)

View file

@ -3,31 +3,23 @@ package com.keylightpiano.pianotimeline.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* One row in the Room database = one recording's metadata + cache state.
*
* Room needs an @Entity class to know what columns the table has.
* Each `val` becomes a column. Room handles String, Int, Long, Double natively.
* It does NOT know LocalDate, so we store the date as a String ("dd.MM.yyyy")
* and parse it when mapping back to the domain model.
*
* The two nullable path fields track whether we've downloaded the actual files.
* null = not cached yet. Non-null = file lives at that path on disk.
*/
@Entity(tableName = "recordings")
data class RecordingEntity(
@PrimaryKey val id: String, // MD5 of filename — stable key
val date: String, // stored as "dd.MM.yyyy", parsed on read
@PrimaryKey val id: String,
val date: String,
val artist: String,
val title: String,
val extraInfo: String,
val performanceNumber: Int,
val totalPerformances: Int,
val fileName: String, // raw filename (not URL-encoded)
val midiFileName: String?, // null = no MIDI file exists for this piece
val fileName: String,
val midiFileName: String?,
val durationSeconds: Double,
val soundStartSeconds: Double,
val fileSize: Long, // from list.json — used for change detection
val cachedAudioPath: String? = null, // null = not downloaded yet
val cachedMidiPath: String? = null // null = not downloaded yet
val fileSize: Long,
val cachedAudioPath: String? = null,
val cachedMidiPath: String? = null,
// -- local-only state (never sent by server) --
val isFavorite: Boolean = false,
val isNew: Boolean = false
)

View file

@ -6,38 +6,33 @@ import java.util.Locale
/**
* The clean, app-facing model. Real types, sane defaults, computed helpers.
* The UI only ever touches this never the raw DTO.
* The UI only ever touches this -- never the raw DTO.
*/
data class Recording(
val id: String, // MD5 of the filename — stable primary key
val date: LocalDate, // parsed from "dd.MM.yyyy"
val artist: String, // the REAL artist, shown on the row as-is
val id: String,
val date: LocalDate,
val artist: String,
val title: String,
val extraInfo: String,
val performanceNumber: Int,
val totalPerformances: Int,
val fileName: String, // raw, NOT url-encoded (encode at fetch time)
val midiFileName: String?, // null when the JSON had ""
val fileName: String,
val midiFileName: String?,
val durationSeconds: Double,
val soundStartSeconds: Double,
val fileSize: Long
val fileSize: Long,
val isFavorite: Boolean = false,
val isNew: Boolean = false
) {
/** True when there's a MIDI file → the "Notes" button should appear. */
val hasMidi: Boolean get() = midiFileName != null
/** "22 Jun 2026" style for display. */
val formattedDate: String
get() = date.format(DateTimeFormatter.ofPattern("d MMM yyyy", Locale.ENGLISH))
/**
* Artist used for grouping / filtering only.
* Anything containing "soundtrack" (case-insensitive) collapses to "Soundtracks".
* The real `artist` is left untouched for display.
*/
val groupingArtist: String
get() = if (artist.contains("soundtrack", ignoreCase = true)) "Soundtracks" else artist
/** "4:16" style. durationSeconds is wall-clock length of the file. */
val formattedDuration: String
get() {
val total = durationSeconds.toInt()
@ -46,7 +41,6 @@ data class Recording(
return "%d:%02d".format(minutes, seconds)
}
/** "2 / 2" — this performance number out of the total for the piece. */
val performanceLabel: String
get() = "$performanceNumber / $totalPerformances"
}

View file

@ -0,0 +1,67 @@
package com.keylightpiano.pianotimeline.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Draws a thin, auto-fading scrollbar on the trailing edge of a scrollable list,
* the way most Android apps show one: invisible at rest, fades in while scrolling.
*
* This is an approximation that works well for lists where items are roughly
* uniform height (ours are): the thumb size/position is estimated from the
* visible item indices rather than exact pixel offsets.
*/
fun Modifier.fadingScrollbar(
state: LazyListState,
width: Dp = 4.dp,
color: Color = Color.Gray
): Modifier = composed {
// Scrollbar is visible while actively scrolling, then fades out.
val targetAlpha = if (state.isScrollInProgress) 0.5f else 0f
val alpha by animateFloatAsState(
targetValue = targetAlpha,
animationSpec = tween(durationMillis = if (state.isScrollInProgress) 150 else 500),
label = "scrollbarAlpha"
)
drawWithContent {
drawContent()
val layoutInfo = state.layoutInfo
val totalItems = layoutInfo.totalItemsCount
val visibleItems = layoutInfo.visibleItemsInfo
if (totalItems > 0 && visibleItems.isNotEmpty() && alpha > 0f) {
val firstVisible = visibleItems.first().index
val visibleCount = visibleItems.size
// Fraction of the list that's on screen → thumb height.
val viewportHeight = size.height
val proportionVisible = (visibleCount.toFloat() / totalItems).coerceIn(0.05f, 1f)
val thumbHeight = viewportHeight * proportionVisible
// Scroll progress → thumb vertical position.
val scrollableItems = (totalItems - visibleCount).coerceAtLeast(1)
val scrollProgress = (firstVisible.toFloat() / scrollableItems).coerceIn(0f, 1f)
val thumbTop = (viewportHeight - thumbHeight) * scrollProgress
val widthPx = width.toPx()
drawRoundRect(
color = color.copy(alpha = alpha),
topLeft = Offset(size.width - widthPx, thumbTop),
size = Size(widthPx, thumbHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(widthPx / 2, widthPx / 2)
)
}
}
}

View file

@ -2,6 +2,7 @@ package com.keylightpiano.pianotimeline.ui
import androidx.compose.foundation.basicMarquee
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
@ -10,13 +11,18 @@ 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.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.foundation.BorderStroke
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -27,109 +33,129 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.keylightpiano.pianotimeline.domain.Recording
private val NotesGreen = Color(0xFF25B300)
private val NewBadgeRed = Color(0xFFE53935)
@Composable
fun RecordingItem(
recording: Recording,
onPlayClick: () -> Unit,
onFavoriteClick: () -> Unit,
onNotesClick: (() -> Unit)?,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
Box(modifier = modifier.fillMaxWidth()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
// Play button
FilledTonalIconButton(
onClick = onPlayClick,
modifier = Modifier.size(44.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(android.R.drawable.ic_media_play),
contentDescription = "Play",
modifier = Modifier.size(22.dp)
)
}
Spacer(Modifier.width(12.dp))
// Text content
Column(modifier = Modifier.weight(1f)) {
// Artist
Text(
text = recording.artist,
style = MaterialTheme.typography.labelMedium,
color = Color(0xFFE6A900),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Title — marquee scrolls when it doesn't fit
Text(
text = recording.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
modifier = Modifier.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = 2000, // pause before starting to scroll
initialDelayMillis = 3000, // pause on first appearance
velocity = 30.dp // scroll speed
)
)
// Metadata row
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
FilledTonalIconButton(
onClick = onPlayClick,
modifier = Modifier.size(44.dp)
) {
Text(
text = recording.formattedDate,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFA3D1FF)
)
Text(
text = "·",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = recording.formattedDuration,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFA3FFBC)
)
Text(
text = "·",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = recording.performanceLabel,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF999999)
Icon(
painter = painterResource(android.R.drawable.ic_media_play),
contentDescription = "Play",
modifier = Modifier.size(22.dp)
)
}
}
// Notes button (only if MIDI exists)
if (onNotesClick != null) {
Spacer(Modifier.width(8.dp))
OutlinedButton(
onClick = onNotesClick,
modifier = Modifier.size(width = 60.dp, height = 32.dp),
contentPadding = PaddingValues(0.dp)
) {
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
"Notes",
style = MaterialTheme.typography.labelSmall
text = recording.artist,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = recording.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
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 = MaterialTheme.colorScheme.onSurfaceVariant
)
Text("\u00B7", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = recording.formattedDuration,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text("\u00B7", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = recording.performanceLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Favorite star
IconButton(onClick = onFavoriteClick, modifier = Modifier.size(36.dp)) {
Icon(
imageVector = if (recording.isFavorite) Icons.Filled.Star
else Icons.Outlined.StarBorder,
contentDescription = if (recording.isFavorite) "Unfavorite" else "Favorite",
tint = if (recording.isFavorite) Color(0xFFFFC107)
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
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(
"Notes",
style = MaterialTheme.typography.labelSmall,
color = NotesGreen
)
}
}
}
}
// "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)
}
}
}

View file

@ -3,6 +3,7 @@ package com.keylightpiano.pianotimeline.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -15,8 +16,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
@ -36,7 +39,9 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -45,10 +50,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.keylightpiano.pianotimeline.R
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@ -58,6 +65,7 @@ fun RecordingListScreen(
) {
val syncState by vm.syncState.collectAsStateWithLifecycle()
val recordings by vm.recordings.collectAsStateWithLifecycle()
val isRefreshing by vm.isRefreshing.collectAsStateWithLifecycle()
val searchQuery by vm.searchQuery.collectAsStateWithLifecycle()
val sortMode by vm.sortMode.collectAsStateWithLifecycle()
val hiddenArtists by vm.hiddenArtists.collectAsStateWithLifecycle()
@ -67,9 +75,25 @@ fun RecordingListScreen(
var showSortMenu by remember { mutableStateOf(false) }
var showArtistFilter by rememberSaveable { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val listState = rememberLazyListState()
// Whether any filter is active — used to tint the search icon
// Bug fix: when the sort mode changes, jump back to the TOP of the list
// instead of the LazyColumn trying (and failing) to preserve scroll position.
LaunchedEffect(sortMode) {
listState.scrollToItem(0)
}
// After a pull-to-refresh finishes, scroll to top so any newly-pulled entries
// (which appear at the top, newest-first) are immediately visible.
var wasRefreshing by remember { mutableStateOf(false) }
LaunchedEffect(isRefreshing) {
if (wasRefreshing && !isRefreshing) {
listState.animateScrollToItem(0)
}
wasRefreshing = isRefreshing
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val hasActiveFilter = searchQuery.isNotEmpty() || hiddenArtists.isNotEmpty()
Scaffold(
@ -77,10 +101,16 @@ fun RecordingListScreen(
topBar = {
TopAppBar(
title = {
Text(
"Piano Timeline",
fontWeight = FontWeight.Bold
)
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(R.drawable.logo),
contentDescription = null, // decorative; title text conveys meaning
modifier = Modifier
.size(28.dp)
.padding(end = 8.dp)
)
Text("Piano Timeline", fontWeight = FontWeight.Bold)
}
},
actions = {
IconButton(onClick = { showControls = !showControls }) {
@ -98,23 +128,17 @@ fun RecordingListScreen(
) { innerPadding ->
when (val s = syncState) {
is SyncState.Syncing -> {
Box(
Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center
) {
Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text("Syncing", style = MaterialTheme.typography.bodyMedium)
Text("Syncing...", style = MaterialTheme.typography.bodyMedium)
}
}
}
is SyncState.Error -> {
Box(
Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center
) {
Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
@ -128,29 +152,19 @@ fun RecordingListScreen(
}
is SyncState.Ready -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
// ── Collapsible search + filter + sort block ───
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
AnimatedVisibility(
visible = showControls,
enter = expandVertically(),
exit = shrinkVertically()
) {
Column(modifier = Modifier.fillMaxWidth()) {
// Search bar
OutlinedTextField(
value = searchQuery,
onValueChange = { vm.setSearchQuery(it) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
placeholder = { Text("Search artist, title…") },
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null)
},
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
placeholder = { Text("Search artist, title...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { vm.setSearchQuery("") }) {
@ -162,32 +176,22 @@ fun RecordingListScreen(
shape = MaterialTheme.shapes.medium
)
// Sort + artist filter toggle row
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box {
TextButton(onClick = { showSortMenu = true }) {
Text(
sortMode.label,
style = MaterialTheme.typography.labelMedium
)
Text(sortMode.label, style = MaterialTheme.typography.labelMedium)
}
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false }
) {
DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
SortMode.entries.forEach { mode ->
DropdownMenuItem(
text = {
Text(
mode.label,
fontWeight = if (mode == sortMode)
FontWeight.Bold else FontWeight.Normal
fontWeight = if (mode == sortMode) FontWeight.Bold else FontWeight.Normal
)
},
onClick = {
@ -208,20 +212,13 @@ fun RecordingListScreen(
}
}
// Artist filter chips (nested collapse)
AnimatedVisibility(
visible = showArtistFilter,
enter = expandVertically(),
exit = shrinkVertically()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { vm.showAllArtists() }) {
Text("All", style = MaterialTheme.typography.labelSmall)
}
@ -237,12 +234,7 @@ fun RecordingListScreen(
FilterChip(
selected = artist !in hiddenArtists,
onClick = { vm.toggleArtist(artist) },
label = {
Text(
artist,
style = MaterialTheme.typography.labelSmall
)
}
label = { Text(artist, style = MaterialTheme.typography.labelSmall) }
)
}
}
@ -253,7 +245,6 @@ fun RecordingListScreen(
}
}
// ── Results count ──────────────────────────────
Text(
text = "${recordings.size} recordings",
style = MaterialTheme.typography.labelSmall,
@ -261,26 +252,29 @@ fun RecordingListScreen(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
// ── Recording list ─────────────────────────────
LazyColumn(
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
bottom = 80.dp
),
verticalArrangement = Arrangement.spacedBy(6.dp)
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { vm.refresh() },
modifier = Modifier.fillMaxSize()
) {
items(
items = recordings,
key = { it.id }
) { recording ->
RecordingItem(
recording = recording,
onPlayClick = { /* TODO: wire up player */ },
onNotesClick = if (recording.hasMidi) {
{ /* TODO: open falling notes */ }
} else null
)
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.fadingScrollbar(listState),
contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 80.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
items(items = recordings, key = { it.id }) { recording ->
RecordingItem(
recording = recording,
onPlayClick = { vm.onPlay(recording) },
onFavoriteClick = { vm.toggleFavorite(recording) },
onNotesClick = if (recording.hasMidi) {
{ /* TODO: open falling notes */ }
} else null
)
}
}
}
}

View file

@ -32,6 +32,10 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
private val _syncState = MutableStateFlow<SyncState>(SyncState.Syncing)
val syncState: StateFlow<SyncState> = _syncState.asStateFlow()
/** True while a pull-to-refresh is in flight (keeps the list visible, spinner on top). */
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
// ── User-controlled filters ────────────────────────────────────────
private val _searchQuery = MutableStateFlow("")
@ -76,6 +80,9 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
all.filterByArtist(hidden)
.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 }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// ── Init: run sync on creation ─────────────────────────────────────
@ -121,6 +128,37 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
_hiddenArtists.value = availableArtists.value.toSet()
}
fun toggleFavorite(recording: Recording) {
viewModelScope.launch {
repo.setFavorite(recording.id, !recording.isFavorite)
}
}
/** Called when a recording's play button is pressed. Clears its "new" badge. */
fun onPlay(recording: Recording) {
if (recording.isNew) {
viewModelScope.launch { repo.clearNew(recording.id) }
}
// TODO: actual playback wiring comes with the player layer
}
/**
* Pull-to-refresh: re-fetch list.json and re-check revision, WITHOUT blanking
* the list to the full-screen syncing spinner. The list stays visible; only the
* small pull indicator shows. A failed refresh leaves the existing list intact.
*/
fun refresh() {
if (_isRefreshing.value) return // ignore overlapping pulls
_isRefreshing.value = true
viewModelScope.launch {
try {
syncManager.sync()
} finally {
_isRefreshing.value = false
}
}
}
fun retry() {
_syncState.value = SyncState.Syncing
viewModelScope.launch {
@ -166,16 +204,24 @@ private fun List<Recording>.filterByArtist(hidden: Set<String>): List<Recording>
}
private fun SortMode.comparator(): Comparator<Recording> = when (this) {
SortMode.DATE_DESC -> compareByDescending { it.date }
SortMode.DATE_ASC -> compareBy { it.date }
SortMode.DATE_DESC -> compareByDescending<Recording> { it.date }
.thenByDescending { it.performanceNumber }
SortMode.DATE_ASC -> compareBy<Recording> { it.date }
// Oldest-first is the only mode that sub-orders performances ascending.
.thenBy { it.performanceNumber }
SortMode.ARTIST_AZ -> compareBy<Recording, String>(String.CASE_INSENSITIVE_ORDER) { it.artist }
.thenByDescending { it.date }
.thenByDescending { it.performanceNumber }
SortMode.ARTIST_ZA -> compareByDescending<Recording, String>(String.CASE_INSENSITIVE_ORDER) { it.artist }
.thenByDescending { it.date }
.thenByDescending { it.performanceNumber }
SortMode.TITLE_AZ -> compareBy<Recording, String>(String.CASE_INSENSITIVE_ORDER) { it.title }
.thenByDescending { it.date }
SortMode.DURATION_DESC -> compareByDescending { it.durationSeconds }
SortMode.DURATION_ASC -> compareBy { it.durationSeconds }
.thenByDescending { it.performanceNumber }
SortMode.DURATION_DESC -> compareByDescending<Recording> { it.durationSeconds }
.thenByDescending { it.performanceNumber }
SortMode.DURATION_ASC -> compareBy<Recording> { it.durationSeconds }
.thenByDescending { it.performanceNumber }
}
/**

View file

@ -37,7 +37,7 @@ private val LightColorScheme = lightColorScheme(
fun PianoTimelineTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
icon_mono.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB