Started work on GUI
This commit is contained in:
parent
f2429cd6ef
commit
a1ff435e81
12 changed files with 644 additions and 151 deletions
|
|
@ -4,10 +4,10 @@
|
|||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<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">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=/home/sylar/.android/avd/Medium_Phone.avd" />
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCY20YA30P" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<option name="approvalStates">
|
||||
<map>
|
||||
<entry key="20260623-075402-8a5368e9-a150-4048-aaa6-a07699e3c266" value="true" />
|
||||
<entry key="20260623-091803-ac498ab1-c280-469b-a10f-4e7d5a828aea" value="true" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
|
|
|
|||
|
|
@ -90,4 +90,6 @@ dependencies {
|
|||
androidTestImplementation(libs.androidx.junit)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
implementation(libs.androidx.compose.material.icons.core)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,8 @@ import android.os.Bundle
|
|||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
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.RecordingListScreen
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
|
@ -19,28 +13,8 @@ class MainActivity : ComponentActivity() {
|
|||
enableEdgeToEdge()
|
||||
setContent {
|
||||
PianoTimelineTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
TestScreen(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
RecordingListScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = "Hello $name!",
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun GreetingPreview() {
|
||||
PianoTimelineTheme {
|
||||
Greeting("Android")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package com.keylightpiano.pianotimeline.domain
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
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".
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ navigationCompose = "2.8.5"
|
|||
coil = "2.7.0"
|
||||
|
||||
[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" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue