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> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCY20YA30P" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=RFCY20YA30P" />

View file

@ -92,4 +92,5 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.material.icons.core) 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.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.RecordingListScreen
import com.keylightpiano.pianotimeline.ui.theme.PianoTimelineTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
PianoTimelineTheme { // Force dark mode regardless of system setting
PianoTimelineTheme(darkTheme = true) {
RecordingListScreen() RecordingListScreen()
} }
} }
} }
} }

View file

@ -51,6 +51,16 @@ class MusicRepository(context: Context) {
dao.setCachedMidiPath(id, path) 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. * Clear all cached audio/midi files from disk and reset paths in the database.
* Used by the "clear cache" setting. * 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 prefs = context.dataStore.data.first()
val cachedRevision = prefs[KEY_REVISION] ?: -1 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") Log.d(TAG, "Revision $serverRevision unchanged — cache is current")
return@withContext SyncResult.UpToDate 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 // 3. Build lookup maps for diffing
val serverById = serverRecordings.associateBy { it.id } val serverById = serverRecordings.associateBy { it.id }
val cachedIds = dao.getAllIds().toSet() val cachedIds = dao.getAllIds().toSet()
val serverIds = serverById.keys 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) // 4a. New recordings (on server but not in cache)
val addedIds = serverIds - cachedIds val addedIds = serverIds - cachedIds
// 4b. Removed recordings (in cache but not on server) // 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. // 7. Upsert ALL server recordings into the database.
// For entries that already exist and haven't changed size, // For entries that already exist, we preserve their cached file
// we preserve their cached file paths. // paths (unless size changed) and ALWAYS preserve favorite/new flags.
val entitiesToUpsert = serverRecordings.map { recording -> 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) dao.getById(recording.id)
} else { } else {
null null
} }
val keepPaths = recording.id in commonIds && recording.id !in sizeChangedIds
recording.toEntity( recording.toEntity(
cachedAudioPath = existingEntity?.cachedAudioPath, cachedAudioPath = if (keepPaths) existingEntity?.cachedAudioPath else null,
cachedMidiPath = existingEntity?.cachedMidiPath cachedMidiPath = if (keepPaths) existingEntity?.cachedMidiPath else null,
isFavorite = existingEntity?.isFavorite ?: false,
isNew = existingEntity?.isNew ?: false
) )
} }
dao.upsert(entitiesToUpsert) 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 // 8. Store the new revision
context.dataStore.edit { it[KEY_REVISION] = serverRevision } context.dataStore.edit { it[KEY_REVISION] = serverRevision }

View file

@ -5,15 +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)
* 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)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun recordingDao(): RecordingDao abstract fun recordingDao(): RecordingDao
@ -29,7 +21,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"piano_timeline.db" "piano_timeline.db"
) )
.fallbackToDestructiveMigration() // fine during dev; revisit for production .fallbackToDestructiveMigration(dropAllTables = true)
.build() .build()
.also { INSTANCE = it } .also { INSTANCE = it }
} }

View file

@ -6,11 +6,6 @@ import java.time.format.DateTimeFormatter
private val DATE_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") 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? { fun RecordingEntity.toDomainOrNull(): Recording? {
val parsedDate = try { val parsedDate = try {
LocalDate.parse(date.trim(), DATE_FORMAT) LocalDate.parse(date.trim(), DATE_FORMAT)
@ -30,17 +25,21 @@ fun RecordingEntity.toDomainOrNull(): Recording? {
midiFileName = midiFileName, midiFileName = midiFileName,
durationSeconds = durationSeconds, durationSeconds = durationSeconds,
soundStartSeconds = soundStartSeconds, soundStartSeconds = soundStartSeconds,
fileSize = fileSize fileSize = fileSize,
isFavorite = isFavorite,
isNew = isNew
) )
} }
/** /**
* Domain Entity. Used when storing fresh server data into Room. * Domain -> Entity. cachedAudioPath/cachedMidiPath and the local flags
* cachedAudioPath/cachedMidiPath default to null (not yet downloaded). * default to their "fresh" values; callers preserve existing ones where needed.
*/ */
fun Recording.toEntity( fun Recording.toEntity(
cachedAudioPath: String? = null, cachedAudioPath: String? = null,
cachedMidiPath: String? = null cachedMidiPath: String? = null,
isFavorite: Boolean = false,
isNew: Boolean = false
): RecordingEntity = RecordingEntity( ): RecordingEntity = RecordingEntity(
id = id, id = id,
date = date.format(DATE_FORMAT), date = date.format(DATE_FORMAT),
@ -55,5 +54,7 @@ fun Recording.toEntity(
soundStartSeconds = soundStartSeconds, soundStartSeconds = soundStartSeconds,
fileSize = fileSize, fileSize = fileSize,
cachedAudioPath = cachedAudioPath, 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 androidx.room.Query
import kotlinx.coroutines.flow.Flow 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 @Dao
interface RecordingDao { 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 " + @Query("SELECT * FROM recordings ORDER BY " +
"substr(date, 7, 4) DESC, " + // year "substr(date, 7, 4) DESC, " +
"substr(date, 4, 2) DESC, " + // month "substr(date, 4, 2) DESC, " +
"substr(date, 1, 2) DESC") // day "substr(date, 1, 2) DESC")
fun observeAllByDate(): Flow<List<RecordingEntity>> 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") @Query("SELECT id FROM recordings")
suspend fun getAllIds(): List<String> suspend fun getAllIds(): List<String>
/** IDs paired with their file sizes. Used to detect changed files. */
@Query("SELECT id, fileSize FROM recordings") @Query("SELECT id, fileSize FROM recordings")
suspend fun getIdAndSizes(): List<IdAndSize> suspend fun getIdAndSizes(): List<IdAndSize>
/** Get a single recording by ID. */
@Query("SELECT * FROM recordings WHERE id = :id") @Query("SELECT * FROM recordings WHERE id = :id")
suspend fun getById(id: String): RecordingEntity? 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) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(recordings: List<RecordingEntity>) suspend fun upsert(recordings: List<RecordingEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(recording: RecordingEntity) suspend fun upsert(recording: RecordingEntity)
/** Delete specific recordings by ID. Used when the server removed entries. */
@Query("DELETE FROM recordings WHERE id IN (:ids)") @Query("DELETE FROM recordings WHERE id IN (:ids)")
suspend fun deleteByIds(ids: Set<String>) 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") @Query("UPDATE recordings SET cachedAudioPath = NULL, cachedMidiPath = NULL WHERE id = :id")
suspend fun clearCachedPaths(id: String) suspend fun clearCachedPaths(id: String)
/** Set the cached audio path after a successful download. */
@Query("UPDATE recordings SET cachedAudioPath = :path WHERE id = :id") @Query("UPDATE recordings SET cachedAudioPath = :path WHERE id = :id")
suspend fun setCachedAudioPath(id: String, path: String) 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") @Query("UPDATE recordings SET cachedMidiPath = :path WHERE id = :id")
suspend fun setCachedMidiPath(id: String, path: String) 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") @Query("UPDATE recordings SET cachedAudioPath = NULL, cachedMidiPath = NULL")
suspend fun clearAllCachedPaths() suspend fun clearAllCachedPaths()
/** Total number of recordings. Handy for the UI or debugging. */
@Query("SELECT COUNT(*) FROM recordings") @Query("SELECT COUNT(*) FROM recordings")
suspend fun count(): Int 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) 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.Entity
import androidx.room.PrimaryKey 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") @Entity(tableName = "recordings")
data class RecordingEntity( data class RecordingEntity(
@PrimaryKey val id: String, // MD5 of filename — stable key @PrimaryKey val id: String,
val date: String, // stored as "dd.MM.yyyy", parsed on read val date: String,
val artist: String, val artist: String,
val title: String, val title: String,
val extraInfo: String, val extraInfo: String,
val performanceNumber: Int, val performanceNumber: Int,
val totalPerformances: Int, val totalPerformances: Int,
val fileName: String, // raw filename (not URL-encoded) val fileName: String,
val midiFileName: String?, // null = no MIDI file exists for this piece val midiFileName: String?,
val durationSeconds: Double, val durationSeconds: Double,
val soundStartSeconds: Double, val soundStartSeconds: Double,
val fileSize: Long, // from list.json — used for change detection val fileSize: Long,
val cachedAudioPath: String? = null, // null = not downloaded yet val cachedAudioPath: String? = null,
val cachedMidiPath: String? = null // null = not downloaded yet 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 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( data class Recording(
val id: String, // MD5 of the filename — stable primary key val id: String,
val date: LocalDate, // parsed from "dd.MM.yyyy" val date: LocalDate,
val artist: String, // the REAL artist, shown on the row as-is val artist: String,
val title: String, val title: String,
val extraInfo: String, val extraInfo: String,
val performanceNumber: Int, val performanceNumber: Int,
val totalPerformances: Int, val totalPerformances: Int,
val fileName: String, // raw, NOT url-encoded (encode at fetch time) val fileName: String,
val midiFileName: String?, // null when the JSON had "" val midiFileName: String?,
val durationSeconds: Double, val durationSeconds: Double,
val soundStartSeconds: 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 val hasMidi: Boolean get() = midiFileName != null
/** "22 Jun 2026" style for display. */ /** "22 Jun 2026" style for display. */
val formattedDate: String val formattedDate: String
get() = date.format(DateTimeFormatter.ofPattern("d MMM yyyy", Locale.ENGLISH)) 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 val groupingArtist: String
get() = if (artist.contains("soundtrack", ignoreCase = true)) "Soundtracks" else artist get() = if (artist.contains("soundtrack", ignoreCase = true)) "Soundtracks" else artist
/** "4:16" style. durationSeconds is wall-clock length of the file. */
val formattedDuration: String val formattedDuration: String
get() { get() {
val total = durationSeconds.toInt() val total = durationSeconds.toInt()
@ -46,7 +41,6 @@ data class Recording(
return "%d:%02d".format(minutes, seconds) return "%d:%02d".format(minutes, seconds)
} }
/** "2 / 2" — this performance number out of the total for the piece. */
val performanceLabel: String val performanceLabel: String
get() = "$performanceNumber / $totalPerformances" 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.basicMarquee
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
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.PaddingValues
import androidx.compose.foundation.layout.Row 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.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.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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.foundation.BorderStroke
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -27,109 +33,129 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.keylightpiano.pianotimeline.domain.Recording import com.keylightpiano.pianotimeline.domain.Recording
private val NotesGreen = Color(0xFF25B300)
private val NewBadgeRed = Color(0xFFE53935)
@Composable @Composable
fun RecordingItem( fun RecordingItem(
recording: Recording, recording: Recording,
onPlayClick: () -> Unit, onPlayClick: () -> Unit,
onFavoriteClick: () -> Unit,
onNotesClick: (() -> Unit)?, onNotesClick: (() -> Unit)?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Card( Box(modifier = modifier.fillMaxWidth()) {
modifier = modifier.fillMaxWidth(), Card(
colors = CardDefaults.cardColors( modifier = Modifier.fillMaxWidth(),
containerColor = MaterialTheme.colorScheme.surface colors = CardDefaults.cardColors(
), containerColor = MaterialTheme.colorScheme.surface
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ),
) { elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
// Play button Row(
FilledTonalIconButton( modifier = Modifier
onClick = onPlayClick, .fillMaxWidth()
modifier = Modifier.size(44.dp) .padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( FilledTonalIconButton(
painter = painterResource(android.R.drawable.ic_media_play), onClick = onPlayClick,
contentDescription = "Play", modifier = Modifier.size(44.dp)
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
) { ) {
Text( Icon(
text = recording.formattedDate, painter = painterResource(android.R.drawable.ic_media_play),
style = MaterialTheme.typography.bodySmall, contentDescription = "Play",
color = Color(0xFFA3D1FF) modifier = Modifier.size(22.dp)
)
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)
) )
} }
}
// Notes button (only if MIDI exists) Spacer(Modifier.width(12.dp))
if (onNotesClick != null) {
Spacer(Modifier.width(8.dp)) Column(modifier = Modifier.weight(1f)) {
OutlinedButton(
onClick = onNotesClick,
modifier = Modifier.size(width = 60.dp, height = 32.dp),
contentPadding = PaddingValues(0.dp)
) {
Text( Text(
"Notes", text = recording.artist,
style = MaterialTheme.typography.labelSmall 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.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
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
@ -15,8 +16,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search 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.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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
@ -45,10 +50,12 @@ 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.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
@ -58,6 +65,7 @@ fun RecordingListScreen(
) { ) {
val syncState by vm.syncState.collectAsStateWithLifecycle() val syncState by vm.syncState.collectAsStateWithLifecycle()
val recordings by vm.recordings.collectAsStateWithLifecycle() val recordings by vm.recordings.collectAsStateWithLifecycle()
val isRefreshing by vm.isRefreshing.collectAsStateWithLifecycle()
val searchQuery by vm.searchQuery.collectAsStateWithLifecycle() val searchQuery by vm.searchQuery.collectAsStateWithLifecycle()
val sortMode by vm.sortMode.collectAsStateWithLifecycle() val sortMode by vm.sortMode.collectAsStateWithLifecycle()
val hiddenArtists by vm.hiddenArtists.collectAsStateWithLifecycle() val hiddenArtists by vm.hiddenArtists.collectAsStateWithLifecycle()
@ -67,9 +75,25 @@ fun RecordingListScreen(
var showSortMenu by remember { mutableStateOf(false) } var showSortMenu by remember { mutableStateOf(false) }
var showArtistFilter by rememberSaveable { 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() val hasActiveFilter = searchQuery.isNotEmpty() || hiddenArtists.isNotEmpty()
Scaffold( Scaffold(
@ -77,10 +101,16 @@ fun RecordingListScreen(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Text( Row(verticalAlignment = Alignment.CenterVertically) {
"Piano Timeline", Image(
fontWeight = FontWeight.Bold 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 = { actions = {
IconButton(onClick = { showControls = !showControls }) { IconButton(onClick = { showControls = !showControls }) {
@ -98,23 +128,17 @@ fun RecordingListScreen(
) { innerPadding -> ) { innerPadding ->
when (val s = syncState) { when (val s = syncState) {
is SyncState.Syncing -> { is SyncState.Syncing -> {
Box( Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) {
Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator() CircularProgressIndicator()
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text("Syncing", style = MaterialTheme.typography.bodyMedium) Text("Syncing...", style = MaterialTheme.typography.bodyMedium)
} }
} }
} }
is SyncState.Error -> { is SyncState.Error -> {
Box( Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) {
Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center
) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
@ -128,29 +152,19 @@ fun RecordingListScreen(
} }
is SyncState.Ready -> { is SyncState.Ready -> {
Column( Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
// ── Collapsible search + filter + sort block ───
AnimatedVisibility( AnimatedVisibility(
visible = showControls, visible = showControls,
enter = expandVertically(), enter = expandVertically(),
exit = shrinkVertically() exit = shrinkVertically()
) { ) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
// Search bar
OutlinedTextField( OutlinedTextField(
value = searchQuery, value = searchQuery,
onValueChange = { vm.setSearchQuery(it) }, onValueChange = { vm.setSearchQuery(it) },
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
.fillMaxWidth() placeholder = { Text("Search artist, title...") },
.padding(horizontal = 16.dp, vertical = 4.dp), leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
placeholder = { Text("Search artist, title…") },
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null)
},
trailingIcon = { trailingIcon = {
if (searchQuery.isNotEmpty()) { if (searchQuery.isNotEmpty()) {
IconButton(onClick = { vm.setSearchQuery("") }) { IconButton(onClick = { vm.setSearchQuery("") }) {
@ -162,32 +176,22 @@ fun RecordingListScreen(
shape = MaterialTheme.shapes.medium shape = MaterialTheme.shapes.medium
) )
// Sort + artist filter toggle row
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box { Box {
TextButton(onClick = { showSortMenu = true }) { TextButton(onClick = { showSortMenu = true }) {
Text( Text(sortMode.label, style = MaterialTheme.typography.labelMedium)
sortMode.label,
style = MaterialTheme.typography.labelMedium
)
} }
DropdownMenu( DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false }
) {
SortMode.entries.forEach { mode -> SortMode.entries.forEach { mode ->
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text( Text(
mode.label, mode.label,
fontWeight = if (mode == sortMode) fontWeight = if (mode == sortMode) FontWeight.Bold else FontWeight.Normal
FontWeight.Bold else FontWeight.Normal
) )
}, },
onClick = { onClick = {
@ -208,20 +212,13 @@ fun RecordingListScreen(
} }
} }
// Artist filter chips (nested collapse)
AnimatedVisibility( AnimatedVisibility(
visible = showArtistFilter, visible = showArtistFilter,
enter = expandVertically(), enter = expandVertically(),
exit = shrinkVertically() exit = shrinkVertically()
) { ) {
Column( Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
modifier = Modifier Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextButton(onClick = { vm.showAllArtists() }) { TextButton(onClick = { vm.showAllArtists() }) {
Text("All", style = MaterialTheme.typography.labelSmall) Text("All", style = MaterialTheme.typography.labelSmall)
} }
@ -237,12 +234,7 @@ fun RecordingListScreen(
FilterChip( FilterChip(
selected = artist !in hiddenArtists, selected = artist !in hiddenArtists,
onClick = { vm.toggleArtist(artist) }, onClick = { vm.toggleArtist(artist) },
label = { label = { Text(artist, style = MaterialTheme.typography.labelSmall) }
Text(
artist,
style = MaterialTheme.typography.labelSmall
)
}
) )
} }
} }
@ -253,7 +245,6 @@ fun RecordingListScreen(
} }
} }
// ── Results count ──────────────────────────────
Text( Text(
text = "${recordings.size} recordings", text = "${recordings.size} recordings",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@ -261,26 +252,29 @@ fun RecordingListScreen(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
) )
// ── Recording list ───────────────────────────── PullToRefreshBox(
LazyColumn( isRefreshing = isRefreshing,
contentPadding = PaddingValues( onRefresh = { vm.refresh() },
start = 12.dp, modifier = Modifier.fillMaxSize()
end = 12.dp,
bottom = 80.dp
),
verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
items( LazyColumn(
items = recordings, state = listState,
key = { it.id } modifier = Modifier
) { recording -> .fillMaxSize()
RecordingItem( .fadingScrollbar(listState),
recording = recording, contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 80.dp),
onPlayClick = { /* TODO: wire up player */ }, verticalArrangement = Arrangement.spacedBy(6.dp)
onNotesClick = if (recording.hasMidi) { ) {
{ /* TODO: open falling notes */ } items(items = recordings, key = { it.id }) { recording ->
} else null 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) private val _syncState = MutableStateFlow<SyncState>(SyncState.Syncing)
val syncState: StateFlow<SyncState> = _syncState.asStateFlow() 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 ──────────────────────────────────────── // ── User-controlled filters ────────────────────────────────────────
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow("")
@ -76,6 +80,9 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
all.filterByArtist(hidden) all.filterByArtist(hidden)
.filterBySearch(query) .filterBySearch(query)
.sortedWith(sort.comparator()) .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()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// ── Init: run sync on creation ───────────────────────────────────── // ── Init: run sync on creation ─────────────────────────────────────
@ -121,6 +128,37 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
_hiddenArtists.value = availableArtists.value.toSet() _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() { fun retry() {
_syncState.value = SyncState.Syncing _syncState.value = SyncState.Syncing
viewModelScope.launch { viewModelScope.launch {
@ -166,16 +204,24 @@ private fun List<Recording>.filterByArtist(hidden: Set<String>): List<Recording>
} }
private fun SortMode.comparator(): Comparator<Recording> = when (this) { private fun SortMode.comparator(): Comparator<Recording> = when (this) {
SortMode.DATE_DESC -> compareByDescending { it.date } SortMode.DATE_DESC -> compareByDescending<Recording> { it.date }
SortMode.DATE_ASC -> compareBy { 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 } SortMode.ARTIST_AZ -> compareBy<Recording, String>(String.CASE_INSENSITIVE_ORDER) { it.artist }
.thenByDescending { it.date } .thenByDescending { it.date }
.thenByDescending { it.performanceNumber }
SortMode.ARTIST_ZA -> compareByDescending<Recording, String>(String.CASE_INSENSITIVE_ORDER) { it.artist } SortMode.ARTIST_ZA -> compareByDescending<Recording, String>(String.CASE_INSENSITIVE_ORDER) { it.artist }
.thenByDescending { it.date } .thenByDescending { it.date }
.thenByDescending { it.performanceNumber }
SortMode.TITLE_AZ -> compareBy<Recording, String>(String.CASE_INSENSITIVE_ORDER) { it.title } SortMode.TITLE_AZ -> compareBy<Recording, String>(String.CASE_INSENSITIVE_ORDER) { it.title }
.thenByDescending { it.date } .thenByDescending { it.date }
SortMode.DURATION_DESC -> compareByDescending { it.durationSeconds } .thenByDescending { it.performanceNumber }
SortMode.DURATION_ASC -> compareBy { it.durationSeconds } 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( fun PianoTimelineTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+ // Dynamic color is available on Android 12+
dynamicColor: Boolean = true, dynamicColor: Boolean = false,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = when { 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