Test fetch: live list.json renders in throwaway UI

This commit is contained in:
OmniaX-Dev 2026-06-23 08:39:48 +02:00
parent 25ebacbe31
commit 4d52f85900
10 changed files with 354 additions and 3 deletions

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"

View file

@ -20,8 +20,7 @@ class MainActivity : ComponentActivity() {
setContent {
PianoTimelineTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
TestScreen(
modifier = Modifier.padding(innerPadding)
)
}

View file

@ -0,0 +1,71 @@
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

@ -0,0 +1,50 @@
package com.keylightpiano.pianotimeline
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.keylightpiano.pianotimeline.data.MusicRepository
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
/**
* Throwaway test ViewModel: fetch the list on creation, expose the result as state.
* This is the real shape we'll keep (StateFlow + sealed state), just bare-bones.
*/
class TestViewModel : ViewModel() {
private val repo = MusicRepository()
private val _state = MutableStateFlow<FetchState>(FetchState.Loading)
val state: StateFlow<FetchState> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.value = FetchState.Loading
// viewModelScope is a coroutine scope tied to this ViewModel's lifetime.
// Launching here is what lets us CALL the suspend repository function.
viewModelScope.launch {
try {
val result = repo.fetchRecordingList()
_state.value = FetchState.Success(
revision = result.revision,
recordings = result.recordings
)
} catch (e: Exception) {
_state.value = FetchState.Error(e.message ?: e.toString())
}
}
}
}
/** Three possible states of the screen, modeled explicitly. */
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

@ -0,0 +1,37 @@
package com.keylightpiano.pianotimeline.data
import com.keylightpiano.pianotimeline.data.remote.MusicNetwork
import com.keylightpiano.pianotimeline.domain.Recording
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Result of a list fetch: the revision number plus the parsed recordings.
* We keep the revision because the startup sync logic compares it against
* what we last cached.
*/
data class RecordingList(
val revision: Int,
val recordings: List<Recording>
)
/**
* The repository is the single entry point the rest of the app uses to get data.
* It hides *where* data comes from (network now, cache later) behind clean functions.
*/
class MusicRepository {
/**
* Fetches list.json, parses it, and maps every entry to a clean Recording.
* Broken entries (e.g. an unparseable date) are silently dropped rather than
* crashing the whole list.
*
* Runs on the IO dispatcher the thread pool meant for network/disk work
* so the caller never blocks the UI thread.
*/
suspend fun fetchRecordingList(): RecordingList = withContext(Dispatchers.IO) {
val dto = MusicNetwork.api.getRecordingList()
val recordings = dto.data.mapNotNull { it.toDomainOrNull() }
RecordingList(revision = dto.revision, recordings = recordings)
}
}

View file

@ -0,0 +1,36 @@
package com.keylightpiano.pianotimeline.data
import com.keylightpiano.pianotimeline.data.remote.RecordingDto
import com.keylightpiano.pianotimeline.domain.Recording
import java.time.LocalDate
import java.time.format.DateTimeFormatter
private val DATE_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
/**
* Converts a raw DTO into a clean Recording.
* Returns null if the entry is too broken to use (e.g. unparseable date),
* so the caller can skip it instead of crashing the whole list.
*/
fun RecordingDto.toDomainOrNull(): Recording? {
val parsedDate = try {
LocalDate.parse(date.trim(), DATE_FORMAT)
} catch (e: Exception) {
return null // a recording with no valid date is useless for sorting
}
return Recording(
id = id,
date = parsedDate,
artist = artist.trim(),
title = title.trim(),
extraInfo = extraInfo.trim(),
performanceNumber = performanceNumber.toIntOrNull() ?: 0,
totalPerformances = totalPerformances.toIntOrNull() ?: 0,
fileName = fileName, // keep raw — encoded only when building a URL
midiFileName = midiFileName.ifBlank { null },
durationSeconds = duration.toDoubleOrNull() ?: 0.0,
soundStartSeconds = soundStart.toDoubleOrNull() ?: 0.0,
fileSize = fileSize.toLongOrNull() ?: 0L
)
}

View file

@ -0,0 +1,18 @@
package com.keylightpiano.pianotimeline.data.remote
import retrofit2.http.GET
/**
* Retrofit turns this interface into a working HTTP client at runtime.
* Each function = one endpoint. The return type tells Retrofit what to
* deserialize the response into (our DTO, via the kotlinx-serialization converter).
*
* `suspend` means this is a coroutine function: it can run the network call
* off the main thread and "suspend" until the response arrives, without
* blocking anything. Retrofit has built-in suspend support.
*/
interface MusicApi {
@GET("list.json")
suspend fun getRecordingList(): RecordingListDto
}

View file

@ -0,0 +1,61 @@
package com.keylightpiano.pianotimeline.data.remote
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
/**
* Central place that knows your server layout and builds the HTTP stack.
* Everything media-related lives under this one base directory.
*/
object MusicNetwork {
const val BASE_URL = "https://timeline.keylightpiano.com/music/"
/**
* JSON parser config.
* - ignoreUnknownKeys: we don't model "generated-at", and this future-proofs
* the app against new server-side fields.
* - isLenient: tolerates minor formatting slack.
*/
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}
private val logging = HttpLoggingInterceptor().apply {
// BODY in debug is verbose but invaluable while building. Dial down later.
level = HttpLoggingInterceptor.Level.BASIC
}
private val okHttp: OkHttpClient = OkHttpClient.Builder()
.addInterceptor(logging)
.build()
private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
val api: MusicApi = retrofit.create(MusicApi::class.java)
/** The shared OkHttp client, reused later for streaming file downloads. */
val httpClient: OkHttpClient get() = okHttp
/**
* Builds a correct, percent-encoded URL for a media file living next to list.json.
* OkHttp's HttpUrl builder handles the encoding of spaces, '#', '&', apostrophes,
* accented characters, etc. so we never hand-concatenate raw filenames.
*/
fun fileUrl(fileName: String): String =
BASE_URL.toHttpUrl()
.newBuilder()
.addPathSegment(fileName) // addPathSegment encodes the segment for us
.build()
.toString()
}

View file

@ -0,0 +1,32 @@
package com.keylightpiano.pianotimeline.data.remote
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Mirrors the top-level shape of list.json exactly.
* This layer does NO interpretation every field stays a String,
* exactly as the server sends it. Mapping to clean types happens later.
*/
@Serializable
data class RecordingListDto(
@SerialName("generated-at") val generatedAt: String,
@SerialName("revision") val revision: Int,
@SerialName("data") val data: List<RecordingDto>
)
@Serializable
data class RecordingDto(
@SerialName("date") val date: String,
@SerialName("artist") val artist: String,
@SerialName("title") val title: String,
@SerialName("extra-info") val extraInfo: String = "",
@SerialName("performance-number") val performanceNumber: String = "0",
@SerialName("file-name") val fileName: String,
@SerialName("midi-file-name") val midiFileName: String = "",
@SerialName("duration") val duration: String = "0",
@SerialName("sound-start") val soundStart: String = "0",
@SerialName("total-performances") val totalPerformances: String = "0",
@SerialName("file-size") val fileSize: String = "0",
@SerialName("ID") val id: String
)

View file

@ -0,0 +1,46 @@
package com.keylightpiano.pianotimeline.domain
import java.time.LocalDate
/**
* The clean, app-facing model. Real types, sane defaults, computed helpers.
* The UI only ever touches this never the raw DTO.
*/
data class Recording(
val id: String, // MD5 of the filename — stable primary key
val date: LocalDate, // parsed from "dd.MM.yyyy"
val artist: String, // the REAL artist, shown on the row as-is
val title: String,
val extraInfo: String,
val performanceNumber: Int,
val totalPerformances: Int,
val fileName: String, // raw, NOT url-encoded (encode at fetch time)
val midiFileName: String?, // null when the JSON had ""
val durationSeconds: Double,
val soundStartSeconds: Double,
val fileSize: Long
) {
/** True when there's a MIDI file → the "Notes" button should appear. */
val hasMidi: Boolean get() = midiFileName != null
/**
* Artist used for grouping / filtering only.
* Anything containing "soundtrack" (case-insensitive) collapses to "Soundtracks".
* The real `artist` is left untouched for display.
*/
val groupingArtist: String
get() = if (artist.contains("soundtrack", ignoreCase = true)) "Soundtracks" else artist
/** "4:16" style. durationSeconds is wall-clock length of the file. */
val formattedDuration: String
get() {
val total = durationSeconds.toInt()
val minutes = total / 60
val seconds = total % 60
return "%d:%02d".format(minutes, seconds)
}
/** "2 / 2" — this performance number out of the total for the piece. */
val performanceLabel: String
get() = "$performanceNumber / $totalPerformances"
}