From 4d52f85900de0a1fa878aa5b4b27ddb240ce9fed Mon Sep 17 00:00:00 2001 From: OmniaX-Dev Date: Tue, 23 Jun 2026 08:39:48 +0200 Subject: [PATCH] Test fetch: live list.json renders in throwaway UI --- app/src/main/AndroidManifest.xml | 3 +- .../pianotimeline/MainActivity.kt | 3 +- .../keylightpiano/pianotimeline/TestScreen.kt | 71 +++++++++++++++++++ .../pianotimeline/TestViewModel.kt | 50 +++++++++++++ .../pianotimeline/data/MusicRepository.kt | 37 ++++++++++ .../pianotimeline/data/RecordingMapper.kt | 36 ++++++++++ .../pianotimeline/data/remote/MusicApi.kt | 18 +++++ .../pianotimeline/data/remote/MusicNetwork.kt | 61 ++++++++++++++++ .../pianotimeline/data/remote/RecordingDto.kt | 32 +++++++++ .../pianotimeline/domain/Recording.kt | 46 ++++++++++++ 10 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/keylightpiano/pianotimeline/TestScreen.kt create mode 100644 app/src/main/java/com/keylightpiano/pianotimeline/TestViewModel.kt create mode 100644 app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt create mode 100644 app/src/main/java/com/keylightpiano/pianotimeline/data/RecordingMapper.kt create mode 100644 app/src/main/java/com/keylightpiano/pianotimeline/data/remote/MusicApi.kt create mode 100644 app/src/main/java/com/keylightpiano/pianotimeline/data/remote/MusicNetwork.kt create mode 100644 app/src/main/java/com/keylightpiano/pianotimeline/data/remote/RecordingDto.kt create mode 100644 app/src/main/java/com/keylightpiano/pianotimeline/domain/Recording.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 357f98c..47e91ef 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,8 @@ - + + - Greeting( - name = "Android", + TestScreen( modifier = Modifier.padding(innerPadding) ) } diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/TestScreen.kt b/app/src/main/java/com/keylightpiano/pianotimeline/TestScreen.kt new file mode 100644 index 0000000..1e9ba1e --- /dev/null +++ b/app/src/main/java/com/keylightpiano/pianotimeline/TestScreen.kt @@ -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 + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/TestViewModel.kt b/app/src/main/java/com/keylightpiano/pianotimeline/TestViewModel.kt new file mode 100644 index 0000000..6e4279c --- /dev/null +++ b/app/src/main/java/com/keylightpiano/pianotimeline/TestViewModel.kt @@ -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.Loading) + val state: StateFlow = _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) : FetchState + data class Error(val message: String) : FetchState +} diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt new file mode 100644 index 0000000..0021043 --- /dev/null +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt @@ -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 +) + +/** + * 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) + } +} diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/RecordingMapper.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/RecordingMapper.kt new file mode 100644 index 0000000..ecd09ca --- /dev/null +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/RecordingMapper.kt @@ -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 + ) +} diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/remote/MusicApi.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/remote/MusicApi.kt new file mode 100644 index 0000000..063f679 --- /dev/null +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/remote/MusicApi.kt @@ -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 +} diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/remote/MusicNetwork.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/remote/MusicNetwork.kt new file mode 100644 index 0000000..6f92c5c --- /dev/null +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/remote/MusicNetwork.kt @@ -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() +} diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/remote/RecordingDto.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/remote/RecordingDto.kt new file mode 100644 index 0000000..e625daa --- /dev/null +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/remote/RecordingDto.kt @@ -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 +) + +@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 +) diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/domain/Recording.kt b/app/src/main/java/com/keylightpiano/pianotimeline/domain/Recording.kt new file mode 100644 index 0000000..e395b0a --- /dev/null +++ b/app/src/main/java/com/keylightpiano/pianotimeline/domain/Recording.kt @@ -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" +}