Phase 3: Subsonic browsing API + real library UI
Replaces Phase 2's sample-data placeholders with real Navidrome browsing: artists, albums, songs, and search, per the Phase 2+ roadmap's Phase 3. - SubsonicApi grows from just ping to getArtists/getArtist/ getAlbumList2/getAlbum/search3, with DTOs split per-domain (ArtistModels/AlbumModels/SongModels/SearchModels) and mapped to plain domain models (data/model) by the new LibraryRepository - Coil wired in for cover art, sharing the same signed OkHttpClient as Retrofit via a Hilt EntryPoint (SubsonicRequestInterceptor signs cover-art requests identically to every other Subsonic call) - Artwork() falls back to the Phase 2 placeholder icon when no cover art id is known - ui/library replaces the Phase 2 placeholders: HomeScreen now shows real Recently Added/Played and a random "Made For You" row, ArtistsScreen is the real Library tab, ArtistDetailScreen and AlbumDetailScreen are new drill-down screens (typed nav routes, ViewModels resolve their id via SavedStateHandle.toRoute()), and SearchScreen does debounced search3 across artists/albums/songs - TrackRow gained a `circular` option so it can double as an artist row in the Library list, not just a track row First real test coverage in the repo: LibraryRepositoryTest (DTO to domain-model mapping against MockWebServer) and SubsonicRequestInterceptorTest (rewritten URL + signed query params). CredentialsStore/ServerRepository tests are deliberately still out of scope - CredentialsStore's Tink/Android Keystore usage isn't testable in a plain JVM unit test without Robolectric, which felt like a bigger side quest than this phase called for. Coil pinned to 3.3.0 rather than the newer 3.6.x latest: newer Coil requires Kotlin 2.4+, which conflicts with this project's Kotlin 2.2.10; 3.3.0 was the last release built against Kotlin 2.2.x. Verified end-to-end against a real Navidrome server on-device: Home rows, artist list/detail, album detail, and search all load real data and real cover art. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a07868b688
commit
08b3a9a0df
Generated
+13
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -68,7 +68,14 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.tink.android)
|
||||
|
||||
// Cover art loading (shares the signed OkHttpClient from NetworkModule)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.mockito.core)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
package com.InfernalAquatics.deepwave
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import com.InfernalAquatics.deepwave.di.OkHttpClientEntryPoint
|
||||
import dagger.hilt.EntryPoints
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class DeepwaveApplication : Application()
|
||||
class DeepwaveApplication : Application(), SingletonImageLoader.Factory {
|
||||
|
||||
/**
|
||||
* Reuses the same signed OkHttpClient as Retrofit so cover-art requests get the
|
||||
* Subsonic auth params from [com.InfernalAquatics.deepwave.data.network.SubsonicRequestInterceptor]
|
||||
* too. Pulled via an EntryPoint rather than field injection since Coil constructs
|
||||
* this outside Hilt's own injection points.
|
||||
*/
|
||||
override fun newImageLoader(context: Context): ImageLoader {
|
||||
val okHttpClient = EntryPoints.get(this, OkHttpClientEntryPoint::class.java).okHttpClient()
|
||||
return ImageLoader.Builder(context)
|
||||
.components { add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })) }
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.InfernalAquatics.deepwave.data.model
|
||||
|
||||
/** Domain models the UI/repositories deal in — mapped from Subsonic DTOs, never exposed directly. */
|
||||
|
||||
data class Artist(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val albumCount: Int,
|
||||
val coverArtId: String?,
|
||||
)
|
||||
|
||||
data class Album(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val artistName: String,
|
||||
val artistId: String?,
|
||||
val coverArtId: String?,
|
||||
val songCount: Int,
|
||||
val year: Int?,
|
||||
)
|
||||
|
||||
data class Song(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val albumName: String?,
|
||||
val artistName: String?,
|
||||
val albumId: String?,
|
||||
val artistId: String?,
|
||||
val coverArtId: String?,
|
||||
val track: Int?,
|
||||
val durationSeconds: Int?,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.InfernalAquatics.deepwave.data.network
|
||||
|
||||
import com.InfernalAquatics.deepwave.di.PLACEHOLDER_BASE_URL
|
||||
|
||||
/**
|
||||
* Builds a cover-art request URL for Coil. Just like Retrofit's placeholder base URL,
|
||||
* [SubsonicRequestInterceptor] rewrites this onto the real server and signs it — Coil's
|
||||
* ImageLoader shares the same OkHttpClient (see DeepwaveApplication), so this resolves
|
||||
* identically to every other Subsonic request.
|
||||
*/
|
||||
fun coverArtUrl(coverArtId: String?, size: Int = 300): String? =
|
||||
coverArtId?.let { "${PLACEHOLDER_BASE_URL}rest/getCoverArt.view?id=$it&size=$size" }
|
||||
@@ -1,7 +1,13 @@
|
||||
package com.InfernalAquatics.deepwave.data.network
|
||||
|
||||
import com.InfernalAquatics.deepwave.data.network.model.GetAlbumListResponse
|
||||
import com.InfernalAquatics.deepwave.data.network.model.GetAlbumResponse
|
||||
import com.InfernalAquatics.deepwave.data.network.model.GetArtistResponse
|
||||
import com.InfernalAquatics.deepwave.data.network.model.GetArtistsResponse
|
||||
import com.InfernalAquatics.deepwave.data.network.model.PingResponse
|
||||
import com.InfernalAquatics.deepwave.data.network.model.SearchResult3Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Subsonic API surface implemented by Navidrome. The base URL is a placeholder
|
||||
@@ -12,4 +18,28 @@ import retrofit2.http.GET
|
||||
interface SubsonicApi {
|
||||
@GET("rest/ping")
|
||||
suspend fun ping(): PingResponse
|
||||
|
||||
@GET("rest/getArtists")
|
||||
suspend fun getArtists(): GetArtistsResponse
|
||||
|
||||
@GET("rest/getArtist")
|
||||
suspend fun getArtist(@Query("id") id: String): GetArtistResponse
|
||||
|
||||
@GET("rest/getAlbumList2")
|
||||
suspend fun getAlbumList2(
|
||||
@Query("type") type: String,
|
||||
@Query("size") size: Int = 20,
|
||||
@Query("offset") offset: Int = 0,
|
||||
): GetAlbumListResponse
|
||||
|
||||
@GET("rest/getAlbum")
|
||||
suspend fun getAlbum(@Query("id") id: String): GetAlbumResponse
|
||||
|
||||
@GET("rest/search3")
|
||||
suspend fun search3(
|
||||
@Query("query") query: String,
|
||||
@Query("artistCount") artistCount: Int = 10,
|
||||
@Query("albumCount") albumCount: Int = 10,
|
||||
@Query("songCount") songCount: Int = 10,
|
||||
): SearchResult3Response
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.InfernalAquatics.deepwave.data.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AlbumSummaryDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val artist: String? = null,
|
||||
val artistId: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val songCount: Int = 0,
|
||||
val duration: Int = 0,
|
||||
val year: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetAlbumListResponse(
|
||||
@SerialName("subsonic-response") val subsonicResponse: GetAlbumListBody,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetAlbumListBody(
|
||||
val albumList2: AlbumListDto? = null,
|
||||
val error: SubsonicError? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AlbumListDto(
|
||||
val album: List<AlbumSummaryDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetAlbumResponse(
|
||||
@SerialName("subsonic-response") val subsonicResponse: GetAlbumBody,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetAlbumBody(
|
||||
val album: AlbumDetailDto? = null,
|
||||
val error: SubsonicError? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AlbumDetailDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val artist: String? = null,
|
||||
val artistId: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val songCount: Int = 0,
|
||||
val duration: Int = 0,
|
||||
val year: Int? = null,
|
||||
val song: List<SongDto> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.InfernalAquatics.deepwave.data.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ArtistSummaryDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val albumCount: Int = 0,
|
||||
val coverArt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetArtistsResponse(
|
||||
@SerialName("subsonic-response") val subsonicResponse: GetArtistsBody,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetArtistsBody(
|
||||
val artists: ArtistsIndexDto? = null,
|
||||
val error: SubsonicError? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistsIndexDto(
|
||||
val index: List<ArtistIndexEntryDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistIndexEntryDto(
|
||||
val name: String,
|
||||
val artist: List<ArtistSummaryDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetArtistResponse(
|
||||
@SerialName("subsonic-response") val subsonicResponse: GetArtistBody,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetArtistBody(
|
||||
val artist: ArtistDetailDto? = null,
|
||||
val error: SubsonicError? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistDetailDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val albumCount: Int = 0,
|
||||
val coverArt: String? = null,
|
||||
val album: List<AlbumSummaryDto> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.InfernalAquatics.deepwave.data.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SearchResult3Response(
|
||||
@SerialName("subsonic-response") val subsonicResponse: SearchResult3Body,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchResult3Body(
|
||||
val searchResult3: SearchResult3Dto? = null,
|
||||
val error: SubsonicError? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchResult3Dto(
|
||||
val artist: List<ArtistSummaryDto> = emptyList(),
|
||||
val album: List<AlbumSummaryDto> = emptyList(),
|
||||
val song: List<SongDto> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.InfernalAquatics.deepwave.data.network.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SongDto(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val album: String? = null,
|
||||
val artist: String? = null,
|
||||
val albumId: String? = null,
|
||||
val artistId: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val track: Int? = null,
|
||||
val duration: Int? = null,
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.InfernalAquatics.deepwave.data.repository
|
||||
|
||||
import com.InfernalAquatics.deepwave.data.model.Album
|
||||
import com.InfernalAquatics.deepwave.data.model.Artist
|
||||
import com.InfernalAquatics.deepwave.data.model.Song
|
||||
import com.InfernalAquatics.deepwave.data.network.SubsonicApi
|
||||
import com.InfernalAquatics.deepwave.data.network.model.AlbumDetailDto
|
||||
import com.InfernalAquatics.deepwave.data.network.model.AlbumSummaryDto
|
||||
import com.InfernalAquatics.deepwave.data.network.model.ArtistSummaryDto
|
||||
import com.InfernalAquatics.deepwave.data.network.model.SongDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class SearchResults(
|
||||
val artists: List<Artist>,
|
||||
val albums: List<Album>,
|
||||
val songs: List<Song>,
|
||||
)
|
||||
|
||||
/** Subsonic album-list row types accepted by [LibraryRepository.getAlbumList]. */
|
||||
object AlbumListType {
|
||||
const val NEWEST = "newest"
|
||||
const val RECENT = "recent"
|
||||
const val RANDOM = "random"
|
||||
const val FREQUENT = "frequent"
|
||||
}
|
||||
|
||||
/** Read-only library browsing: artists, albums, songs, search. Maps DTOs to domain models. */
|
||||
@Singleton
|
||||
class LibraryRepository @Inject constructor(
|
||||
private val subsonicApi: SubsonicApi,
|
||||
) {
|
||||
suspend fun getArtists(): List<Artist> =
|
||||
subsonicApi.getArtists().subsonicResponse.artists?.index.orEmpty()
|
||||
.flatMap { it.artist }
|
||||
.map { it.toDomain() }
|
||||
|
||||
suspend fun getArtistDetail(artistId: String): Pair<Artist, List<Album>> {
|
||||
val body = subsonicApi.getArtist(artistId).subsonicResponse
|
||||
val dto = body.artist ?: error(body.error?.message ?: "Artist not found")
|
||||
val artist = Artist(id = dto.id, name = dto.name, albumCount = dto.albumCount, coverArtId = dto.coverArt)
|
||||
return artist to dto.album.map { it.toDomain() }
|
||||
}
|
||||
|
||||
suspend fun getAlbumList(type: String, size: Int = 20): List<Album> =
|
||||
subsonicApi.getAlbumList2(type = type, size = size).subsonicResponse.albumList2?.album.orEmpty()
|
||||
.map { it.toDomain() }
|
||||
|
||||
suspend fun getAlbumDetail(albumId: String): Pair<Album, List<Song>> {
|
||||
val body = subsonicApi.getAlbum(albumId).subsonicResponse
|
||||
val dto = body.album ?: error(body.error?.message ?: "Album not found")
|
||||
return dto.toDomain() to dto.song.map { it.toDomain() }
|
||||
}
|
||||
|
||||
suspend fun search(query: String): SearchResults {
|
||||
val dto = subsonicApi.search3(query = query).subsonicResponse.searchResult3
|
||||
return SearchResults(
|
||||
artists = dto?.artist.orEmpty().map { it.toDomain() },
|
||||
albums = dto?.album.orEmpty().map { it.toDomain() },
|
||||
songs = dto?.song.orEmpty().map { it.toDomain() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ArtistSummaryDto.toDomain() = Artist(
|
||||
id = id,
|
||||
name = name,
|
||||
albumCount = albumCount,
|
||||
coverArtId = coverArt,
|
||||
)
|
||||
|
||||
private fun AlbumSummaryDto.toDomain() = Album(
|
||||
id = id,
|
||||
name = name,
|
||||
artistName = artist.orEmpty(),
|
||||
artistId = artistId,
|
||||
coverArtId = coverArt,
|
||||
songCount = songCount,
|
||||
year = year,
|
||||
)
|
||||
|
||||
private fun AlbumDetailDto.toDomain() = Album(
|
||||
id = id,
|
||||
name = name,
|
||||
artistName = artist.orEmpty(),
|
||||
artistId = artistId,
|
||||
coverArtId = coverArt,
|
||||
songCount = songCount,
|
||||
year = year,
|
||||
)
|
||||
|
||||
private fun SongDto.toDomain() = Song(
|
||||
id = id,
|
||||
title = title,
|
||||
albumName = album,
|
||||
artistName = artist,
|
||||
albumId = albumId,
|
||||
artistId = artistId,
|
||||
coverArtId = coverArt,
|
||||
track = track,
|
||||
durationSeconds = duration,
|
||||
)
|
||||
@@ -17,9 +17,11 @@ import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Retrofit's base URL is a non-functional placeholder: [SubsonicRequestInterceptor]
|
||||
* rewrites every outgoing request onto the user's actual saved server URL.
|
||||
* rewrites every outgoing request onto the user's actual saved server URL. Internal
|
||||
* (not private) so [com.InfernalAquatics.deepwave.data.network.coverArtUrl] can build
|
||||
* cover-art request URLs that resolve the same way through the same interceptor.
|
||||
*/
|
||||
private const val PLACEHOLDER_BASE_URL = "http://localhost/"
|
||||
internal const val PLACEHOLDER_BASE_URL = "http://localhost/"
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.InfernalAquatics.deepwave.di
|
||||
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Lets [com.InfernalAquatics.deepwave.DeepwaveApplication] pull the signed [OkHttpClient]
|
||||
* out of the Hilt graph for Coil's [coil3.SingletonImageLoader.Factory], which Coil
|
||||
* constructs itself rather than through Hilt injection.
|
||||
*/
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface OkHttpClientEntryPoint {
|
||||
fun okHttpClient(): OkHttpClient
|
||||
}
|
||||
@@ -21,6 +21,7 @@ fun AlbumCard(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
coverArtUrl: String? = null,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
@@ -28,7 +29,7 @@ fun AlbumCard(
|
||||
.width(AlbumCardWidth)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
ArtworkPlaceholder(size = AlbumCardWidth)
|
||||
Artwork(imageUrl = coverArtUrl, size = AlbumCardWidth)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
|
||||
@@ -22,6 +22,7 @@ private val ArtistCardWidth = 110.dp
|
||||
fun ArtistCard(
|
||||
name: String,
|
||||
modifier: Modifier = Modifier,
|
||||
coverArtUrl: String? = null,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
@@ -30,7 +31,7 @@ fun ArtistCard(
|
||||
.clickable(onClick = onClick),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
ArtworkPlaceholder(size = ArtistCardWidth, circular = true)
|
||||
Artwork(imageUrl = coverArtUrl, size = ArtistCardWidth, circular = true)
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.InfernalAquatics.deepwave.ui.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
/** Cover art for a track/album/artist: real image via Coil when a URL is known, else the placeholder icon. */
|
||||
@Composable
|
||||
fun Artwork(
|
||||
imageUrl: String?,
|
||||
size: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
circular: Boolean = false,
|
||||
) {
|
||||
if (imageUrl == null) {
|
||||
ArtworkPlaceholder(size = size, modifier = modifier, circular = circular)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(if (circular) CircleShape else RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun ArtworkPreview() {
|
||||
DeepwaveTheme {
|
||||
Row {
|
||||
Artwork(imageUrl = null, size = 96.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ fun TrackRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
coverArtUrl: String? = null,
|
||||
circular: Boolean = false,
|
||||
trailingContent: (@Composable RowScope.() -> Unit)? = null,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
@@ -32,7 +34,7 @@ fun TrackRow(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ArtworkPlaceholder(size = 48.dp)
|
||||
Artwork(imageUrl = coverArtUrl, size = 48.dp, circular = circular)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package com.InfernalAquatics.deepwave.ui.home
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.ui.components.AlbumCard
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
/** Sample content demonstrating the design system; replaced by real library data in Phase 3. */
|
||||
@Composable
|
||||
fun HomeScreen(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
HomeRow(title = stringResource(R.string.home_row_recently_added))
|
||||
HomeRow(title = stringResource(R.string.home_row_recently_played))
|
||||
HomeRow(title = stringResource(R.string.home_row_made_for_you))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeRow(title: String) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(5) { index ->
|
||||
AlbumCard(title = "Sample Album ${index + 1}", subtitle = "Sample Artist")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
|
||||
@Composable
|
||||
private fun HomeScreenPreview() {
|
||||
DeepwaveTheme {
|
||||
HomeScreen()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.InfernalAquatics.deepwave.ui.library
|
||||
|
||||
import android.content.res.Configuration
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.data.model.Album
|
||||
import com.InfernalAquatics.deepwave.data.model.Song
|
||||
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
|
||||
import com.InfernalAquatics.deepwave.ui.components.Artwork
|
||||
import com.InfernalAquatics.deepwave.ui.components.TrackRow
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
@Composable
|
||||
fun AlbumDetailScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: AlbumDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
AlbumDetailContent(uiState = uiState, onBack = onBack)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AlbumDetailContent(uiState: AlbumDetailUiState, onBack: () -> Unit) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
when {
|
||||
uiState.isLoading -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
uiState.errorMessage != null -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = uiState.errorMessage, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
item { AlbumHeader(uiState.album) }
|
||||
items(uiState.songs, key = { it.id }) { song ->
|
||||
TrackRow(
|
||||
title = song.title,
|
||||
subtitle = song.artistName ?: uiState.album?.artistName.orEmpty(),
|
||||
coverArtUrl = coverArtUrl(song.coverArtId ?: uiState.album?.coverArtId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlbumHeader(album: Album?) {
|
||||
if (album == null) return
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Artwork(imageUrl = coverArtUrl(album.coverArtId), size = 180.dp)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(text = album.name, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
|
||||
Text(
|
||||
text = album.year?.let { "${album.artistName} · $it" } ?: album.artistName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val sampleAlbum = Album(
|
||||
id = "sample",
|
||||
name = "Sample Album",
|
||||
artistName = "Sample Artist",
|
||||
artistId = "artist-sample",
|
||||
coverArtId = null,
|
||||
songCount = 4,
|
||||
year = 2024,
|
||||
)
|
||||
private val sampleSongs = List(4) { index ->
|
||||
Song(
|
||||
id = "sample-song-$index",
|
||||
title = "Sample Track ${index + 1}",
|
||||
albumName = sampleAlbum.name,
|
||||
artistName = sampleAlbum.artistName,
|
||||
albumId = sampleAlbum.id,
|
||||
artistId = sampleAlbum.artistId,
|
||||
coverArtId = null,
|
||||
track = index + 1,
|
||||
durationSeconds = 200,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
|
||||
@Composable
|
||||
private fun AlbumDetailContentPreview() {
|
||||
DeepwaveTheme {
|
||||
AlbumDetailContent(
|
||||
uiState = AlbumDetailUiState(isLoading = false, album = sampleAlbum, songs = sampleSongs),
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.InfernalAquatics.deepwave.ui.library
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.toRoute
|
||||
import com.InfernalAquatics.deepwave.data.model.Album
|
||||
import com.InfernalAquatics.deepwave.data.model.Song
|
||||
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
|
||||
import com.InfernalAquatics.deepwave.ui.navigation.Route
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AlbumDetailUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val album: Album? = null,
|
||||
val songs: List<Song> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AlbumDetailViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val libraryRepository: LibraryRepository,
|
||||
) : ViewModel() {
|
||||
private val albumId = savedStateHandle.toRoute<Route.AlbumDetail>().albumId
|
||||
|
||||
private val _uiState = MutableStateFlow(AlbumDetailUiState())
|
||||
val uiState: StateFlow<AlbumDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
try {
|
||||
val (album, songs) = libraryRepository.getAlbumDetail(albumId)
|
||||
_uiState.update { it.copy(isLoading = false, album = album, songs = songs) }
|
||||
} catch (e: IOException) {
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = "Couldn't reach server") }
|
||||
} catch (e: HttpException) {
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = "Server error: ${e.message()}") }
|
||||
} catch (e: IllegalStateException) {
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.InfernalAquatics.deepwave.ui.library
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.data.model.Album
|
||||
import com.InfernalAquatics.deepwave.data.model.Artist
|
||||
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
|
||||
import com.InfernalAquatics.deepwave.ui.components.TrackRow
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
@Composable
|
||||
fun ArtistDetailScreen(
|
||||
onBack: () -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
ArtistDetailContent(uiState = uiState, onBack = onBack, onAlbumClick = onAlbumClick)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ArtistDetailContent(
|
||||
uiState: ArtistDetailUiState,
|
||||
onBack: () -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(uiState.artist?.name.orEmpty()) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
when {
|
||||
uiState.isLoading -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
uiState.errorMessage != null -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = uiState.errorMessage, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(uiState.albums, key = { it.id }) { album ->
|
||||
TrackRow(
|
||||
title = album.name,
|
||||
subtitle = album.year?.toString().orEmpty(),
|
||||
coverArtUrl = coverArtUrl(album.coverArtId),
|
||||
onClick = { onAlbumClick(album.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val sampleArtist = Artist(id = "sample", name = "Sample Artist", albumCount = 3, coverArtId = null)
|
||||
private val sampleAlbums = List(3) { index ->
|
||||
Album(
|
||||
id = "sample-$index",
|
||||
name = "Sample Album ${index + 1}",
|
||||
artistName = sampleArtist.name,
|
||||
artistId = sampleArtist.id,
|
||||
coverArtId = null,
|
||||
songCount = 10,
|
||||
year = 2020 + index,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
|
||||
@Composable
|
||||
private fun ArtistDetailContentPreview() {
|
||||
DeepwaveTheme {
|
||||
ArtistDetailContent(
|
||||
uiState = ArtistDetailUiState(isLoading = false, artist = sampleArtist, albums = sampleAlbums),
|
||||
onBack = {},
|
||||
onAlbumClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.InfernalAquatics.deepwave.ui.library
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.toRoute
|
||||
import com.InfernalAquatics.deepwave.data.model.Album
|
||||
import com.InfernalAquatics.deepwave.data.model.Artist
|
||||
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
|
||||
import com.InfernalAquatics.deepwave.ui.navigation.Route
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ArtistDetailUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val artist: Artist? = null,
|
||||
val albums: List<Album> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ArtistDetailViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val libraryRepository: LibraryRepository,
|
||||
) : ViewModel() {
|
||||
private val artistId = savedStateHandle.toRoute<Route.ArtistDetail>().artistId
|
||||
|
||||
private val _uiState = MutableStateFlow(ArtistDetailUiState())
|
||||
val uiState: StateFlow<ArtistDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
try {
|
||||
val (artist, albums) = libraryRepository.getArtistDetail(artistId)
|
||||
_uiState.update { it.copy(isLoading = false, artist = artist, albums = albums) }
|
||||
} catch (e: IOException) {
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = "Couldn't reach server") }
|
||||
} catch (e: HttpException) {
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = "Server error: ${e.message()}") }
|
||||
} catch (e: IllegalStateException) {
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.InfernalAquatics.deepwave.ui.library
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.InfernalAquatics.deepwave.data.model.Artist
|
||||
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
|
||||
import com.InfernalAquatics.deepwave.ui.components.TrackRow
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
@Composable
|
||||
fun ArtistsScreen(
|
||||
onArtistClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ArtistsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
ArtistsContent(uiState = uiState, onArtistClick = onArtistClick, modifier = modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistsContent(
|
||||
uiState: ArtistsUiState,
|
||||
onArtistClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
uiState.isLoading -> Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
uiState.errorMessage != null -> Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = uiState.errorMessage, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
else -> LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||
items(uiState.artists, key = { it.id }) { artist ->
|
||||
TrackRow(
|
||||
title = artist.name,
|
||||
subtitle = albumCountLabel(artist.albumCount),
|
||||
coverArtUrl = coverArtUrl(artist.coverArtId),
|
||||
circular = true,
|
||||
onClick = { onArtistClick(artist.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun albumCountLabel(count: Int): String = if (count == 1) "1 album" else "$count albums"
|
||||
|
||||
private val sampleArtists = List(8) { index ->
|
||||
Artist(id = "sample-$index", name = "Sample Artist ${index + 1}", albumCount = index + 1, coverArtId = null)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
|
||||
@Composable
|
||||
private fun ArtistsContentPreview() {
|
||||
DeepwaveTheme {
|
||||
ArtistsContent(uiState = ArtistsUiState(isLoading = false, artists = sampleArtists), onArtistClick = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.InfernalAquatics.deepwave.ui.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.InfernalAquatics.deepwave.data.model.Artist
|
||||
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ArtistsUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val artists: List<Artist> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ArtistsViewModel @Inject constructor(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ArtistsUiState())
|
||||
val uiState: StateFlow<ArtistsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
try {
|
||||
val artists = libraryRepository.getArtists()
|
||||
_uiState.update { it.copy(isLoading = false, artists = artists) }
|
||||
} catch (e: IOException) {
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = "Couldn't reach server") }
|
||||
} catch (e: HttpException) {
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = "Server error: ${e.message()}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.InfernalAquatics.deepwave.ui.library
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.data.model.Album
|
||||
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
|
||||
import com.InfernalAquatics.deepwave.ui.components.AlbumCard
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
onAlbumClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: HomeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
HomeContent(uiState = uiState, onAlbumClick = onAlbumClick, modifier = modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeContent(
|
||||
uiState: HomeUiState,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
uiState.isLoading -> Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
uiState.errorMessage != null -> Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = uiState.errorMessage, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
else -> Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
HomeRow(title = stringResource(R.string.home_row_recently_added), albums = uiState.recentlyAdded, onAlbumClick = onAlbumClick)
|
||||
HomeRow(title = stringResource(R.string.home_row_recently_played), albums = uiState.recentlyPlayed, onAlbumClick = onAlbumClick)
|
||||
HomeRow(title = stringResource(R.string.home_row_made_for_you), albums = uiState.madeForYou, onAlbumClick = onAlbumClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeRow(title: String, albums: List<Album>, onAlbumClick: (String) -> Unit) {
|
||||
if (albums.isEmpty()) return
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(albums, key = { it.id }) { album ->
|
||||
AlbumCard(
|
||||
title = album.name,
|
||||
subtitle = album.artistName,
|
||||
coverArtUrl = coverArtUrl(album.coverArtId),
|
||||
onClick = { onAlbumClick(album.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val sampleAlbums = List(5) { index ->
|
||||
Album(
|
||||
id = "sample-$index",
|
||||
name = "Sample Album ${index + 1}",
|
||||
artistName = "Sample Artist",
|
||||
artistId = null,
|
||||
coverArtId = null,
|
||||
songCount = 10,
|
||||
year = 2024,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
|
||||
@Composable
|
||||
private fun HomeContentPreview() {
|
||||
DeepwaveTheme {
|
||||
HomeContent(
|
||||
uiState = HomeUiState(
|
||||
isLoading = false,
|
||||
recentlyAdded = sampleAlbums,
|
||||
recentlyPlayed = sampleAlbums,
|
||||
madeForYou = sampleAlbums,
|
||||
),
|
||||
onAlbumClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Loading", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
|
||||
@Composable
|
||||
private fun HomeContentLoadingPreview() {
|
||||
DeepwaveTheme {
|
||||
HomeContent(uiState = HomeUiState(isLoading = true), onAlbumClick = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.InfernalAquatics.deepwave.ui.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.InfernalAquatics.deepwave.data.model.Album
|
||||
import com.InfernalAquatics.deepwave.data.repository.AlbumListType
|
||||
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
data class HomeUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val recentlyAdded: List<Album> = emptyList(),
|
||||
val recentlyPlayed: List<Album> = emptyList(),
|
||||
val madeForYou: List<Album> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(HomeUiState())
|
||||
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
try {
|
||||
val recentlyAdded = libraryRepository.getAlbumList(AlbumListType.NEWEST)
|
||||
val recentlyPlayed = libraryRepository.getAlbumList(AlbumListType.RECENT)
|
||||
val madeForYou = libraryRepository.getAlbumList(AlbumListType.RANDOM)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
recentlyAdded = recentlyAdded,
|
||||
recentlyPlayed = recentlyPlayed,
|
||||
madeForYou = madeForYou,
|
||||
)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = "Couldn't reach server") }
|
||||
} catch (e: HttpException) {
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = "Server error: ${e.message()}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.InfernalAquatics.deepwave.ui.library
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
/** Placeholder; real library browsing (artists/albums) arrives in Phase 3. */
|
||||
@Composable
|
||||
fun LibraryScreen(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = stringResource(R.string.library_placeholder), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
|
||||
@Composable
|
||||
private fun LibraryScreenPreview() {
|
||||
DeepwaveTheme {
|
||||
LibraryScreen()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.InfernalAquatics.deepwave.ui.library
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.data.model.Album
|
||||
import com.InfernalAquatics.deepwave.data.model.Artist
|
||||
import com.InfernalAquatics.deepwave.data.model.Song
|
||||
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
|
||||
import com.InfernalAquatics.deepwave.data.repository.SearchResults
|
||||
import com.InfernalAquatics.deepwave.ui.components.AlbumCard
|
||||
import com.InfernalAquatics.deepwave.ui.components.ArtistCard
|
||||
import com.InfernalAquatics.deepwave.ui.components.TrackRow
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
onArtistClick: (String) -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SearchViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
SearchContent(
|
||||
uiState = uiState,
|
||||
onQueryChange = viewModel::onQueryChange,
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchContent(
|
||||
uiState: SearchUiState,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
OutlinedTextField(
|
||||
value = uiState.query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text(stringResource(R.string.search_hint)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
val results = uiState.results
|
||||
when {
|
||||
uiState.isLoading -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
uiState.errorMessage != null -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = uiState.errorMessage, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
results != null && results.artists.isEmpty() && results.albums.isEmpty() && results.songs.isEmpty() ->
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = stringResource(R.string.search_no_results), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
results != null -> SearchResultsList(results = results, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResultsList(
|
||||
results: SearchResults,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
if (results.artists.isNotEmpty()) {
|
||||
item { SectionHeader(stringResource(R.string.search_section_artists)) }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(results.artists, key = { it.id }) { artist ->
|
||||
ArtistCard(
|
||||
name = artist.name,
|
||||
coverArtUrl = coverArtUrl(artist.coverArtId),
|
||||
onClick = { onArtistClick(artist.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (results.albums.isNotEmpty()) {
|
||||
item { SectionHeader(stringResource(R.string.search_section_albums)) }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(results.albums, key = { it.id }) { album ->
|
||||
AlbumCard(
|
||||
title = album.name,
|
||||
subtitle = album.artistName,
|
||||
coverArtUrl = coverArtUrl(album.coverArtId),
|
||||
onClick = { onAlbumClick(album.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (results.songs.isNotEmpty()) {
|
||||
item { SectionHeader(stringResource(R.string.search_section_songs)) }
|
||||
items(results.songs, key = { it.id }) { song ->
|
||||
TrackRow(
|
||||
title = song.title,
|
||||
subtitle = song.artistName.orEmpty(),
|
||||
coverArtUrl = coverArtUrl(song.coverArtId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private val sampleResults = SearchResults(
|
||||
artists = List(3) { Artist(id = "artist-$it", name = "Sample Artist ${it + 1}", albumCount = 2, coverArtId = null) },
|
||||
albums = List(3) { Album(id = "album-$it", name = "Sample Album ${it + 1}", artistName = "Sample Artist", artistId = null, coverArtId = null, songCount = 8, year = 2023) },
|
||||
songs = List(3) { Song(id = "song-$it", title = "Sample Track ${it + 1}", albumName = "Sample Album", artistName = "Sample Artist", albumId = null, artistId = null, coverArtId = null, track = it + 1, durationSeconds = 200) },
|
||||
)
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
|
||||
@Composable
|
||||
private fun SearchContentPreview() {
|
||||
DeepwaveTheme {
|
||||
SearchContent(
|
||||
uiState = SearchUiState(query = "sample", results = sampleResults),
|
||||
onQueryChange = {},
|
||||
onArtistClick = {},
|
||||
onAlbumClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.InfernalAquatics.deepwave.ui.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
|
||||
import com.InfernalAquatics.deepwave.data.repository.SearchResults
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SEARCH_DEBOUNCE_MS = 400L
|
||||
|
||||
data class SearchUiState(
|
||||
val query: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val results: SearchResults? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SearchViewModel @Inject constructor(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(SearchUiState())
|
||||
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
fun onQueryChange(query: String) {
|
||||
_uiState.update { it.copy(query = query, errorMessage = null) }
|
||||
searchJob?.cancel()
|
||||
if (query.isBlank()) {
|
||||
_uiState.update { it.copy(isLoading = false, results = null) }
|
||||
return
|
||||
}
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(SEARCH_DEBOUNCE_MS)
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
try {
|
||||
val results = libraryRepository.search(query)
|
||||
_uiState.update { it.copy(isLoading = false, results = results) }
|
||||
} catch (e: IOException) {
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = "Couldn't reach server") }
|
||||
} catch (e: HttpException) {
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = "Server error: ${e.message()}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.InfernalAquatics.deepwave.ui.library.AlbumDetailScreen
|
||||
import com.InfernalAquatics.deepwave.ui.library.ArtistDetailScreen
|
||||
import com.InfernalAquatics.deepwave.ui.login.LoginScreen
|
||||
import com.InfernalAquatics.deepwave.ui.settings.SettingsScreen
|
||||
|
||||
@@ -41,7 +43,11 @@ fun DeepwaveNavHost(
|
||||
)
|
||||
}
|
||||
composable<Route.Main> {
|
||||
MainScreen(onOpenSettings = { navController.navigate(Route.Settings) })
|
||||
MainScreen(
|
||||
onOpenSettings = { navController.navigate(Route.Settings) },
|
||||
onOpenArtist = { artistId -> navController.navigate(Route.ArtistDetail(artistId)) },
|
||||
onOpenAlbum = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) },
|
||||
)
|
||||
}
|
||||
composable<Route.Settings> {
|
||||
SettingsScreen(
|
||||
@@ -53,6 +59,15 @@ fun DeepwaveNavHost(
|
||||
},
|
||||
)
|
||||
}
|
||||
composable<Route.ArtistDetail> {
|
||||
ArtistDetailScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onAlbumClick = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) },
|
||||
)
|
||||
}
|
||||
composable<Route.AlbumDetail> {
|
||||
AlbumDetailScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,15 +25,19 @@ import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.ui.components.BottomNavBar
|
||||
import com.InfernalAquatics.deepwave.ui.components.BottomTab
|
||||
import com.InfernalAquatics.deepwave.ui.components.MiniPlayerBar
|
||||
import com.InfernalAquatics.deepwave.ui.home.HomeScreen
|
||||
import com.InfernalAquatics.deepwave.ui.library.LibraryScreen
|
||||
import com.InfernalAquatics.deepwave.ui.search.SearchScreen
|
||||
import com.InfernalAquatics.deepwave.ui.library.ArtistsScreen
|
||||
import com.InfernalAquatics.deepwave.ui.library.HomeScreen
|
||||
import com.InfernalAquatics.deepwave.ui.library.SearchScreen
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
/** Hosts the bottom-tab shell (Home / Search / Library) plus the mini-player bar and settings entry point. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen(onOpenSettings: () -> Unit) {
|
||||
fun MainScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenArtist: (String) -> Unit,
|
||||
onOpenAlbum: (String) -> Unit,
|
||||
) {
|
||||
var selectedTab by remember { mutableStateOf(BottomTab.Home) }
|
||||
|
||||
Scaffold(
|
||||
@@ -60,9 +64,9 @@ fun MainScreen(onOpenSettings: () -> Unit) {
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
when (selectedTab) {
|
||||
BottomTab.Home -> HomeScreen()
|
||||
BottomTab.Search -> SearchScreen()
|
||||
BottomTab.Library -> LibraryScreen()
|
||||
BottomTab.Home -> HomeScreen(onAlbumClick = onOpenAlbum)
|
||||
BottomTab.Search -> SearchScreen(onArtistClick = onOpenArtist, onAlbumClick = onOpenAlbum)
|
||||
BottomTab.Library -> ArtistsScreen(onArtistClick = onOpenArtist)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,6 +77,6 @@ fun MainScreen(onOpenSettings: () -> Unit) {
|
||||
@Composable
|
||||
private fun MainScreenPreview() {
|
||||
DeepwaveTheme {
|
||||
MainScreen(onOpenSettings = {})
|
||||
MainScreen(onOpenSettings = {}, onOpenArtist = {}, onOpenAlbum = {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,6 @@ sealed interface Route {
|
||||
@Serializable data object Login : Route
|
||||
@Serializable data object Main : Route
|
||||
@Serializable data object Settings : Route
|
||||
@Serializable data class ArtistDetail(val artistId: String) : Route
|
||||
@Serializable data class AlbumDetail(val albumId: String) : Route
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.InfernalAquatics.deepwave.ui.search
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
/** Placeholder; real search UI arrives in Phase 3. */
|
||||
@Composable
|
||||
fun SearchScreen(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = stringResource(R.string.search_placeholder), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
|
||||
@Composable
|
||||
private fun SearchScreenPreview() {
|
||||
DeepwaveTheme {
|
||||
SearchScreen()
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,16 @@
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="settings_log_out">Log out</string>
|
||||
|
||||
<string name="search_placeholder">Search coming soon</string>
|
||||
<string name="library_placeholder">Your library coming soon</string>
|
||||
|
||||
<string name="player_nothing_playing">Nothing playing</string>
|
||||
<string name="player_play">Play</string>
|
||||
|
||||
<string name="home_row_recently_added">Recently Added</string>
|
||||
<string name="home_row_recently_played">Recently Played</string>
|
||||
<string name="home_row_made_for_you">Made For You</string>
|
||||
|
||||
<string name="search_hint">Artists, albums, songs</string>
|
||||
<string name="search_no_results">No results</string>
|
||||
<string name="search_section_artists">Artists</string>
|
||||
<string name="search_section_albums">Albums</string>
|
||||
<string name="search_section_songs">Songs</string>
|
||||
</resources>
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.InfernalAquatics.deepwave.data.network
|
||||
|
||||
import com.InfernalAquatics.deepwave.data.security.CredentialsStore
|
||||
import com.InfernalAquatics.deepwave.data.security.ServerCredentials
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import mockwebserver3.MockResponse
|
||||
import mockwebserver3.MockWebServer
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
/**
|
||||
* Covers the URL-rewriting and auth-signing logic every Subsonic request goes through.
|
||||
* [CredentialsStore] is mocked rather than constructed for real: its `credentials` property
|
||||
* eagerly touches Preferences DataStore in the constructor body (not lazily), which needs a
|
||||
* working Android Context unavailable in a plain JVM unit test. Mockito's class mock never
|
||||
* runs the real constructor, so this sidesteps that entirely - only [CredentialsStore.currentForRequest]
|
||||
* (the one method the interceptor actually calls) needs stubbing.
|
||||
*/
|
||||
class SubsonicRequestInterceptorTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: OkHttpClient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
|
||||
val credentialsStore = Mockito.mock(CredentialsStore::class.java)
|
||||
val credentials = ServerCredentials(
|
||||
serverUrl = server.url("/music/").toString(),
|
||||
username = "alex",
|
||||
password = "hunter2",
|
||||
)
|
||||
runBlocking {
|
||||
Mockito.`when`(credentialsStore.currentForRequest()).thenReturn(credentials)
|
||||
}
|
||||
|
||||
client = OkHttpClient.Builder()
|
||||
.addInterceptor(SubsonicRequestInterceptor(credentialsStore))
|
||||
.build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rewrites the placeholder host onto the saved server URL, preserving its path prefix`() {
|
||||
server.enqueue(MockResponse.Builder().body("{}").build())
|
||||
|
||||
client.newCall(Request.Builder().url("http://localhost/rest/ping").build()).execute().use { }
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
assertEquals("/music/rest/ping", recorded.url.encodedPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signs every request with the Subsonic token-auth query params`() {
|
||||
server.enqueue(MockResponse.Builder().body("{}").build())
|
||||
|
||||
client.newCall(Request.Builder().url("http://localhost/rest/ping").build()).execute().use { }
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
assertEquals("alex", recorded.url.queryParameter("u"))
|
||||
assertEquals("Deepwave", recorded.url.queryParameter("c"))
|
||||
assertEquals("json", recorded.url.queryParameter("f"))
|
||||
assertNotNull(recorded.url.queryParameter("t"))
|
||||
assertNotNull(recorded.url.queryParameter("s"))
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package com.InfernalAquatics.deepwave.data.repository
|
||||
|
||||
import com.InfernalAquatics.deepwave.data.network.SubsonicApi
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import mockwebserver3.MockResponse
|
||||
import mockwebserver3.MockWebServer
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import retrofit2.Retrofit
|
||||
|
||||
/**
|
||||
* Covers LibraryRepository's Subsonic-DTO-to-domain-model mapping against a real (mocked)
|
||||
* HTTP server, bypassing Hilt/DI and [com.InfernalAquatics.deepwave.data.network.SubsonicRequestInterceptor]
|
||||
* entirely - just Retrofit talking to [MockWebServer] like it would to a real Navidrome instance.
|
||||
*/
|
||||
class LibraryRepositoryTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var repository: LibraryRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(server.url("/"))
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
repository = LibraryRepository(retrofit.create(SubsonicApi::class.java))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getArtists flattens the indexed artist list`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse.Builder()
|
||||
.body(
|
||||
"""
|
||||
{"subsonic-response":{"status":"ok","version":"1.16.1","artists":{"index":[
|
||||
{"name":"A","artist":[{"id":"1","name":"AC Slater","albumCount":1,"coverArt":"ar-1"}]},
|
||||
{"name":"C","artist":[{"id":"2","name":"Calvin Harris","albumCount":2}]}
|
||||
]}}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
val artists = repository.getArtists()
|
||||
|
||||
assertEquals(2, artists.size)
|
||||
assertEquals("AC Slater", artists[0].name)
|
||||
assertEquals("ar-1", artists[0].coverArtId)
|
||||
assertEquals(2, artists[1].albumCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAlbumList maps album summaries`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse.Builder()
|
||||
.body(
|
||||
"""
|
||||
{"subsonic-response":{"status":"ok","version":"1.16.1","albumList2":{"album":[
|
||||
{"id":"10","name":"Funk Wav Bounces","artist":"Calvin Harris","artistId":"2","coverArt":"al-10","songCount":12,"duration":2400,"year":2017}
|
||||
]}}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
val albums = repository.getAlbumList(type = AlbumListType.NEWEST)
|
||||
|
||||
assertEquals(1, albums.size)
|
||||
assertEquals("Funk Wav Bounces", albums[0].name)
|
||||
assertEquals("Calvin Harris", albums[0].artistName)
|
||||
assertEquals(2017, albums[0].year)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAlbumDetail returns the album and its songs`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse.Builder()
|
||||
.body(
|
||||
"""
|
||||
{"subsonic-response":{"status":"ok","version":"1.16.1","album":{
|
||||
"id":"10","name":"Funk Wav Bounces","artist":"Calvin Harris","artistId":"2",
|
||||
"songCount":1,"duration":200,"year":2017,
|
||||
"song":[{"id":"100","title":"Slide","album":"Funk Wav Bounces","artist":"Calvin Harris","albumId":"10","artistId":"2","track":1,"duration":200}]
|
||||
}}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
val (album, songs) = repository.getAlbumDetail("10")
|
||||
|
||||
assertEquals("Funk Wav Bounces", album.name)
|
||||
assertEquals(1, songs.size)
|
||||
assertEquals("Slide", songs[0].title)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException::class)
|
||||
fun `getAlbumDetail throws with the server's error message when the album is missing`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse.Builder()
|
||||
.body("""{"subsonic-response":{"status":"failed","version":"1.16.1","error":{"code":70,"message":"Album not found"}}}""")
|
||||
.build(),
|
||||
)
|
||||
|
||||
repository.getAlbumDetail("missing")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search maps artists, albums, and songs from a single response`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse.Builder()
|
||||
.body(
|
||||
"""
|
||||
{"subsonic-response":{"status":"ok","version":"1.16.1","searchResult3":{
|
||||
"artist":[{"id":"2","name":"Calvin Harris","albumCount":2}],
|
||||
"album":[{"id":"10","name":"Funk Wav Bounces","artist":"Calvin Harris"}],
|
||||
"song":[{"id":"100","title":"Slide","artist":"Calvin Harris"}]
|
||||
}}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
val results = repository.search("calvin")
|
||||
|
||||
assertEquals(1, results.artists.size)
|
||||
assertEquals(1, results.albums.size)
|
||||
assertEquals(1, results.songs.size)
|
||||
assertEquals("Slide", results.songs[0].title)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ kotlinxSerializationJson = "1.9.0"
|
||||
retrofitKotlinxSerializationConverter = "1.0.0"
|
||||
androidxDatastorePreferences = "1.2.1"
|
||||
tink = "1.23.0"
|
||||
coil = "3.3.0"
|
||||
mockitoCore = "5.23.0"
|
||||
kotlinxCoroutinesTest = "1.11.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -47,6 +50,11 @@ okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-i
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDatastorePreferences" }
|
||||
tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" }
|
||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
|
||||
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver3", version.ref = "okhttp" }
|
||||
mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockitoCore" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user