Started work on GUI

This commit is contained in:
OmniaX-Dev 2026-06-23 10:07:00 +02:00
parent f2429cd6ef
commit a1ff435e81
12 changed files with 644 additions and 151 deletions

View file

@ -4,10 +4,10 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-06-23T06:48:40.641060889Z"> <DropdownSelection timestamp="2026-06-23T07:28:08.761449449Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/sylar/.android/avd/Medium_Phone.avd" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=RFCY20YA30P" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

View file

@ -4,6 +4,7 @@
<option name="approvalStates"> <option name="approvalStates">
<map> <map>
<entry key="20260623-075402-8a5368e9-a150-4048-aaa6-a07699e3c266" value="true" /> <entry key="20260623-075402-8a5368e9-a150-4048-aaa6-a07699e3c266" value="true" />
<entry key="20260623-091803-ac498ab1-c280-469b-a10f-4e7d5a828aea" value="true" />
</map> </map>
</option> </option>
</component> </component>

View file

@ -90,4 +90,6 @@ dependencies {
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.material.icons.core)
} }

View file

@ -4,14 +4,8 @@ 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 androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.keylightpiano.pianotimeline.ui.theme.PianoTimelineTheme import com.keylightpiano.pianotimeline.ui.theme.PianoTimelineTheme
import com.keylightpiano.pianotimeline.ui.RecordingListScreen
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -19,28 +13,8 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
PianoTimelineTheme { PianoTimelineTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> RecordingListScreen()
TestScreen(
modifier = Modifier.padding(innerPadding)
)
} }
} }
} }
} }
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
PianoTimelineTheme {
Greeting("Android")
}
}

View file

@ -1,71 +0,0 @@
package com.keylightpiano.pianotimeline
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun TestScreen(modifier: Modifier = Modifier) {
val vm: TestViewModel = viewModel()
// collectAsStateWithLifecycle bridges the StateFlow into Compose:
// the UI recomposes automatically whenever the state changes.
val state by vm.state.collectAsStateWithLifecycle()
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (val s = state) {
is FetchState.Loading -> CircularProgressIndicator()
is FetchState.Error -> Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(24.dp)
) {
Text("Fetch failed", style = MaterialTheme.typography.titleMedium)
Text(s.message, style = MaterialTheme.typography.bodySmall)
Button(onClick = { vm.load() }) { Text("Retry") }
}
is FetchState.Success -> Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text(
"revision ${s.revision}${s.recordings.size} recordings",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
items(s.recordings) { r ->
Column {
Text(
"${r.artist}${r.title}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
Text(
"${r.date} · ${r.formattedDuration} · ${r.performanceLabel}" +
if (r.hasMidi) " · MIDI" else "",
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
}
}
}

View file

@ -1,50 +0,0 @@
package com.keylightpiano.pianotimeline
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.keylightpiano.pianotimeline.data.MusicRepository
import com.keylightpiano.pianotimeline.data.SyncManager
import com.keylightpiano.pianotimeline.domain.Recording
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class TestViewModel(app: Application) : AndroidViewModel(app) {
private val repo = MusicRepository(app)
private val syncManager = SyncManager(app)
private val _state = MutableStateFlow<FetchState>(FetchState.Loading)
val state: StateFlow<FetchState> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.value = FetchState.Loading
viewModelScope.launch {
// Sync first (updates Room), then observe from Room
val result = syncManager.sync()
try {
// One-shot collect to get current list for the test screen
repo.observeRecordings().collect { recordings ->
_state.value = FetchState.Success(
revision = syncManager.getCachedRevision(),
recordings = recordings
)
}
} catch (e: Exception) {
_state.value = FetchState.Error(e.message ?: e.toString())
}
}
}
}
sealed interface FetchState {
data object Loading : FetchState
data class Success(val revision: Int, val recordings: List<Recording>) : FetchState
data class Error(val message: String) : FetchState
}

View file

@ -1,6 +1,8 @@
package com.keylightpiano.pianotimeline.domain package com.keylightpiano.pianotimeline.domain
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter
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.
@ -23,6 +25,10 @@ data class Recording(
/** True when there's a MIDI file → the "Notes" button should appear. */ /** 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. */
val formattedDate: String
get() = date.format(DateTimeFormatter.ofPattern("d MMM yyyy", Locale.ENGLISH))
/** /**
* Artist used for grouping / filtering only. * Artist used for grouping / filtering only.
* Anything containing "soundtrack" (case-insensitive) collapses to "Soundtracks". * Anything containing "soundtrack" (case-insensitive) collapses to "Soundtracks".

View file

@ -0,0 +1,136 @@
package com.keylightpiano.pianotimeline.ui
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.keylightpiano.pianotimeline.domain.Recording
@Composable
fun RecordingItem(
recording: Recording,
onPlayClick: () -> Unit,
onNotesClick: (() -> Unit)?,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Play button
FilledTonalIconButton(
onClick = onPlayClick,
modifier = Modifier.size(44.dp)
) {
Icon(
painter = painterResource(android.R.drawable.ic_media_play),
contentDescription = "Play",
modifier = Modifier.size(22.dp)
)
}
Spacer(Modifier.width(12.dp))
// Text content
Column(modifier = Modifier.weight(1f)) {
// Artist
Text(
text = recording.artist,
style = MaterialTheme.typography.labelMedium,
color = Color(0xFFE6A900),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Title — marquee scrolls when it doesn't fit
Text(
text = recording.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
modifier = Modifier.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = 2000, // pause before starting to scroll
initialDelayMillis = 3000, // pause on first appearance
velocity = 30.dp // scroll speed
)
)
// Metadata row
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = recording.formattedDate,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFA3D1FF)
)
Text(
text = "·",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = recording.formattedDuration,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFA3FFBC)
)
Text(
text = "·",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = recording.performanceLabel,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF999999)
)
}
}
// Notes button (only if MIDI exists)
if (onNotesClick != null) {
Spacer(Modifier.width(8.dp))
OutlinedButton(
onClick = onNotesClick,
modifier = Modifier.size(width = 60.dp, height = 32.dp),
contentPadding = PaddingValues(0.dp)
) {
Text(
"Notes",
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
}

View file

@ -0,0 +1,290 @@
package com.keylightpiano.pianotimeline.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun RecordingListScreen(
modifier: Modifier = Modifier,
vm: RecordingListViewModel = viewModel()
) {
val syncState by vm.syncState.collectAsStateWithLifecycle()
val recordings by vm.recordings.collectAsStateWithLifecycle()
val searchQuery by vm.searchQuery.collectAsStateWithLifecycle()
val sortMode by vm.sortMode.collectAsStateWithLifecycle()
val hiddenArtists by vm.hiddenArtists.collectAsStateWithLifecycle()
val availableArtists by vm.availableArtists.collectAsStateWithLifecycle()
var showControls by rememberSaveable { mutableStateOf(false) }
var showSortMenu by remember { mutableStateOf(false) }
var showArtistFilter by rememberSaveable { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
// Whether any filter is active — used to tint the search icon
val hasActiveFilter = searchQuery.isNotEmpty() || hiddenArtists.isNotEmpty()
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = {
Text(
"Piano Timeline",
fontWeight = FontWeight.Bold
)
},
actions = {
IconButton(onClick = { showControls = !showControls }) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search & Filter",
tint = if (hasActiveFilter) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
scrollBehavior = scrollBehavior
)
}
) { innerPadding ->
when (val s = syncState) {
is SyncState.Syncing -> {
Box(
Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text("Syncing…", style = MaterialTheme.typography.bodyMedium)
}
}
}
is SyncState.Error -> {
Box(
Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(24.dp)
) {
Text("Couldn't load recordings", style = MaterialTheme.typography.titleMedium)
Text(s.message, style = MaterialTheme.typography.bodySmall)
Button(onClick = { vm.retry() }) { Text("Retry") }
}
}
}
is SyncState.Ready -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
// ── Collapsible search + filter + sort block ───
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)
},
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { vm.setSearchQuery("") }) {
Icon(Icons.Default.Clear, contentDescription = "Clear")
}
}
},
singleLine = true,
shape = MaterialTheme.shapes.medium
)
// Sort + artist filter toggle row
Row(
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
)
}
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false }
) {
SortMode.entries.forEach { mode ->
DropdownMenuItem(
text = {
Text(
mode.label,
fontWeight = if (mode == sortMode)
FontWeight.Bold else FontWeight.Normal
)
},
onClick = {
vm.setSortMode(mode)
showSortMenu = false
}
)
}
}
}
TextButton(onClick = { showArtistFilter = !showArtistFilter }) {
Text(
if (hiddenArtists.isEmpty()) "Artists"
else "Artists (${hiddenArtists.size} hidden)",
style = MaterialTheme.typography.labelMedium
)
}
}
// 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)
) {
TextButton(onClick = { vm.showAllArtists() }) {
Text("All", style = MaterialTheme.typography.labelSmall)
}
TextButton(onClick = { vm.hideAllArtists() }) {
Text("None", style = MaterialTheme.typography.labelSmall)
}
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
availableArtists.forEach { artist ->
FilterChip(
selected = artist !in hiddenArtists,
onClick = { vm.toggleArtist(artist) },
label = {
Text(
artist,
style = MaterialTheme.typography.labelSmall
)
}
)
}
}
}
}
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
}
}
// ── Results count ──────────────────────────────
Text(
text = "${recordings.size} recordings",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
// ── Recording list ─────────────────────────────
LazyColumn(
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
bottom = 80.dp
),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
items(
items = recordings,
key = { it.id }
) { recording ->
RecordingItem(
recording = recording,
onPlayClick = { /* TODO: wire up player */ },
onNotesClick = if (recording.hasMidi) {
{ /* TODO: open falling notes */ }
} else null
)
}
}
}
}
}
}
}

View file

@ -0,0 +1,189 @@
package com.keylightpiano.pianotimeline.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.keylightpiano.pianotimeline.data.MusicRepository
import com.keylightpiano.pianotimeline.data.SyncManager
import com.keylightpiano.pianotimeline.domain.Recording
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/**
* Drives the main recording list screen.
*
* Architecture: three independent state streams (search query, sort mode, artist filters)
* are combined with the Room-backed recording list into one final filtered+sorted list.
* Compose observes the result and recomposes when anything changes.
*/
class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
private val repo = MusicRepository(app)
private val syncManager = SyncManager(app)
// ── Sync state ─────────────────────────────────────────────────────
private val _syncState = MutableStateFlow<SyncState>(SyncState.Syncing)
val syncState: StateFlow<SyncState> = _syncState.asStateFlow()
// ── User-controlled filters ────────────────────────────────────────
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _sortMode = MutableStateFlow(SortMode.DATE_DESC)
val sortMode: StateFlow<SortMode> = _sortMode.asStateFlow()
/**
* Set of HIDDEN grouping-artist names. Empty = everything visible.
* We track hidden rather than visible so the default (empty set = show all)
* works without knowing the full artist list up front.
*/
private val _hiddenArtists = MutableStateFlow<Set<String>>(emptySet())
val hiddenArtists: StateFlow<Set<String>> = _hiddenArtists.asStateFlow()
// ── Derived: all unique grouping artists, for the filter UI ────────
/** All recordings straight from Room (unfiltered). */
private val allRecordings = repo.observeRecordings()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
/**
* Sorted list of unique grouping artists derived from the full recording list.
* Updates automatically if recordings change.
*/
val availableArtists: StateFlow<List<String>> = allRecordings
.mapToState(viewModelScope) { recordings ->
recordings.map { it.groupingArtist }
.distinct()
.sorted()
}
// ── The final filtered + sorted list the UI renders ────────────────
val recordings: StateFlow<List<Recording>> = combine(
allRecordings,
_searchQuery,
_sortMode,
_hiddenArtists
) { all, query, sort, hidden ->
all.filterByArtist(hidden)
.filterBySearch(query)
.sortedWith(sort.comparator())
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// ── Init: run sync on creation ─────────────────────────────────────
init {
viewModelScope.launch {
_syncState.value = SyncState.Syncing
val result = syncManager.sync()
_syncState.value = when (result) {
is SyncManager.SyncResult.UpToDate -> SyncState.Ready
is SyncManager.SyncResult.Updated -> SyncState.Ready
is SyncManager.SyncResult.Failed -> {
// If we have cached data, still show it despite sync failure
if (syncManager.hasCachedData()) SyncState.Ready
else SyncState.Error(result.message)
}
}
}
}
// ── Public actions the UI calls ────────────────────────────────────
fun setSearchQuery(query: String) {
_searchQuery.value = query
}
fun setSortMode(mode: SortMode) {
_sortMode.value = mode
}
fun toggleArtist(groupingArtist: String) {
_hiddenArtists.value = _hiddenArtists.value.let { current ->
if (groupingArtist in current) current - groupingArtist
else current + groupingArtist
}
}
fun showAllArtists() {
_hiddenArtists.value = emptySet()
}
fun hideAllArtists() {
_hiddenArtists.value = availableArtists.value.toSet()
}
fun retry() {
_syncState.value = SyncState.Syncing
viewModelScope.launch {
val result = syncManager.sync()
_syncState.value = when (result) {
is SyncManager.SyncResult.UpToDate -> SyncState.Ready
is SyncManager.SyncResult.Updated -> SyncState.Ready
is SyncManager.SyncResult.Failed -> SyncState.Error(result.message)
}
}
}
}
// ── State types ────────────────────────────────────────────────────────
sealed interface SyncState {
data object Syncing : SyncState
data object Ready : SyncState
data class Error(val message: String) : SyncState
}
// ── Private helpers ────────────────────────────────────────────────────
/**
* Case-insensitive, token-based search.
* Splits the query into words; a recording matches if EVERY word appears
* somewhere in its artist, title, or extra-info. This gives a fuzzy feel
* ("chopin nocturne" matches "Chopin - Nocturne Op. 9...") without needing
* a real fuzzy library.
*/
private fun List<Recording>.filterBySearch(query: String): List<Recording> {
if (query.isBlank()) return this
val tokens = query.lowercase().split(" ").filter { it.isNotBlank() }
return filter { recording ->
val haystack = "${recording.artist} ${recording.title} ${recording.extraInfo}".lowercase()
tokens.all { token -> haystack.contains(token) }
}
}
private fun List<Recording>.filterByArtist(hidden: Set<String>): List<Recording> {
if (hidden.isEmpty()) return this
return filter { it.groupingArtist !in hidden }
}
private fun SortMode.comparator(): Comparator<Recording> = when (this) {
SortMode.DATE_DESC -> compareByDescending { it.date }
SortMode.DATE_ASC -> compareBy { it.date }
SortMode.ARTIST_AZ -> compareBy<Recording, String>(String.CASE_INSENSITIVE_ORDER) { it.artist }
.thenByDescending { it.date }
SortMode.ARTIST_ZA -> compareByDescending<Recording, String>(String.CASE_INSENSITIVE_ORDER) { it.artist }
.thenByDescending { it.date }
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 }
}
/**
* Helper to map a StateFlow through a transform into a new StateFlow.
* (Kotlin doesn't have this built-in; combine() only works with multiple flows.)
*/
private fun <T, R> StateFlow<T>.mapToState(
scope: kotlinx.coroutines.CoroutineScope,
transform: (T) -> R
): StateFlow<R> = this.map { transform(it) }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), transform(value))

View file

@ -0,0 +1,15 @@
package com.keylightpiano.pianotimeline.ui
/**
* Available sort modes for the recording list.
* Each knows how to produce a Comparator for Recording.
*/
enum class SortMode(val label: String) {
DATE_DESC("Newest first"),
DATE_ASC("Oldest first"),
ARTIST_AZ("Artist A → Z"),
ARTIST_ZA("Artist Z → A"),
TITLE_AZ("Title A → Z"),
DURATION_DESC("Longest first"),
DURATION_ASC("Shortest first");
}

View file

@ -23,6 +23,7 @@ navigationCompose = "2.8.5"
coil = "2.7.0" coil = "2.7.0"
[libraries] [libraries]
androidx-compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }