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