Test fetch: live list.json renders in throwaway UI
This commit is contained in:
parent
25ebacbe31
commit
4d52f85900
10 changed files with 354 additions and 3 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,7 @@ class MainActivity : ComponentActivity() {
|
||||||
setContent {
|
setContent {
|
||||||
PianoTimelineTheme {
|
PianoTimelineTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||||
Greeting(
|
TestScreen(
|
||||||
name = "Android",
|
|
||||||
modifier = Modifier.padding(innerPadding)
|
modifier = Modifier.padding(innerPadding)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue