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:
@@ -156,6 +156,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation(libs.coil.network.okhttp)
|
||||||
|
implementation(libs.icons.lucide)
|
||||||
|
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
||||||
import com.fabledsword.minstrel.theme.MinstrelTheme
|
import com.fabledsword.minstrel.theme.MinstrelTheme
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
@@ -26,7 +27,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun App() {
|
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 ->
|
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 android.app.Application
|
||||||
import androidx.hilt.work.HiltWorkerFactory
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
import androidx.work.Configuration
|
import androidx.work.Configuration
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class MinstrelApplication : Application(), Configuration.Provider {
|
class MinstrelApplication :
|
||||||
|
Application(),
|
||||||
|
Configuration.Provider,
|
||||||
|
SingletonImageLoader.Factory {
|
||||||
|
|
||||||
@Inject lateinit var workerFactory: HiltWorkerFactory
|
@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() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
||||||
@@ -20,4 +35,17 @@ class MinstrelApplication : Application(), Configuration.Provider {
|
|||||||
get() = Configuration.Builder()
|
get() = Configuration.Builder()
|
||||||
.setWorkerFactory(workerFactory)
|
.setWorkerFactory(workerFactory)
|
||||||
.build()
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp"
|
|||||||
media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
|
media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
|
||||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
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" }
|
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" }
|
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
|
||||||
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" }
|
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" }
|
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version = "1.11.3" }
|
||||||
|
|||||||
Reference in New Issue
Block a user