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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
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".
|
||||||
|
|
|
||||||
|
|
@ -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"
|
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" }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue