From 08b3a9a0dfa7c7e72549edbacb50e2183eb3cd50 Mon Sep 17 00:00:00 2001 From: christopher Date: Thu, 17 Sep 2026 03:59:53 -0400 Subject: [PATCH] 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 --- .idea/deviceManager.xml | 13 ++ app/build.gradle.kts | 7 + .../deepwave/DeepwaveApplication.kt | 22 ++- .../deepwave/data/model/LibraryModels.kt | 32 ++++ .../deepwave/data/network/CoverArtUrls.kt | 12 ++ .../deepwave/data/network/SubsonicApi.kt | 30 +++ .../data/network/model/AlbumModels.kt | 56 ++++++ .../data/network/model/ArtistModels.kt | 54 ++++++ .../data/network/model/SearchModels.kt | 22 +++ .../deepwave/data/network/model/SongModels.kt | 16 ++ .../data/repository/LibraryRepository.kt | 102 ++++++++++ .../deepwave/di/NetworkModule.kt | 6 +- .../deepwave/di/OkHttpClientEntryPoint.kt | 17 ++ .../deepwave/ui/components/AlbumCard.kt | 3 +- .../deepwave/ui/components/ArtistCard.kt | 3 +- .../deepwave/ui/components/Artwork.kt | 51 +++++ .../deepwave/ui/components/TrackRow.kt | 4 +- .../deepwave/ui/home/HomeScreen.kt | 68 ------- .../deepwave/ui/library/AlbumDetailScreen.kt | 141 ++++++++++++++ .../ui/library/AlbumDetailViewModel.kt | 57 ++++++ .../deepwave/ui/library/ArtistDetailScreen.kt | 109 +++++++++++ .../ui/library/ArtistDetailViewModel.kt | 57 ++++++ .../deepwave/ui/library/ArtistsScreen.kt | 72 +++++++ .../deepwave/ui/library/ArtistsViewModel.kt | 47 +++++ .../deepwave/ui/library/HomeScreen.kt | 131 +++++++++++++ .../deepwave/ui/library/HomeViewModel.kt | 59 ++++++ .../deepwave/ui/library/LibraryScreen.kt | 30 --- .../deepwave/ui/library/SearchScreen.kt | 175 ++++++++++++++++++ .../deepwave/ui/library/SearchViewModel.kt | 57 ++++++ .../deepwave/ui/navigation/DeepwaveNavHost.kt | 17 +- .../deepwave/ui/navigation/MainScreen.kt | 20 +- .../deepwave/ui/navigation/Route.kt | 2 + .../deepwave/ui/search/SearchScreen.kt | 30 --- app/src/main/res/values/strings.xml | 9 +- .../network/SubsonicRequestInterceptorTest.kt | 78 ++++++++ .../data/repository/LibraryRepositoryTest.kt | 145 +++++++++++++++ gradle/libs.versions.toml | 8 + 37 files changed, 1616 insertions(+), 146 deletions(-) create mode 100644 .idea/deviceManager.xml create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/model/LibraryModels.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/network/CoverArtUrls.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/AlbumModels.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/ArtistModels.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SearchModels.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SongModels.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/repository/LibraryRepository.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/di/OkHttpClientEntryPoint.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/components/Artwork.kt delete mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/home/HomeScreen.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistDetailScreen.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistDetailViewModel.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistsScreen.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistsViewModel.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/library/HomeScreen.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/library/HomeViewModel.kt delete mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/library/LibraryScreen.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchScreen.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchViewModel.kt delete mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/search/SearchScreen.kt create mode 100644 app/src/test/java/com/InfernalAquatics/deepwave/data/network/SubsonicRequestInterceptorTest.kt create mode 100644 app/src/test/java/com/InfernalAquatics/deepwave/data/repository/LibraryRepositoryTest.kt diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bdc7016..1a249dd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt b/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt index c88815d..b9201f0 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt @@ -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() + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/model/LibraryModels.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/model/LibraryModels.kt new file mode 100644 index 0000000..8cd9ee9 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/model/LibraryModels.kt @@ -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?, +) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/CoverArtUrls.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/CoverArtUrls.kt new file mode 100644 index 0000000..7cd90b3 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/CoverArtUrls.kt @@ -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" } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt index b647ec7..ce805a9 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt @@ -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 } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/AlbumModels.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/AlbumModels.kt new file mode 100644 index 0000000..093ee7b --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/AlbumModels.kt @@ -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 = 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 = emptyList(), +) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/ArtistModels.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/ArtistModels.kt new file mode 100644 index 0000000..1da8862 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/ArtistModels.kt @@ -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 = emptyList(), +) + +@Serializable +data class ArtistIndexEntryDto( + val name: String, + val artist: List = 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 = emptyList(), +) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SearchModels.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SearchModels.kt new file mode 100644 index 0000000..4157b11 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SearchModels.kt @@ -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 = emptyList(), + val album: List = emptyList(), + val song: List = emptyList(), +) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SongModels.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SongModels.kt new file mode 100644 index 0000000..639ace8 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SongModels.kt @@ -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, +) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/repository/LibraryRepository.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/repository/LibraryRepository.kt new file mode 100644 index 0000000..89e1800 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/repository/LibraryRepository.kt @@ -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, + val albums: List, + val songs: List, +) + +/** 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 = + subsonicApi.getArtists().subsonicResponse.artists?.index.orEmpty() + .flatMap { it.artist } + .map { it.toDomain() } + + suspend fun getArtistDetail(artistId: String): Pair> { + 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 = + subsonicApi.getAlbumList2(type = type, size = size).subsonicResponse.albumList2?.album.orEmpty() + .map { it.toDomain() } + + suspend fun getAlbumDetail(albumId: String): Pair> { + 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, +) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/di/NetworkModule.kt b/app/src/main/java/com/InfernalAquatics/deepwave/di/NetworkModule.kt index d86ae77..d7e6c38 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/di/NetworkModule.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/di/NetworkModule.kt @@ -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) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/di/OkHttpClientEntryPoint.kt b/app/src/main/java/com/InfernalAquatics/deepwave/di/OkHttpClientEntryPoint.kt new file mode 100644 index 0000000..94674d6 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/di/OkHttpClientEntryPoint.kt @@ -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 +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/AlbumCard.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/AlbumCard.kt index 66059f6..5f3fa95 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/AlbumCard.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/AlbumCard.kt @@ -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, diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/ArtistCard.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/ArtistCard.kt index 4a50224..9d01d7f 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/ArtistCard.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/ArtistCard.kt @@ -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, diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/Artwork.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/Artwork.kt new file mode 100644 index 0000000..ae2bf1a --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/Artwork.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/TrackRow.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/TrackRow.kt index a3a8358..7bdf348 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/TrackRow.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/TrackRow.kt @@ -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) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/home/HomeScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/home/HomeScreen.kt deleted file mode 100644 index 1b39e05..0000000 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/home/HomeScreen.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt new file mode 100644 index 0000000..bb88a6a --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt @@ -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 = {}, + ) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt new file mode 100644 index 0000000..13fbbba --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt @@ -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 = emptyList(), + val errorMessage: String? = null, +) + +@HiltViewModel +class AlbumDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val libraryRepository: LibraryRepository, +) : ViewModel() { + private val albumId = savedStateHandle.toRoute().albumId + + private val _uiState = MutableStateFlow(AlbumDetailUiState()) + val uiState: StateFlow = _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) } + } + } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistDetailScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistDetailScreen.kt new file mode 100644 index 0000000..ce7a7bd --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistDetailScreen.kt @@ -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 = {}, + ) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistDetailViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistDetailViewModel.kt new file mode 100644 index 0000000..34217fa --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistDetailViewModel.kt @@ -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 = emptyList(), + val errorMessage: String? = null, +) + +@HiltViewModel +class ArtistDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val libraryRepository: LibraryRepository, +) : ViewModel() { + private val artistId = savedStateHandle.toRoute().artistId + + private val _uiState = MutableStateFlow(ArtistDetailUiState()) + val uiState: StateFlow = _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) } + } + } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistsScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistsScreen.kt new file mode 100644 index 0000000..2d9f2e4 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistsScreen.kt @@ -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 = {}) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistsViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistsViewModel.kt new file mode 100644 index 0000000..2994f18 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistsViewModel.kt @@ -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 = emptyList(), + val errorMessage: String? = null, +) + +@HiltViewModel +class ArtistsViewModel @Inject constructor( + private val libraryRepository: LibraryRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(ArtistsUiState()) + val uiState: StateFlow = _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()}") } + } + } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/HomeScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/HomeScreen.kt new file mode 100644 index 0000000..4aed5e7 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/HomeScreen.kt @@ -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, 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 = {}) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/HomeViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/HomeViewModel.kt new file mode 100644 index 0000000..6edbdd8 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/HomeViewModel.kt @@ -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 = emptyList(), + val recentlyPlayed: List = emptyList(), + val madeForYou: List = emptyList(), + val errorMessage: String? = null, +) + +@HiltViewModel +class HomeViewModel @Inject constructor( + private val libraryRepository: LibraryRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(HomeUiState()) + val uiState: StateFlow = _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()}") } + } + } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/LibraryScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/LibraryScreen.kt deleted file mode 100644 index a8546ed..0000000 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/LibraryScreen.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchScreen.kt new file mode 100644 index 0000000..ec460e6 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchScreen.kt @@ -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 = {}, + ) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchViewModel.kt new file mode 100644 index 0000000..5120fbb --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchViewModel.kt @@ -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 = _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()}") } + } + } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt index 22d41f7..1318cad 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt @@ -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 { - 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 { SettingsScreen( @@ -53,6 +59,15 @@ fun DeepwaveNavHost( }, ) } + composable { + ArtistDetailScreen( + onBack = { navController.popBackStack() }, + onAlbumClick = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) }, + ) + } + composable { + AlbumDetailScreen(onBack = { navController.popBackStack() }) + } } } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt index 112b648..8aa96e7 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt @@ -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 = {}) } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt index 2ddc217..935d09e 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt @@ -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 } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/search/SearchScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/search/SearchScreen.kt deleted file mode 100644 index c87b14b..0000000 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/search/SearchScreen.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 94d668c..76e8949 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,13 +18,16 @@ Settings Log out - Search coming soon - Your library coming soon - Nothing playing Play Recently Added Recently Played Made For You + + Artists, albums, songs + No results + Artists + Albums + Songs diff --git a/app/src/test/java/com/InfernalAquatics/deepwave/data/network/SubsonicRequestInterceptorTest.kt b/app/src/test/java/com/InfernalAquatics/deepwave/data/network/SubsonicRequestInterceptorTest.kt new file mode 100644 index 0000000..d8c94e2 --- /dev/null +++ b/app/src/test/java/com/InfernalAquatics/deepwave/data/network/SubsonicRequestInterceptorTest.kt @@ -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")) + } +} diff --git a/app/src/test/java/com/InfernalAquatics/deepwave/data/repository/LibraryRepositoryTest.kt b/app/src/test/java/com/InfernalAquatics/deepwave/data/repository/LibraryRepositoryTest.kt new file mode 100644 index 0000000..5954a31 --- /dev/null +++ b/app/src/test/java/com/InfernalAquatics/deepwave/data/repository/LibraryRepositoryTest.kt @@ -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) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 193b31d..bd44a16 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }