Polishing UX
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
|
|
@ -4,15 +4,17 @@ 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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,15 +33,20 @@ 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
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
|
|
@ -47,7 +58,6 @@ fun RecordingItem(
|
|||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Play button
|
||||
FilledTonalIconButton(
|
||||
onClick = onPlayClick,
|
||||
modifier = Modifier.size(44.dp)
|
||||
|
|
@ -61,17 +71,14 @@ fun RecordingItem(
|
|||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
// Text content
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Artist
|
||||
Text(
|
||||
text = recording.artist,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFFE6A900),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
// Title — marquee scrolls when it doesn't fit
|
||||
Text(
|
||||
text = recording.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
|
|
@ -79,12 +86,11 @@ fun RecordingItem(
|
|||
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
|
||||
repeatDelayMillis = 2000,
|
||||
initialDelayMillis = 3000,
|
||||
velocity = 30.dp
|
||||
)
|
||||
)
|
||||
// Metadata row
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
|
|
@ -92,45 +98,65 @@ fun RecordingItem(
|
|||
Text(
|
||||
text = recording.formattedDate,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFFA3D1FF)
|
||||
)
|
||||
Text(
|
||||
text = "·",
|
||||
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 = Color(0xFFA3FFBC)
|
||||
)
|
||||
Text(
|
||||
text = "·",
|
||||
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 = Color(0xFF999999)
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Notes button (only if MIDI exists)
|
||||
// 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(8.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
onClick = onNotesClick,
|
||||
modifier = Modifier.size(width = 60.dp, height = 32.dp),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
border = BorderStroke(1.5.dp, NotesGreen)
|
||||
) {
|
||||
Text(
|
||||
"Notes",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,22 +252,24 @@ fun RecordingListScreen(
|
|||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
)
|
||||
|
||||
// ── Recording list ─────────────────────────────
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = { vm.refresh() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
bottom = 80.dp
|
||||
),
|
||||
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 ->
|
||||
items(items = recordings, key = { it.id }) { recording ->
|
||||
RecordingItem(
|
||||
recording = recording,
|
||||
onPlayClick = { /* TODO: wire up player */ },
|
||||
onPlayClick = { vm.onPlay(recording) },
|
||||
onFavoriteClick = { vm.toggleFavorite(recording) },
|
||||
onNotesClick = if (recording.hasMidi) {
|
||||
{ /* TODO: open falling notes */ }
|
||||
} else null
|
||||
|
|
@ -288,3 +281,4 @@ fun RecordingListScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
BIN
app/src/main/res/drawable/logo.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.2 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp
Normal file
|
After Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 5 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 7 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 11 KiB |
4
app/src/main/res/values/ic_launcher_background.xml
Normal 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
|
After Width: | Height: | Size: 8.3 KiB |
BIN
icon_mono.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
logo.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |