Polishing UX
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
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 |