feat(android): Library Compose screens + Coil OkHttp sharing + Lucide (M8 phase 5.4)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 10:38:08 -04:00
parent b03d4a86e7
commit e934da30a1
9 changed files with 460 additions and 3 deletions
+1
View File
@@ -156,6 +156,7 @@ dependencies {
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.icons.lucide)
implementation(libs.timber)
@@ -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()
}
}
}
@@ -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()
}
@@ -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,
)
}
@@ -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,
)
}
}
}
@@ -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,
)
}
}
}
@@ -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,
)
}
}
@@ -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)
}
}
}
+1
View File
@@ -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" }