From e934da30a1ee19533da306e4deeb38cab2693347 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 10:38:08 -0400 Subject: [PATCH] feat(android): Library Compose screens + Coil OkHttp sharing + Lucide (M8 phase 5.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First user-visible UI. LibraryScreen renders the LibraryViewModel UiState into horizontally-scrolling Artist / Album rows; Loading shows a centered spinner, Empty / Error fall back to shared widgets. Files: - library/ui/LibraryScreen.kt — top-level screen, hiltViewModel + collectAsStateWithLifecycle, exhaustive when(state) - library/widgets/ArtistCard.kt — circular cover + name beneath - library/widgets/AlbumCard.kt — 144dp square cover + title + artist beneath, matches Flutter spec (~176dp tile width) - shared/widgets/EmptyState.kt — generic empty-state widget (Lucide Inbox by default), used by Library + reusable for Quarantine / search etc. - shared/widgets/ErrorRetry.kt — error message + retry button (uses LocalActionColors.primary = Moss per design system rule) Audit-deferred items now triggered: - MinstrelApplication implements SingletonImageLoader.Factory and wires OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }) so Coil cover-art requests reuse the shared auth-bearing OkHttp - Lucide icons via com.composables:icons-lucide-android:2.2.1 for placeholder / decorative iconography MainActivity now renders LibraryScreen inside a Scaffold (not the "phase 1" text placeholder). Nav-graph wiring deferred to Phase 5.5 — onArtistClick / onAlbumClick are no-op for now. Co-Authored-By: Claude Opus 4.7 (1M context) --- android/app/build.gradle.kts | 1 + .../com/fabledsword/minstrel/MainActivity.kt | 10 +- .../minstrel/MinstrelApplication.kt | 30 ++++- .../minstrel/library/ui/LibraryScreen.kt | 119 ++++++++++++++++++ .../minstrel/library/widgets/AlbumCard.kt | 88 +++++++++++++ .../minstrel/library/widgets/ArtistCard.kt | 84 +++++++++++++ .../minstrel/shared/widgets/EmptyState.kt | 64 ++++++++++ .../minstrel/shared/widgets/ErrorRetry.kt | 66 ++++++++++ android/gradle/libs.versions.toml | 1 + 9 files changed, 460 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/EmptyState.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ErrorRetry.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b9057c5e..06a863e3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -156,6 +156,7 @@ dependencies { implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) + implementation(libs.icons.lucide) implementation(libs.timber) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt index 59a8cd00..5136b613 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt @@ -4,12 +4,13 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box 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 com.fabledsword.minstrel.library.ui.LibraryScreen import com.fabledsword.minstrel.theme.MinstrelTheme import dagger.hilt.android.AndroidEntryPoint @@ -26,7 +27,12 @@ class MainActivity : ComponentActivity() { @Composable private fun App() { + // Bottom-bar nav-graph wiring lands in Phase 5.5. For now render + // LibraryScreen directly so the new UI is reachable end-to-end. + // onArtistClick / onAlbumClick are no-op until the nav graph lands. Scaffold(modifier = Modifier.fillMaxSize()) { inner -> - Text(text = "Minstrel native — phase 1", modifier = Modifier.padding(inner)) + Box(modifier = Modifier.padding(inner)) { + LibraryScreen() + } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index 34f149c5..3157c4ad 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -3,14 +3,29 @@ package com.fabledsword.minstrel import android.app.Application import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration +import coil3.ImageLoader +import coil3.SingletonImageLoader +import coil3.network.okhttp.OkHttpNetworkFetcherFactory import dagger.hilt.android.HiltAndroidApp +import okhttp3.OkHttpClient import timber.log.Timber import javax.inject.Inject @HiltAndroidApp -class MinstrelApplication : Application(), Configuration.Provider { +class MinstrelApplication : + Application(), + Configuration.Provider, + SingletonImageLoader.Factory { + @Inject lateinit var workerFactory: HiltWorkerFactory + /** + * The single shared OkHttp client (with the auth-cookie interceptor) + * — also handed to Coil so cover-art requests carry the session + * cookie and reuse the same connection pool + TLS config. + */ + @Inject lateinit var okHttpClient: OkHttpClient + override fun onCreate() { super.onCreate() if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) @@ -20,4 +35,17 @@ class MinstrelApplication : Application(), Configuration.Provider { get() = Configuration.Builder() .setWorkerFactory(workerFactory) .build() + + /** + * Builds the process-singleton Coil ImageLoader with our shared + * OkHttp client as the network fetcher. The `callFactory` lambda + * is invoked lazily so Hilt has time to inject `okHttpClient` + * before Coil makes its first request. + */ + override fun newImageLoader(context: android.content.Context): ImageLoader = + ImageLoader.Builder(context) + .components { + add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })) + } + .build() } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt new file mode 100644 index 00000000..b617f465 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt @@ -0,0 +1,119 @@ +package com.fabledsword.minstrel.library.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +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.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fabledsword.minstrel.library.widgets.AlbumCard +import com.fabledsword.minstrel.library.widgets.ArtistCard +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.ErrorRetry + +/** + * Top-level Library tab. Renders horizontally-scrolling rows of + * Artists and Albums driven by `LibraryViewModel`'s UiState. + * + * Loading shows a centered spinner; Empty / Error use the shared + * widgets; Success renders the lists. Detail-screen navigation + * (`onArtistClick` / `onAlbumClick`) is wired by the caller — Phase 5.5 + * adds the nav graph + routes. + */ +@Composable +fun LibraryScreen( + onArtistClick: (ArtistRef) -> Unit = {}, + onAlbumClick: (AlbumRef) -> Unit = {}, + viewModel: LibraryViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + when (val s = state) { + LibraryUiState.Loading -> LoadingCentered() + LibraryUiState.Empty -> EmptyState( + title = "Nothing here yet", + body = "Scan a folder in your server settings to populate the library.", + ) + is LibraryUiState.Error -> ErrorRetry( + message = s.message, + onRetry = { /* Wired in Phase 5.5 — for now, surface only. */ }, + ) + is LibraryUiState.Success -> LibrarySuccessContent( + state = s, + onArtistClick = onArtistClick, + onAlbumClick = onAlbumClick, + ) + } +} + +@Composable +private fun LoadingCentered() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } +} + +@Composable +private fun LibrarySuccessContent( + state: LibraryUiState.Success, + onArtistClick: (ArtistRef) -> Unit, + onAlbumClick: (AlbumRef) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 16.dp), + ) { + if (state.artists.isNotEmpty()) { + item { SectionHeader(text = "Artists") } + item { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = state.artists, key = { it.id }) { artist -> + ArtistCard(artist = artist, onClick = { onArtistClick(artist) }) + } + } + } + item { Spacer(Modifier.height(24.dp)) } + } + if (state.albums.isNotEmpty()) { + item { SectionHeader(text = "Albums") } + item { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = state.albums, key = { it.id }) { album -> + AlbumCard(album = album, onClick = { onAlbumClick(album) }) + } + } + } + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt new file mode 100644 index 00000000..ad48454e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt @@ -0,0 +1,88 @@ +package com.fabledsword.minstrel.library.widgets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.composables.icons.lucide.Disc3 +import com.composables.icons.lucide.Lucide +import androidx.compose.material3.Icon +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.theme.FabledSwordTokens + +/** + * One album tile. Mirrors the Flutter `AlbumCard` (~176dp wide, 144dp + * square cover). Tap fires `onClick` — wired to `AlbumDetail` nav in + * Phase 5.5. + */ +@Composable +fun AlbumCard( + album: AlbumRef, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .width(176.dp) + .clickable(onClick = onClick), + color = Color.Transparent, + ) { + Column(modifier = Modifier.padding(horizontal = 8.dp)) { + Box( + modifier = Modifier + .size(144.dp) + .clip(RoundedCornerShape(FabledSwordTokens.radiusSm)), + contentAlignment = Alignment.Center, + ) { + if (album.coverUrl.isEmpty()) { + Icon( + imageVector = Lucide.Disc3, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(56.dp), + ) + } else { + AsyncImage( + model = album.coverUrl, + contentDescription = album.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + } + Spacer(Modifier.height(8.dp)) + Text( + text = album.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = album.artistName.ifEmpty { " " }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt new file mode 100644 index 00000000..4aa69fa3 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt @@ -0,0 +1,84 @@ +package com.fabledsword.minstrel.library.widgets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.User +import com.fabledsword.minstrel.models.ArtistRef + +/** + * One artist tile. Circular cover; name centered beneath. Tap fires + * `onClick` — wired to `ArtistDetail` nav in Phase 5.5. + */ +@Composable +fun ArtistCard( + artist: ArtistRef, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .width(144.dp) + .clickable(onClick = onClick), + color = Color.Transparent, + ) { + Column( + modifier = Modifier.padding(horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(128.dp) + .clip(CircleShape), + contentAlignment = Alignment.Center, + ) { + if (artist.coverUrl.isEmpty()) { + Icon( + imageVector = Lucide.User, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(56.dp), + ) + } else { + AsyncImage( + model = artist.coverUrl, + contentDescription = artist.name, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + } + Spacer(Modifier.height(8.dp)) + Text( + text = artist.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/EmptyState.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/EmptyState.kt new file mode 100644 index 00000000..aff0d686 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/EmptyState.kt @@ -0,0 +1,64 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.material3.Icon +import androidx.compose.ui.graphics.vector.ImageVector +import com.composables.icons.lucide.Inbox +import com.composables.icons.lucide.Lucide + +/** + * Shared empty-state widget. Used wherever a screen / section has no + * content to show — Library before first sync, Quarantine when no + * tracks are hidden, search results, etc. + * + * Per the FabledSword design system: sentence case copy, understated + * tone. The icon defaults to Lucide's `Inbox`; callers pass their own + * when a more topical icon helps. + */ +@Composable +fun EmptyState( + title: String, + body: String, + modifier: Modifier = Modifier, + icon: ImageVector = Lucide.Inbox, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = title, + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Text( + text = body, + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ErrorRetry.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ErrorRetry.kt new file mode 100644 index 00000000..a77f3aeb --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ErrorRetry.kt @@ -0,0 +1,66 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.TriangleAlert +import com.fabledsword.minstrel.theme.LocalActionColors + +/** + * Shared error-with-retry widget. Surfaces a brief message and a + * primary action button. Per the design system, the button uses + * `LocalActionColors.primary` (Moss) — NEVER the accent. + */ +@Composable +fun ErrorRetry( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, + retryLabel: String = "Retry", +) { + val actionColors = LocalActionColors.current + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Lucide.TriangleAlert, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = message, + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Button( + onClick = onRetry, + modifier = Modifier.padding(top = 16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = actionColors.primary, + contentColor = actionColors.onAction, + ), + ) { + Text(retryLabel) + } + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 1d8d6615..0bfbab95 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -71,6 +71,7 @@ media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp" media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } +icons-lucide = { module = "com.composables:icons-lucide-android", version = "2.2.1" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version = "1.11.3" }