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.androidx.datastore.preferences)
|
||||||
implementation(libs.tink.android)
|
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.junit)
|
||||||
|
testImplementation(libs.okhttp.mockwebserver)
|
||||||
|
testImplementation(libs.mockito.core)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
package com.InfernalAquatics.deepwave
|
package com.InfernalAquatics.deepwave
|
||||||
|
|
||||||
import android.app.Application
|
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
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
@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
|
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.PingResponse
|
||||||
|
import com.InfernalAquatics.deepwave.data.network.model.SearchResult3Response
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subsonic API surface implemented by Navidrome. The base URL is a placeholder
|
* Subsonic API surface implemented by Navidrome. The base URL is a placeholder
|
||||||
@@ -12,4 +18,28 @@ import retrofit2.http.GET
|
|||||||
interface SubsonicApi {
|
interface SubsonicApi {
|
||||||
@GET("rest/ping")
|
@GET("rest/ping")
|
||||||
suspend fun ping(): PingResponse
|
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]
|
* 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
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@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,
|
title: String,
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
coverArtUrl: String? = null,
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -28,7 +29,7 @@ fun AlbumCard(
|
|||||||
.width(AlbumCardWidth)
|
.width(AlbumCardWidth)
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
) {
|
) {
|
||||||
ArtworkPlaceholder(size = AlbumCardWidth)
|
Artwork(imageUrl = coverArtUrl, size = AlbumCardWidth)
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ private val ArtistCardWidth = 110.dp
|
|||||||
fun ArtistCard(
|
fun ArtistCard(
|
||||||
name: String,
|
name: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
coverArtUrl: String? = null,
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -30,7 +31,7 @@ fun ArtistCard(
|
|||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
ArtworkPlaceholder(size = ArtistCardWidth, circular = true)
|
Artwork(imageUrl = coverArtUrl, size = ArtistCardWidth, circular = true)
|
||||||
Text(
|
Text(
|
||||||
text = name,
|
text = name,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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,
|
title: String,
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
coverArtUrl: String? = null,
|
||||||
|
circular: Boolean = false,
|
||||||
trailingContent: (@Composable RowScope.() -> Unit)? = null,
|
trailingContent: (@Composable RowScope.() -> Unit)? = null,
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
@@ -32,7 +34,7 @@ fun TrackRow(
|
|||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
ArtworkPlaceholder(size = 48.dp)
|
Artwork(imageUrl = coverArtUrl, size = 48.dp, circular = circular)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.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.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
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.login.LoginScreen
|
||||||
import com.InfernalAquatics.deepwave.ui.settings.SettingsScreen
|
import com.InfernalAquatics.deepwave.ui.settings.SettingsScreen
|
||||||
|
|
||||||
@@ -41,7 +43,11 @@ fun DeepwaveNavHost(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable<Route.Main> {
|
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> {
|
composable<Route.Settings> {
|
||||||
SettingsScreen(
|
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.BottomNavBar
|
||||||
import com.InfernalAquatics.deepwave.ui.components.BottomTab
|
import com.InfernalAquatics.deepwave.ui.components.BottomTab
|
||||||
import com.InfernalAquatics.deepwave.ui.components.MiniPlayerBar
|
import com.InfernalAquatics.deepwave.ui.components.MiniPlayerBar
|
||||||
import com.InfernalAquatics.deepwave.ui.home.HomeScreen
|
import com.InfernalAquatics.deepwave.ui.library.ArtistsScreen
|
||||||
import com.InfernalAquatics.deepwave.ui.library.LibraryScreen
|
import com.InfernalAquatics.deepwave.ui.library.HomeScreen
|
||||||
import com.InfernalAquatics.deepwave.ui.search.SearchScreen
|
import com.InfernalAquatics.deepwave.ui.library.SearchScreen
|
||||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||||
|
|
||||||
/** Hosts the bottom-tab shell (Home / Search / Library) plus the mini-player bar and settings entry point. */
|
/** Hosts the bottom-tab shell (Home / Search / Library) plus the mini-player bar and settings entry point. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(onOpenSettings: () -> Unit) {
|
fun MainScreen(
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenArtist: (String) -> Unit,
|
||||||
|
onOpenAlbum: (String) -> Unit,
|
||||||
|
) {
|
||||||
var selectedTab by remember { mutableStateOf(BottomTab.Home) }
|
var selectedTab by remember { mutableStateOf(BottomTab.Home) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -60,9 +64,9 @@ fun MainScreen(onOpenSettings: () -> Unit) {
|
|||||||
.padding(innerPadding),
|
.padding(innerPadding),
|
||||||
) {
|
) {
|
||||||
when (selectedTab) {
|
when (selectedTab) {
|
||||||
BottomTab.Home -> HomeScreen()
|
BottomTab.Home -> HomeScreen(onAlbumClick = onOpenAlbum)
|
||||||
BottomTab.Search -> SearchScreen()
|
BottomTab.Search -> SearchScreen(onArtistClick = onOpenArtist, onAlbumClick = onOpenAlbum)
|
||||||
BottomTab.Library -> LibraryScreen()
|
BottomTab.Library -> ArtistsScreen(onArtistClick = onOpenArtist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,6 +77,6 @@ fun MainScreen(onOpenSettings: () -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MainScreenPreview() {
|
private fun MainScreenPreview() {
|
||||||
DeepwaveTheme {
|
DeepwaveTheme {
|
||||||
MainScreen(onOpenSettings = {})
|
MainScreen(onOpenSettings = {}, onOpenArtist = {}, onOpenAlbum = {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ sealed interface Route {
|
|||||||
@Serializable data object Login : Route
|
@Serializable data object Login : Route
|
||||||
@Serializable data object Main : Route
|
@Serializable data object Main : Route
|
||||||
@Serializable data object Settings : 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_title">Settings</string>
|
||||||
<string name="settings_log_out">Log out</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_nothing_playing">Nothing playing</string>
|
||||||
<string name="player_play">Play</string>
|
<string name="player_play">Play</string>
|
||||||
|
|
||||||
<string name="home_row_recently_added">Recently Added</string>
|
<string name="home_row_recently_added">Recently Added</string>
|
||||||
<string name="home_row_recently_played">Recently Played</string>
|
<string name="home_row_recently_played">Recently Played</string>
|
||||||
<string name="home_row_made_for_you">Made For You</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>
|
</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"
|
retrofitKotlinxSerializationConverter = "1.0.0"
|
||||||
androidxDatastorePreferences = "1.2.1"
|
androidxDatastorePreferences = "1.2.1"
|
||||||
tink = "1.23.0"
|
tink = "1.23.0"
|
||||||
|
coil = "3.3.0"
|
||||||
|
mockitoCore = "5.23.0"
|
||||||
|
kotlinxCoroutinesTest = "1.11.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
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" }
|
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" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user