diff --git a/app/src/main/java/org/listenbrainz/android/di/PlaylistRepositoryModule.kt b/app/src/main/java/org/listenbrainz/android/di/PlaylistRepositoryModule.kt new file mode 100644 index 00000000..fd2e1ace --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/di/PlaylistRepositoryModule.kt @@ -0,0 +1,16 @@ +package org.listenbrainz.android.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.listenbrainz.android.repository.playlists.PlaylistDataRepository +import org.listenbrainz.android.repository.playlists.PlaylistDataRepositoryImpl + +@Module +@InstallIn(SingletonComponent::class) +abstract class PlaylistRepositoryModule { + + @Binds + abstract fun bindsPlaylistRepository(repository: PlaylistDataRepositoryImpl?): PlaylistDataRepository? +} \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/di/ServiceModule.kt b/app/src/main/java/org/listenbrainz/android/di/ServiceModule.kt index e1fdaa49..a971946f 100644 --- a/app/src/main/java/org/listenbrainz/android/di/ServiceModule.kt +++ b/app/src/main/java/org/listenbrainz/android/di/ServiceModule.kt @@ -21,6 +21,7 @@ import org.listenbrainz.android.service.CBService import org.listenbrainz.android.service.FeedService import org.listenbrainz.android.service.ListensService import org.listenbrainz.android.service.MBService +import org.listenbrainz.android.service.PlaylistService import org.listenbrainz.android.service.SocialService import org.listenbrainz.android.service.UserService import org.listenbrainz.android.service.Yim23Service @@ -92,6 +93,12 @@ class ServiceModule { constructRetrofit(appPreferences) .create(UserService::class.java) + @Singleton + @Provides + fun providesPlaylistService(appPreferences: AppPreferences) : PlaylistService = + constructRetrofit(appPreferences) + .create(PlaylistService::class.java) + @Singleton @Provides fun providesArtistService(): ArtistService = Retrofit.Builder() diff --git a/app/src/main/java/org/listenbrainz/android/model/createdForYou/AdditionalMetadata.kt b/app/src/main/java/org/listenbrainz/android/model/createdForYou/AdditionalMetadata.kt new file mode 100644 index 00000000..d4ead820 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/createdForYou/AdditionalMetadata.kt @@ -0,0 +1,11 @@ +package org.listenbrainz.android.model.createdForYou + + +import com.google.gson.annotations.SerializedName + +data class AdditionalMetadata( + @SerializedName("algorithm_metadata") + val algorithmMetadata: AlgorithmMetadata = AlgorithmMetadata(), + @SerializedName("expires_at") + val expiresAt: String? = null, +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/createdForYou/AlgorithmMetadata.kt b/app/src/main/java/org/listenbrainz/android/model/createdForYou/AlgorithmMetadata.kt new file mode 100644 index 00000000..20194229 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/createdForYou/AlgorithmMetadata.kt @@ -0,0 +1,9 @@ +package org.listenbrainz.android.model.createdForYou + + +import com.google.gson.annotations.SerializedName + +data class AlgorithmMetadata( + @SerializedName("source_patch") + val sourcePatch: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/createdForYou/CreatedForYouExtensionData.kt b/app/src/main/java/org/listenbrainz/android/model/createdForYou/CreatedForYouExtensionData.kt new file mode 100644 index 00000000..c9c3e11a --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/createdForYou/CreatedForYouExtensionData.kt @@ -0,0 +1,17 @@ +package org.listenbrainz.android.model.createdForYou + + +import com.google.gson.annotations.SerializedName + +data class CreatedForYouExtensionData( + @SerializedName("additional_metadata") + val additionalMetadata: AdditionalMetadata = AdditionalMetadata(), + @SerializedName("created_for") + val createdFor: String? = null, + @SerializedName("creator") + val creator: String? = null, + @SerializedName("last_modified_at") + val lastModifiedAt: String? = null, + @SerializedName("public") + val public: Boolean? = null +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/createdForYou/CreatedForYouPayload.kt b/app/src/main/java/org/listenbrainz/android/model/createdForYou/CreatedForYouPayload.kt new file mode 100644 index 00000000..761f8b43 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/createdForYou/CreatedForYouPayload.kt @@ -0,0 +1,15 @@ +package org.listenbrainz.android.model.createdForYou + + +import com.google.gson.annotations.SerializedName + +data class CreatedForYouPayload( + @SerializedName("count") + val count: Int? = null, + @SerializedName("offset") + val offset: Int? = null, + @SerializedName("playlist_count") + val playlistCount: Int? = null, + @SerializedName("playlists") + val playlists: List = listOf() +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/createdForYou/CreatedForYouPlaylist.kt b/app/src/main/java/org/listenbrainz/android/model/createdForYou/CreatedForYouPlaylist.kt new file mode 100644 index 00000000..24db54ba --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/createdForYou/CreatedForYouPlaylist.kt @@ -0,0 +1,29 @@ +package org.listenbrainz.android.model.createdForYou + + +import com.google.gson.annotations.SerializedName + +data class CreatedForYouPlaylist( + @SerializedName("annotation") + val annotation: String? = null, + @SerializedName("creator") + val creator: String? = null, + @SerializedName("date") + val date: String? = null, + @SerializedName("extension") + val extension: Extension = Extension(), + @SerializedName("identifier") + val identifier: String? = null, + @SerializedName("title") + val title: String? = null, + @SerializedName("track") + val track: List = listOf() +){ + // Get the MBID of the playlist + fun getPlaylistMBID(): String? { + // Regex to extract the MBID from the identifier + val regex = """playlist/([a-f0-9\-]+)""".toRegex() + val matchResult = identifier?.let { regex.find(it) } + return matchResult?.groupValues?.get(1) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/createdForYou/CreatedForYouPlaylists.kt b/app/src/main/java/org/listenbrainz/android/model/createdForYou/CreatedForYouPlaylists.kt new file mode 100644 index 00000000..ff99e585 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/createdForYou/CreatedForYouPlaylists.kt @@ -0,0 +1,16 @@ +package org.listenbrainz.android.model.createdForYou + + +import com.google.gson.annotations.SerializedName + +data class CreatedForYouPlaylists( + @SerializedName("playlist") + val playlist: CreatedForYouPlaylist = CreatedForYouPlaylist() +){ + fun getPlaylistMBID(): String? { + val url = playlist.identifier + val regex = """playlist/([a-f0-9\-]+)""".toRegex() + val matchResult = url?.let { regex.find(it) } + return matchResult?.groupValues?.get(1) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/createdForYou/Extension.kt b/app/src/main/java/org/listenbrainz/android/model/createdForYou/Extension.kt new file mode 100644 index 00000000..eef4730f --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/createdForYou/Extension.kt @@ -0,0 +1,9 @@ +package org.listenbrainz.android.model.createdForYou + + +import com.google.gson.annotations.SerializedName + +data class Extension( + @SerializedName("https://musicbrainz.org/doc/jspf#playlist") + val createdForYouExtensionData: CreatedForYouExtensionData = CreatedForYouExtensionData() +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/playlist/AdditionalMetadata.kt b/app/src/main/java/org/listenbrainz/android/model/playlist/AdditionalMetadata.kt new file mode 100644 index 00000000..a519eac0 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/playlist/AdditionalMetadata.kt @@ -0,0 +1,11 @@ +package org.listenbrainz.android.model.playlist + + +import com.google.gson.annotations.SerializedName + +data class AdditionalMetadata( + @SerializedName("algorithm_metadata") + val algorithmMetadata: AlgorithmMetadata = AlgorithmMetadata(), + @SerializedName("expires_at") + val expiresAt: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/playlist/AdditionalMetadataTrack.kt b/app/src/main/java/org/listenbrainz/android/model/playlist/AdditionalMetadataTrack.kt new file mode 100644 index 00000000..98e64388 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/playlist/AdditionalMetadataTrack.kt @@ -0,0 +1,13 @@ +package org.listenbrainz.android.model.playlist + + +import com.google.gson.annotations.SerializedName + +data class AdditionalMetadataTrack( + @SerializedName("artists") + val artists: List = listOf(), + @SerializedName("caa_id") + val caaId: Long? = null, + @SerializedName("caa_release_mbid") + val caaReleaseMbid: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/playlist/AlgorithmMetadata.kt b/app/src/main/java/org/listenbrainz/android/model/playlist/AlgorithmMetadata.kt new file mode 100644 index 00000000..7d2eadfd --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/playlist/AlgorithmMetadata.kt @@ -0,0 +1,9 @@ +package org.listenbrainz.android.model.playlist + + +import com.google.gson.annotations.SerializedName + +data class AlgorithmMetadata( + @SerializedName("source_patch") + val sourcePatch: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/playlist/CopyPlaylistResponse.kt b/app/src/main/java/org/listenbrainz/android/model/playlist/CopyPlaylistResponse.kt new file mode 100644 index 00000000..201f3cc7 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/playlist/CopyPlaylistResponse.kt @@ -0,0 +1,11 @@ +package org.listenbrainz.android.model.playlist + + +import com.google.gson.annotations.SerializedName + +data class CopyPlaylistResponse( + @SerializedName("playlist_mbid") + val playlistMbid: String, + @SerializedName("status") + val status: String +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/playlist/Extension.kt b/app/src/main/java/org/listenbrainz/android/model/playlist/Extension.kt new file mode 100644 index 00000000..ba95fb90 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/playlist/Extension.kt @@ -0,0 +1,9 @@ +package org.listenbrainz.android.model.playlist + + +import com.google.gson.annotations.SerializedName + +data class Extension( + @SerializedName("https://musicbrainz.org/doc/jspf#playlist") + val playlistExtensionData: PlaylistExtensionData = PlaylistExtensionData() +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/playlist/PlaylistArtist.kt b/app/src/main/java/org/listenbrainz/android/model/playlist/PlaylistArtist.kt new file mode 100644 index 00000000..2e7b80a9 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/playlist/PlaylistArtist.kt @@ -0,0 +1,13 @@ +package org.listenbrainz.android.model.playlist + + +import com.google.gson.annotations.SerializedName + +data class PlaylistArtist( + @SerializedName("artist_credit_name") + val artistCreditName: String? = null, + @SerializedName("artist_mbid") + val artistMbid: String? = null, + @SerializedName("join_phrase") + val joinPhrase: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/playlist/PlaylistData.kt b/app/src/main/java/org/listenbrainz/android/model/playlist/PlaylistData.kt new file mode 100644 index 00000000..9809b562 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/playlist/PlaylistData.kt @@ -0,0 +1,21 @@ +package org.listenbrainz.android.model.playlist + + +import com.google.gson.annotations.SerializedName + +data class PlaylistData( + @SerializedName("annotation") + val annotation: String? = null, + @SerializedName("creator") + val creator: String? = null, + @SerializedName("date") + val date: String? = null, + @SerializedName("extension") + val extension: Extension = Extension(), + @SerializedName("identifier") + val identifier: String? = null, + @SerializedName("title") + val title: String? = null, + @SerializedName("track") + val track: List = listOf() +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/playlist/PlaylistExtensionData.kt b/app/src/main/java/org/listenbrainz/android/model/playlist/PlaylistExtensionData.kt new file mode 100644 index 00000000..fea3c288 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/playlist/PlaylistExtensionData.kt @@ -0,0 +1,17 @@ +package org.listenbrainz.android.model.playlist + + +import com.google.gson.annotations.SerializedName + +data class PlaylistExtensionData( + @SerializedName("additional_metadata") + val additionalMetadata: AdditionalMetadata = AdditionalMetadata(), + @SerializedName("created_for") + val createdFor: String? = null, + @SerializedName("creator") + val creator: String? = null, + @SerializedName("last_modified_at") + val lastModifiedAt: String? = null, + @SerializedName("public") + val `public`: Boolean? = null +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/playlist/PlaylistPayload.kt b/app/src/main/java/org/listenbrainz/android/model/playlist/PlaylistPayload.kt new file mode 100644 index 00000000..0cd4fa9f --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/playlist/PlaylistPayload.kt @@ -0,0 +1,9 @@ +package org.listenbrainz.android.model.playlist + + +import com.google.gson.annotations.SerializedName + +data class PlaylistPayload( + @SerializedName("playlist") + val playlist: PlaylistData = PlaylistData() +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/playlist/PlaylistTrack.kt b/app/src/main/java/org/listenbrainz/android/model/playlist/PlaylistTrack.kt new file mode 100644 index 00000000..f29b8650 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/playlist/PlaylistTrack.kt @@ -0,0 +1,62 @@ +package org.listenbrainz.android.model.playlist + + +import com.google.gson.annotations.SerializedName +import org.listenbrainz.android.model.AdditionalInfo +import org.listenbrainz.android.model.MbidMapping +import org.listenbrainz.android.model.Metadata +import org.listenbrainz.android.model.TrackMetadata +import org.listenbrainz.android.model.feed.FeedListenArtist + +data class PlaylistTrack( + @SerializedName("album") + val album: String? = null, + @SerializedName("creator") + val creator: String? = null, + @SerializedName("duration") + val duration: Int? = null, + @SerializedName("extension") + val extension: TrackExtension = TrackExtension(), + @SerializedName("identifier") + val identifier: List = listOf(), + @SerializedName("title") + val title: String? = null +){ + fun toMetadata(): Metadata{ + val artistMBID = extension.trackExtensionData.additionalMetadata.artists.map{it.artistMbid?:""} + val artist = extension.trackExtensionData.additionalMetadata.artists + val data = Metadata( + trackMetadata = TrackMetadata( + artistName = creator?:"", + releaseName = album, + trackName = title?:"", + mbidMapping = MbidMapping( + artistMbids = artistMBID, + artists = artist.map { + FeedListenArtist( + it.artistCreditName?:"", + it.artistMbid, + it.joinPhrase + ) + }, + recordingMbid = getRecordingMBID(), + recordingName = title?:"" + ), + additionalInfo = AdditionalInfo( + artistMbids = artistMBID, + artistNames = artist.map { it.artistCreditName?:"" }, + durationMs = duration?:0, + recordingMbid = getRecordingMBID(), + ) + ) + ) + return data + } + + fun getRecordingMBID(): String?{ + val url = identifier.firstOrNull() + val regex = """recording/([a-f0-9\-]+)""".toRegex() + val matchResult = url?.let { regex.find(it) } + return matchResult?.groupValues?.get(1) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/playlist/TrackExtension.kt b/app/src/main/java/org/listenbrainz/android/model/playlist/TrackExtension.kt new file mode 100644 index 00000000..03ba8992 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/playlist/TrackExtension.kt @@ -0,0 +1,9 @@ +package org.listenbrainz.android.model.playlist + + +import com.google.gson.annotations.SerializedName + +data class TrackExtension( + @SerializedName("https://musicbrainz.org/doc/jspf#track") + val trackExtensionData: TrackExtensionData = TrackExtensionData() +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/model/playlist/TrackExtensionData.kt b/app/src/main/java/org/listenbrainz/android/model/playlist/TrackExtensionData.kt new file mode 100644 index 00000000..6a978a8f --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/model/playlist/TrackExtensionData.kt @@ -0,0 +1,15 @@ +package org.listenbrainz.android.model.playlist + + +import com.google.gson.annotations.SerializedName + +data class TrackExtensionData( + @SerializedName("added_at") + val addedAt: String? = null, + @SerializedName("added_by") + val addedBy: String? = null, + @SerializedName("additional_metadata") + val additionalMetadata: AdditionalMetadataTrack = AdditionalMetadataTrack(), + @SerializedName("artist_identifiers") + val artistIdentifiers: List = listOf() +) \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/repository/playlists/PlaylistDataRepository.kt b/app/src/main/java/org/listenbrainz/android/repository/playlists/PlaylistDataRepository.kt new file mode 100644 index 00000000..5922f2fd --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/repository/playlists/PlaylistDataRepository.kt @@ -0,0 +1,14 @@ +package org.listenbrainz.android.repository.playlists + +import org.listenbrainz.android.model.playlist.CopyPlaylistResponse +import org.listenbrainz.android.model.playlist.PlaylistPayload +import org.listenbrainz.android.util.Resource + +interface PlaylistDataRepository { + + // Fetches the playlist with tracks for the given MBID + suspend fun fetchPlaylist(playlistMbid: String?): Resource + + //Duplicates the playlist with the given MBID and returns the new playlist MBID + suspend fun copyPlaylist(playlistMbid: String?): Resource +} \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/repository/playlists/PlaylistDataRepositoryImpl.kt b/app/src/main/java/org/listenbrainz/android/repository/playlists/PlaylistDataRepositoryImpl.kt new file mode 100644 index 00000000..3321b700 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/repository/playlists/PlaylistDataRepositoryImpl.kt @@ -0,0 +1,25 @@ +package org.listenbrainz.android.repository.playlists + +import jakarta.inject.Inject +import org.listenbrainz.android.model.ResponseError +import org.listenbrainz.android.model.playlist.CopyPlaylistResponse +import org.listenbrainz.android.model.playlist.PlaylistPayload +import org.listenbrainz.android.service.PlaylistService +import org.listenbrainz.android.util.Resource +import org.listenbrainz.android.util.Utils.parseResponse + +class PlaylistDataRepositoryImpl @Inject constructor( + private val playlistService: PlaylistService +) : PlaylistDataRepository { + + override suspend fun fetchPlaylist(playlistMbid: String?): Resource = + parseResponse { + if (playlistMbid.isNullOrEmpty()) return ResponseError.BAD_REQUEST.asResource() + playlistService.getPlaylist(playlistMbid) + } + + override suspend fun copyPlaylist(playlistMbid: String?): Resource = parseResponse { + if (playlistMbid.isNullOrEmpty()) return ResponseError.BAD_REQUEST.asResource() + playlistService.copyPlaylist(playlistMbid) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/repository/user/UserRepository.kt b/app/src/main/java/org/listenbrainz/android/repository/user/UserRepository.kt index 571486df..12041fcc 100644 --- a/app/src/main/java/org/listenbrainz/android/repository/user/UserRepository.kt +++ b/app/src/main/java/org/listenbrainz/android/repository/user/UserRepository.kt @@ -3,6 +3,9 @@ package org.listenbrainz.android.repository.user import org.listenbrainz.android.model.CurrentPins import org.listenbrainz.android.model.Listens import org.listenbrainz.android.model.PinnedRecording +import org.listenbrainz.android.model.createdForYou.CreatedForYouPayload +import org.listenbrainz.android.model.createdForYou.CreatedForYouPlaylist +import org.listenbrainz.android.model.createdForYou.CreatedForYouPlaylists import org.listenbrainz.android.model.user.AllPinnedRecordings import org.listenbrainz.android.model.user.TopAlbums import org.listenbrainz.android.model.user.TopArtists @@ -24,4 +27,5 @@ interface UserRepository { suspend fun getGlobalListeningActivity(rangeString: String = "all_time"): Resource suspend fun getTopAlbums(username: String?, rangeString: String = "all_time" ,count: Int = 25): Resource suspend fun getTopSongs(username: String?, rangeString: String = "all_time"): Resource + suspend fun getCreatedForYouPlaylists(username: String?): Resource } \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/repository/user/UserRepositoryImpl.kt b/app/src/main/java/org/listenbrainz/android/repository/user/UserRepositoryImpl.kt index 1b96747f..668e2851 100644 --- a/app/src/main/java/org/listenbrainz/android/repository/user/UserRepositoryImpl.kt +++ b/app/src/main/java/org/listenbrainz/android/repository/user/UserRepositoryImpl.kt @@ -4,6 +4,7 @@ import org.listenbrainz.android.model.CurrentPins import org.listenbrainz.android.model.Listens import org.listenbrainz.android.model.PinnedRecording import org.listenbrainz.android.model.ResponseError +import org.listenbrainz.android.model.createdForYou.CreatedForYouPayload import org.listenbrainz.android.model.user.AllPinnedRecordings import org.listenbrainz.android.model.user.TopAlbums import org.listenbrainz.android.model.user.TopArtists @@ -80,4 +81,11 @@ class UserRepositoryImpl @Inject constructor( service.getTopSongsOfUser(username, rangeString) } + override suspend fun getCreatedForYouPlaylists(username: String?): Resource { + return parseResponse { + if(username.isNullOrEmpty()) return ResponseError.BAD_REQUEST.asResource() + service.getCreatedForYouPlaylists(username) + } + } + } \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/service/PlaylistService.kt b/app/src/main/java/org/listenbrainz/android/service/PlaylistService.kt new file mode 100644 index 00000000..35945a56 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/service/PlaylistService.kt @@ -0,0 +1,17 @@ +package org.listenbrainz.android.service + +import org.listenbrainz.android.model.playlist.CopyPlaylistResponse +import org.listenbrainz.android.model.playlist.PlaylistPayload +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +interface PlaylistService { + + @GET("playlist/{playlist_mbid}") + suspend fun getPlaylist(@Path("playlist_mbid") playlistMbid: String): Response + + @POST("playlist/{playlist_mbid}/copy") + suspend fun copyPlaylist(@Path("playlist_mbid") playlistMbid: String): Response +} \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/service/UserService.kt b/app/src/main/java/org/listenbrainz/android/service/UserService.kt index 641932a7..ba02590b 100644 --- a/app/src/main/java/org/listenbrainz/android/service/UserService.kt +++ b/app/src/main/java/org/listenbrainz/android/service/UserService.kt @@ -2,6 +2,7 @@ package org.listenbrainz.android.service import org.listenbrainz.android.model.CurrentPins import org.listenbrainz.android.model.Listens +import org.listenbrainz.android.model.createdForYou.CreatedForYouPayload import org.listenbrainz.android.model.user.AllPinnedRecordings import org.listenbrainz.android.model.user.TopAlbums import org.listenbrainz.android.model.user.TopArtists @@ -45,4 +46,6 @@ interface UserService { @GET("stats/user/{user_name}/recordings") suspend fun getTopSongsOfUser(@Path("user_name") username: String?, @Query("range") rangeString: String?): Response + @GET("user/{user_name}/playlists/createdfor") + suspend fun getCreatedForYouPlaylists(@Path("user_name") username: String?): Response } \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/ui/navigation/BottomNavigationBar.kt b/app/src/main/java/org/listenbrainz/android/ui/navigation/BottomNavigationBar.kt index 0b22f0b3..14a092fa 100644 --- a/app/src/main/java/org/listenbrainz/android/ui/navigation/BottomNavigationBar.kt +++ b/app/src/main/java/org/listenbrainz/android/ui/navigation/BottomNavigationBar.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.launch @@ -38,7 +38,7 @@ fun BottomNavigationBar( backgroundColor: Color = ListenBrainzTheme.colorScheme.nav, backdropScaffoldState: BackdropScaffoldState = rememberBackdropScaffoldState(initialValue = BackdropValue.Revealed), scrollToTop: () -> Unit, - username : String?, + username: String?, ) { val items = listOf( AppNavigationItem.Feed, @@ -55,7 +55,8 @@ fun BottomNavigationBar( items.forEach { item -> val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination - val selected = currentDestination?.route?.startsWith("${item.route}/") == true || currentDestination?.route == item.route + val selected = + currentDestination?.route?.startsWith("${item.route}/") == true || currentDestination?.route == item.route BottomNavigationItem( modifier = Modifier.navigationBarsPadding(), icon = { @@ -66,7 +67,9 @@ fun BottomNavigationBar( ?: item.iconUnselected), modifier = Modifier .size(24.dp) - .padding(vertical = 4.dp), contentDescription = item.title, tint = MaterialTheme.colorScheme.onSurface + .padding(vertical = 4.dp), + contentDescription = item.title, + tint = MaterialTheme.colorScheme.onSurface ) }, label = { @@ -91,9 +94,9 @@ fun BottomNavigationBar( when (item.route) { AppNavigationItem.Profile.route -> { - navController.navigate(AppNavigationItem.Profile.route + if (!username.isNullOrBlank()) "/${username}" else ""){ + navController.navigate(AppNavigationItem.Profile.route + if (!username.isNullOrBlank()) "/${username}" else "") { // Avoid building large backstack - popUpTo(AppNavigationItem.Feed.route){ + popUpTo(navController.graph.findStartDestination().id){ if (username.isNullOrBlank()) { inclusive = true } @@ -106,23 +109,18 @@ fun BottomNavigationBar( restoreState = true } } - AppNavigationItem.Feed.route -> { - navController.navigate(AppNavigationItem.Feed.route) - } - else -> navController.navigate(item.route){ - // Avoid building large backstack - popUpTo(AppNavigationItem.Feed.route){ - saveState = true - } - // Avoid copies - launchSingleTop = false - // Restore previous state - restoreState = true + + else -> navController.navigate(item.route) { + popUpTo(navController.graph.findStartDestination().id){ + saveState = true } + // Avoid copies + launchSingleTop = true + // Restore previous state + restoreState = true + } } - } - } ) } @@ -134,5 +132,8 @@ fun BottomNavigationBar( @Preview @Composable fun BottomNavigationBarPreview() { - BottomNavigationBar(navController = rememberNavController() , scrollToTop = {} ,username = "pranavkonidena") + BottomNavigationBar( + navController = rememberNavController(), + scrollToTop = {}, + username = "pranavkonidena") } \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/artist/ArtistScreen.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/artist/ArtistScreen.kt index 55b6adb5..c1f24dd1 100644 --- a/app/src/main/java/org/listenbrainz/android/ui/screens/artist/ArtistScreen.kt +++ b/app/src/main/java/org/listenbrainz/android/ui/screens/artist/ArtistScreen.kt @@ -102,6 +102,7 @@ import org.listenbrainz.android.ui.theme.lb_purple_night import org.listenbrainz.android.ui.theme.new_app_bg_light import org.listenbrainz.android.util.Utils import org.listenbrainz.android.util.Utils.measureSize +import org.listenbrainz.android.util.Utils.removeHtmlTags import org.listenbrainz.android.util.Utils.showToast import org.listenbrainz.android.viewmodel.ArtistViewModel import org.listenbrainz.android.viewmodel.FeedViewModel @@ -1116,12 +1117,6 @@ fun SvgWithWebView(svgContent: String, width: Dp, height: Dp) { ) } -fun removeHtmlTags(input: String): String { - // Regular expression pattern to match HTML tags - val regex = "<[^>]*>".toRegex() - // Replace all matches of the pattern with an empty string - return input.replace(regex, "") -} fun formatNumber(input: Int): String { return when { diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/feed/FeedScreen.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/feed/FeedScreen.kt index ee35a899..222fab2d 100644 --- a/app/src/main/java/org/listenbrainz/android/ui/screens/feed/FeedScreen.kt +++ b/app/src/main/java/org/listenbrainz/android/ui/screens/feed/FeedScreen.kt @@ -193,14 +193,14 @@ fun FeedScreen( .fillMaxSize() .pullRefresh(state = pullRefreshState) ) { - - RetryButton( - modifier = Modifier.align(Alignment.Center), - show = myFeedPagingData.itemCount == 0 && myFeedPagingData.loadState.refresh is LoadState.Error, - ) { - myFeedPagingData.retry() - similarListensPagingData.retry() - followListensPagingData.retry() + if (myFeedPagingData.itemCount == 0 && myFeedPagingData.loadState.refresh is LoadState.Error) { + RetryButton( + modifier = Modifier.align(Alignment.Center), + ) { + myFeedPagingData.retry() + similarListensPagingData.retry() + followListensPagingData.retry() + } } HorizontalPager( @@ -707,19 +707,17 @@ fun StartingSpacer() { } @Composable -private fun RetryButton(modifier: Modifier = Modifier, show: Boolean, onClick: () -> Unit) { - if (show) { - Button( - modifier = modifier, - onClick = onClick, - colors = ButtonDefaults.buttonColors(containerColor = ListenBrainzTheme.colorScheme.lbSignature) - ) { - Text( - text = "Retry", - color = ListenBrainzTheme.colorScheme.onLbSignature, - fontWeight = FontWeight.Medium - ) - } +fun RetryButton(modifier: Modifier = Modifier, onClick: () -> Unit) { + Button( + modifier = modifier, + onClick = onClick, + colors = ButtonDefaults.buttonColors(containerColor = ListenBrainzTheme.colorScheme.lbSignature) + ) { + Text( + text = "Retry", + color = ListenBrainzTheme.colorScheme.onLbSignature, + fontWeight = FontWeight.Medium + ) } } @@ -749,7 +747,6 @@ private fun PagerRearLoadingIndicator(pagingData: LazyPagingItems CreatedForYouScreen( + snackbarState = snackbarState, + userViewModel = viewModel, + goToArtistPage = goToArtistPage, + socialViewModel = socialViewModel + ) } } } diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/LoginActivity.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/LoginActivity.kt index bc88670e..1ebf99ff 100644 --- a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/LoginActivity.kt +++ b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/LoginActivity.kt @@ -39,7 +39,6 @@ import org.listenbrainz.android.ui.components.LoadingAnimation import org.listenbrainz.android.ui.theme.ListenBrainzTheme import org.listenbrainz.android.util.Resource import org.listenbrainz.android.util.Utils.LaunchedEffectUnit -import org.listenbrainz.android.util.Utils.SetSystemBarsForegroundAppearance import org.listenbrainz.android.viewmodel.ListensViewModel import kotlin.time.Duration.Companion.seconds diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/ProfileScreenTab.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/ProfileScreenTab.kt index 058d313f..22da4cc2 100644 --- a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/ProfileScreenTab.kt +++ b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/ProfileScreenTab.kt @@ -3,7 +3,7 @@ package org.listenbrainz.android.ui.screens.profile enum class ProfileScreenTab(val value: String, val index: Int) { LISTENS("Listens", 0), STATS("Stats", 1), - TASTE("Taste", 2) + TASTE("Taste", 2), //PLAYLISTS("Playlists", 3), - //CREATED_FOR_YOU("Created for you", 4), + CREATED_FOR_YOU("Created for you", 3), } diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/ProfileUiState.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/ProfileUiState.kt index f366ed71..1f159a7c 100644 --- a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/ProfileUiState.kt +++ b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/ProfileUiState.kt @@ -5,6 +5,9 @@ import org.listenbrainz.android.model.Listen import org.listenbrainz.android.model.ListenBitmap import org.listenbrainz.android.model.PinnedRecording import org.listenbrainz.android.model.SimilarUser +import org.listenbrainz.android.model.createdForYou.CreatedForYouPlaylists +import org.listenbrainz.android.model.playlist.PlaylistData +import org.listenbrainz.android.model.playlist.PlaylistPayload import org.listenbrainz.android.model.user.AllPinnedRecordings import org.listenbrainz.android.model.user.Artist import org.listenbrainz.android.model.user.ListeningActivity @@ -21,6 +24,7 @@ data class ProfileUiState( val listensTabUiState: ListensTabUiState = ListensTabUiState(), val statsTabUIState: StatsTabUIState = StatsTabUIState(), val tasteTabUIState: TasteTabUIState = TasteTabUIState(), + val createdForTabUIState: CreatedForTabUIState = CreatedForTabUIState() ) data class ListensTabUiState ( @@ -54,6 +58,12 @@ data class StatsTabUIState( val topSongs: Map? = null, ) +data class CreatedForTabUIState( + val isLoading: Boolean = true, + val createdForYouPlaylists: List? = emptyList(), + val createdForYouPlaylistData: Map? = null +) + data class ListeningNowUiState( val listeningNow: Listen? = null, val listeningNowBitmap: ListenBitmap = ListenBitmap(), diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/createdforyou/CreatedForYouScreen.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/createdforyou/CreatedForYouScreen.kt new file mode 100644 index 00000000..b0008efb --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/createdforyou/CreatedForYouScreen.kt @@ -0,0 +1,345 @@ +package org.listenbrainz.android.ui.screens.profile.createdforyou + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +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.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import org.listenbrainz.android.model.SocialUiState +import org.listenbrainz.android.model.createdForYou.CreatedForYouPlaylist +import org.listenbrainz.android.model.createdForYou.CreatedForYouPlaylists +import org.listenbrainz.android.model.playlist.AdditionalMetadataTrack +import org.listenbrainz.android.model.playlist.PlaylistData +import org.listenbrainz.android.model.playlist.PlaylistTrack +import org.listenbrainz.android.model.playlist.TrackExtension +import org.listenbrainz.android.model.playlist.TrackExtensionData +import org.listenbrainz.android.ui.components.ErrorBar +import org.listenbrainz.android.ui.components.ListenCardSmallDefault +import org.listenbrainz.android.ui.components.SuccessBar +import org.listenbrainz.android.ui.screens.feed.RetryButton +import org.listenbrainz.android.ui.screens.profile.CreatedForTabUIState +import org.listenbrainz.android.ui.screens.profile.ProfileUiState +import org.listenbrainz.android.ui.theme.ListenBrainzTheme +import org.listenbrainz.android.util.Utils.VerticalSpacer +import org.listenbrainz.android.util.Utils.formatDurationSeconds +import org.listenbrainz.android.util.Utils.getCoverArtUrl +import org.listenbrainz.android.util.Utils.shareLink +import org.listenbrainz.android.viewmodel.SocialViewModel +import org.listenbrainz.android.viewmodel.UserViewModel + +@Composable +fun CreatedForYouScreen( + snackbarState: SnackbarHostState, + socialViewModel: SocialViewModel, + userViewModel: UserViewModel, + goToArtistPage: (String) -> Unit, +) { + val uiState by userViewModel.uiState.collectAsState() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val socialUiState by socialViewModel.uiState.collectAsState() + CreatedForYouScreen(uiState = uiState, + onPlaylistSaveClick = { playlist -> + userViewModel.saveCreatedForPlaylist(playlist?.getPlaylistMBID()) { + scope.launch { + snackbarState.showSnackbar(it) + } + } + }, onPlayAllClick = { + //TODO: Implement this + }, onShareClick = { + if (it?.identifier != null) { + shareLink(context, it.identifier) + } else { + scope.launch { + snackbarState.showSnackbar("Link not found") + } + } + }, onTrackClick = { + it.toMetadata().trackMetadata?.let { it1 -> socialViewModel.playListen(it1) } + }, goToArtistPage = goToArtistPage, + snackbarState = snackbarState, + socialUiState = socialUiState, + onErrorShown = { + socialViewModel.clearErrorFlow() + }, + onMessageShown = { + socialViewModel.clearMsgFlow() + }, + onRetryDataFetch = { + userViewModel.retryFetchAPlaylist(it.getPlaylistMBID()) + } + ) +} + +@Composable +private fun CreatedForYouScreen( + uiState: ProfileUiState, + snackbarState: SnackbarHostState, + socialUiState: SocialUiState, + onPlaylistSaveClick: (CreatedForYouPlaylist?) -> Unit, + onPlayAllClick: () -> Unit, + onShareClick: (CreatedForYouPlaylist?) -> Unit, + onTrackClick: (PlaylistTrack) -> Unit, + goToArtistPage: (String) -> Unit, + onErrorShown: () -> Unit, + onMessageShown: () -> Unit, + onRetryDataFetch: (CreatedForYouPlaylist) -> Unit +) { + var selectedPlaylist by remember { + mutableStateOf( + if (uiState.createdForTabUIState.createdForYouPlaylists.isNullOrEmpty()) null + else uiState.createdForTabUIState.createdForYouPlaylists[0].playlist + ) + } + val playlistData = + uiState.createdForTabUIState.createdForYouPlaylistData?.get(selectedPlaylist?.getPlaylistMBID()) + + if (uiState.createdForTabUIState.createdForYouPlaylists.isNullOrEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(ListenBrainzTheme.paddings.horizontal), + contentAlignment = Alignment.Center + ) { + Text( + text = "No playlists found", + fontWeight = FontWeight.Medium, + color = ListenBrainzTheme.colorScheme.onBackground + ) + } + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(brush = ListenBrainzTheme.colorScheme.gradientBrush) + ) { + LazyColumn { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(0.dp, 0.dp, 16.dp, 16.dp)) + .background(ListenBrainzTheme.colorScheme.background) + ) { + Spacer(modifier = Modifier.height(32.dp)) + PlaylistSelectionCardRow( + modifier = Modifier.padding(vertical = 8.dp), + playlists = uiState.createdForTabUIState.createdForYouPlaylists.map { it.playlist }, + selectedPlaylist = selectedPlaylist, + onPlaylistSelect = { + selectedPlaylist = it + }, + onSaveClick = onPlaylistSaveClick + ) + } + } + + item { + AnimatedContent( + selectedPlaylist + ) { playlist -> + if (playlist == null) { + Box( + modifier = Modifier + .fillParentMaxWidth() + .padding( + horizontal = ListenBrainzTheme.paddings.horizontal, + vertical = 40.dp + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "No playlist selected", + fontWeight = FontWeight.Medium, + color = ListenBrainzTheme.colorScheme.onBackground + ) + } + } else if (playlistData == null) { + Column( + modifier = Modifier + .fillParentMaxWidth() + .padding( + horizontal = ListenBrainzTheme.paddings.horizontal, + vertical = 40.dp + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Playlist data could not be loaded :(", + fontWeight = FontWeight.Medium, + color = ListenBrainzTheme.colorScheme.onBackground + ) + + VerticalSpacer(8.dp) + + RetryButton { + onRetryDataFetch(playlist) + } + } + } else { + PlaylistHeadingAndDescription( + title = playlistData.title ?: "No title", + tracksCount = playlistData.track.size, + lastUpdatedDate = playlistData.date ?: "No date", + description = playlistData.annotation ?: "No description", + onPlayAllClick = { + onPlayAllClick() + }, + onShareClick = { + onShareClick(playlist) + } + ) + } + } + } + + items(playlistData?.track?.size ?: 0) { trackIndex -> + if (playlistData != null) { + val playlist = playlistData.track[trackIndex] + ListenCardSmallDefault( + modifier = Modifier.padding( + horizontal = ListenBrainzTheme.paddings.horizontal, + vertical = ListenBrainzTheme.paddings.lazyListAdjacent + ), + metadata = (playlist.toMetadata()), + coverArtUrl = getCoverArtUrl( + caaReleaseMbid = playlist.extension.trackExtensionData.additionalMetadata.caaReleaseMbid, + caaId = playlist.extension.trackExtensionData.additionalMetadata.caaId + ), + onDropdownSuccess = { messsage -> + snackbarState.showSnackbar(messsage) + }, + onDropdownError = { error -> + snackbarState.showSnackbar(error.toast) + }, + goToArtistPage = goToArtistPage, + onClick = { + onTrackClick(playlist) + }, + trailingContent = { + Text( + modifier = Modifier + .padding(bottom = 4.dp), + text = formatDurationSeconds(playlist.duration?.div(1000) ?: 0), + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ), + color = ListenBrainzTheme.colorScheme.listenText.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + enableTrailingContent = true + ) + } + } + } + } + } + + ErrorBar(error = socialUiState.error, onErrorShown = onErrorShown) + + SuccessBar( + resId = socialUiState.successMsgId, + onMessageShown = onMessageShown, + snackbarState = snackbarState + ) +} + + +@Preview(showBackground = true) +@Composable +fun CreatedForScreenPreview() { + ListenBrainzTheme { + val playlistTrack = PlaylistTrack( + title = "Goodbyes", + identifier = listOf("https://musicbrainz.org/recording/c8162486-f1a6-4673-88a2-e70c2112c221"), + duration = 174853, + extension = TrackExtension( + trackExtensionData = TrackExtensionData( + additionalMetadata = AdditionalMetadataTrack( + caaId = 24593316652, + caaReleaseMbid = "0b751b1b-f420-46e9-b2b5-108615b8427f" + ) + ) + ) + ) + CreatedForYouScreen( + uiState = ProfileUiState( + createdForTabUIState = CreatedForTabUIState( + createdForYouPlaylists = listOf( + CreatedForYouPlaylists( + playlist = CreatedForYouPlaylist( + annotation = "\"

The ListenBrainz Weekly Exploration playlist helps you discover new music! \\n It may require active listening and skips. The playlist features tracks you haven't heard before, \\n selected by a collaborative filtering algorithm.

\\n\\n

Updated every Monday morning based on your timezone.

\\n\"", + creator = "listenbrainz", + date = "2025-01-20T00:09:28.012627+00:00", + identifier = "\"https://listenbrainz.org/playlist/66d1b8fb-f5ed-4f82-b8e2-eac48f2fd278\"", + title = "Weekly Exploration for hemang-mishra, week of 2025-01-20 Mon" + ) + ), + CreatedForYouPlaylists( + playlist = CreatedForYouPlaylist( + annotation = "\"

The ListenBrainz Weekly Exploration playlist helps you discover new music! \\n It may require active listening and skips. The playlist features tracks you haven't heard before, \\n selected by a collaborative filtering algorithm.

\\n\\n

Updated every Monday morning based on your timezone.

\\n\"", + creator = "listenbrainz", + date = "2025-01-20T00:09:28.012627+00:00", + identifier = "\"https://listenbrainz.org/playlist/66d1b8fb-f5ed-4f82-b8e2-eac48f2fd279\"", + title = "Weekly Exploration for hemang-mishra, week of 2025-01-20 Mon" + ) + ), + ), + createdForYouPlaylistData = mapOf( + "66d1b8fb-f5ed-4f82-b8e2-eac48f2fd278" to PlaylistData( + title = "Playlist 1", + track = listOf( + playlistTrack, + playlistTrack, + playlistTrack + ), + date = "2025-01-13T00:07:44.741098+00:00", + annotation = "

The ListenBrainz Weekly Exploration playlist helps you discover new music! \\n It may require active listening and skips. The playlist features tracks you haven't heard before, \\n selected by a collaborative filtering algorithm.

\\n\\n

Updated every Monday morning based on your timezone.

" + ) + ) + ) + ), + snackbarState = SnackbarHostState(), + socialUiState = SocialUiState(), + onPlaylistSaveClick = {}, + onPlayAllClick = {}, + onShareClick = {}, + onTrackClick = {}, + goToArtistPage = {}, + onErrorShown = {}, + onMessageShown = {}, + onRetryDataFetch = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/createdforyou/PlaylistSelectionCards.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/createdforyou/PlaylistSelectionCards.kt new file mode 100644 index 00000000..afec0ef6 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/createdforyou/PlaylistSelectionCards.kt @@ -0,0 +1,323 @@ +package org.listenbrainz.android.ui.screens.profile.createdforyou + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.listenbrainz.android.R +import org.listenbrainz.android.model.createdForYou.AdditionalMetadata +import org.listenbrainz.android.model.createdForYou.CreatedForYouExtensionData +import org.listenbrainz.android.model.createdForYou.CreatedForYouPlaylist +import org.listenbrainz.android.model.createdForYou.Extension +import org.listenbrainz.android.ui.theme.ListenBrainzTheme +import org.listenbrainz.android.ui.theme.lb_purple +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +/** + * A row of cards that display the title of created for you playlists. + * [selectedPlaylist] is the selected playlist. + * [playlists] is the list of playlists. + * [onSaveClick] is the action to be performed when the save button is clicked. + * [onPlaylistSelect] is the action to be performed when the card is selected. + */ +@Composable +fun PlaylistSelectionCardRow( + modifier: Modifier, + selectedPlaylist: CreatedForYouPlaylist? = null, + playlists: List, + onSaveClick: (CreatedForYouPlaylist) -> Unit, + onPlaylistSelect: (CreatedForYouPlaylist) -> Unit +) { + LazyRow( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + itemsIndexed(playlists) { index, playlist-> + if (index == 0) { + Spacer(modifier = Modifier.width(8.dp)) + } + + PlaylistTitleCard( + modifier = Modifier.size(140.dp), + title = remember(playlist) { + modifyTitle(playlist) + }, + fractionLeft = remember(playlist) { + getFractionLeft( + playlist.date, + playlist.extension.createdForYouExtensionData.additionalMetadata.expiresAt + ) + }, + isSelected = selectedPlaylist == playlist, + cardBg = remember(index) { getCardBg(index) }, + alignment = Alignment.Center, + onSaveClick = { onSaveClick(playlist) }, + onPlaylistSelect = { onPlaylistSelect(playlist) } + ) + + if (index == playlists.lastIndex) { + Spacer(modifier = Modifier.width(8.dp)) + } + } + } +} + + +/** + * A card that displays the title of a playlist. + * [title] is the title of the playlist. + * [fractionLeft] is the percentage of the task left to be completed. + * [modifier] is the modifier for the card. + * [height] is the height of the card. + * [width] is the width of the card. + * [sizeIncrementOnSelect] is the increment in size when the card is selected. + * [alignment] is the alignment of the title. + * [cardBg] is the background of the card. + * [isSelected] is the state of the card. + * [onSaveClick] is the action to be performed when the save button is clicked. + * [onPlaylistSelect] is the action to be performed when the card is selected. + */ +@Composable +fun PlaylistTitleCard( + modifier: Modifier = Modifier, + title: String, + fractionLeft: Float = 0.0f, + sizeIncrementOnSelect: Int = 10, + alignment: Alignment = Alignment.TopStart, + cardBg: Int = R.drawable.playlist_card_bg1, + isSelected: Boolean = false, + onSaveClick: () -> Unit, + onPlaylistSelect: () -> Unit +) { + Box( + modifier = modifier + .clip(shape = ListenBrainzTheme.shapes.listenCardSmall) + .border( + width = if (isSelected) 3.dp else 0.dp, + color = ListenBrainzTheme.colorScheme.lbSignature, + shape = ListenBrainzTheme.shapes.listenCardSmall + ) + .animateContentSize(), + contentAlignment = alignment + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable { onPlaylistSelect() } + ) { + Image( + painter = painterResource(cardBg), + contentDescription = "Playlist Card Background", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + Text( + text = title, + style = ListenBrainzTheme.textStyles.dialogTitleBold, + color = lb_purple, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.BottomStart) + .padding( + bottom = ListenBrainzTheme.paddings.defaultPadding, + start = ListenBrainzTheme.paddings.insideCard, + end = ListenBrainzTheme.paddings.insideCard + ) + ) + ProgressCircle( + fractionLeft = fractionLeft, + modifier = Modifier + .align(Alignment.TopEnd) + .padding( + end = ListenBrainzTheme.paddings.insideCard, + top = ListenBrainzTheme.paddings.insideCard + ) + .alpha(0.4f), + size = 32 + ) + } + + Image( + painter = painterResource(R.drawable.playlist_save), + contentDescription = "Button to save the playlist", + modifier = Modifier + .align(Alignment.TopStart) + .padding( + top = ListenBrainzTheme.paddings.insideCard, + start = ListenBrainzTheme.paddings.insideCard + ) + .clickable { onSaveClick() } + ) + + } +} + +/** + * A circular progress indicator that shows the progress of a task. + * [fractionLeft] is the percentage of the task left to be completed. + * Color changes after a certain percentage of completion. + */ +@Composable +fun ProgressCircle( + fractionLeft: Float = 1.0f, + modifier: Modifier = Modifier, + size: Int, + initialProgressColor: Color = Color.White, + endProgressColor: Color = Color.Red, + thresholdOfColorChange: Float = 0.3f +) { + Canvas( + modifier.size(size.dp) + ) { + val startAnge = 270f + val sweepAngle = -360 * fractionLeft + drawArc( + color = Color.White.copy(alpha = 0.3f), + startAngle = 0f, + sweepAngle = 360f, + useCenter = true, + ) + drawArc( + color = if (fractionLeft > thresholdOfColorChange) initialProgressColor else endProgressColor, + startAngle = startAnge, + sweepAngle = sweepAngle, + useCenter = true, + ) + } +} + +//This function provides a background for the card based on the index. +fun getCardBg(index: Int): Int{ + return when(index%3){ + 0 -> R.drawable.playlist_card_bg1 + 1 -> R.drawable.playlist_card_bg2 + 2 -> R.drawable.playlist_card_bg3 + else -> R.drawable.playlist_card_bg1 + } +} + +@Preview(showBackground = true) +@Composable +fun PlaylistTitleCardPreview() { + PlaylistTitleCard( + modifier = Modifier.size(140.dp), + title = "Last Week's Exploration", + fractionLeft = 0.4f, + alignment = Alignment.Center, + onSaveClick = {}, + onPlaylistSelect = {} + ) +} + +@Preview(showBackground = true) +@Composable +fun ProgressCirclePreview() { + ProgressCircle(fractionLeft = 0.3f, size = 40) +} + +@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) +@Composable +fun PlaylistTitleCardRowPreview() { + var selectedPlaylist by remember { mutableStateOf(null) } + ListenBrainzTheme { + val playlist1 = CreatedForYouPlaylist( + title = "Weekly Exploration for hemang-mishra, week of 2025-01-13 Mon", + date = "2025-01-13T00:07:44.741098+00:00", + extension = Extension( + createdForYouExtensionData = CreatedForYouExtensionData( + additionalMetadata = AdditionalMetadata( + expiresAt = "2025-01-27T00:07:41.737783" + ) + ) + ) + ) + val playlist2 = CreatedForYouPlaylist( + title = "Weekly Exploration for hemang-mishra, week of 2025-01-06 Mon", + date = "2025-01-06T00:14:18.151711+00:00", + extension = Extension( + createdForYouExtensionData = CreatedForYouExtensionData( + additionalMetadata = AdditionalMetadata( + expiresAt = "2025-01-20T00:14:15.895660" + ) + ) + ) + ) + PlaylistSelectionCardRow( + modifier = Modifier.padding(16.dp), + playlists = listOf( + playlist1, + playlist2, + CreatedForYouPlaylist(title = "Last Week's Exploration") + ), + selectedPlaylist = selectedPlaylist, + onSaveClick = {}, + onPlaylistSelect = { + selectedPlaylist = it + } + ) + } +} + +//This function provides a shorter title to created for playlists. +fun modifyTitle(createdForYouPlaylist: CreatedForYouPlaylist): String{ + if(createdForYouPlaylist.title == null) return "No title" + if(!createdForYouPlaylist.title.contains("Weekly Exploration")) return createdForYouPlaylist.title + val millis = convertISOTimeStampToMillis(createdForYouPlaylist.date ?: "") + val currentTime = System.currentTimeMillis() + if(currentTime - millis < 7*24*60*60*1000) return "Weekly Exploration" + return "Last Week's Exploration" +} + +//This function converts an ISO timestamp to milliseconds. +fun convertISOTimeStampToMillis(timeStamp: String): Long { + val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.getDefault()) + sdf.timeZone = TimeZone.getTimeZone("UTC") + val date = sdf.parse(timeStamp) + return date?.time ?: 0L +} + +//This function calculates the fraction of time left for a task to be completed. +fun getFractionLeft(createdTimeStamp: String?, expiryTimeStamp: String?): Float { + if (createdTimeStamp == null || expiryTimeStamp == null) return 0.0f + val currentTime = System.currentTimeMillis() + val createdTime = convertISOTimeStampToMillis(createdTimeStamp) + val expiryTime = convertISOTimeStampToMillis(expiryTimeStamp) + val fraction = (expiryTime - currentTime).toFloat() / (expiryTime - createdTime).toFloat() + return if (fraction < 0 || fraction > 1.0f) 0.0f else fraction +} \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/createdforyou/PlaylistTitleComposables.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/createdforyou/PlaylistTitleComposables.kt new file mode 100644 index 00000000..c1662a1e --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/createdforyou/PlaylistTitleComposables.kt @@ -0,0 +1,237 @@ +package org.listenbrainz.android.ui.screens.profile.createdforyou + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.listenbrainz.android.R +import org.listenbrainz.android.ui.theme.ListenBrainzTheme +import org.listenbrainz.android.ui.theme.lb_purple +import org.listenbrainz.android.util.Utils.removeHtmlTags +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +// PlaylistHeadingAndDescription composable is used to display the heading and description of a playlist. +@Composable +fun PlaylistHeadingAndDescription( + title: String, + tracksCount: Int, + lastUpdatedDate: String, + description: String, + onShareClick: () -> Unit, + onPlayAllClick: () -> Unit +) { + var isReadMoreEnabled by remember { + mutableStateOf(true) + } + var isHeadingExpanded by remember { + mutableStateOf(false) + } + var isReadMoreRequired by remember { + mutableStateOf(null) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .animateContentSize() + ) { + Text( + text = title, + style = TextStyle( + fontSize = 26.sp, + fontWeight = FontWeight.ExtraBold + ), + maxLines = if (isHeadingExpanded) Int.MAX_VALUE else 1, + color = ListenBrainzTheme.colorScheme.listenText, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.clickable { + isHeadingExpanded = !isHeadingExpanded + } + ) + Spacer(modifier = Modifier.height(4.dp)) + Row { + Text( + text = "$tracksCount tracks", + color = ListenBrainzTheme.colorScheme.onBackground + ) + Text( + text = remember(lastUpdatedDate) { + " | Updated ${formatDateLegacy(lastUpdatedDate)}" + }, + color = ListenBrainzTheme.colorScheme.listenText.copy(alpha = 0.6f) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = Modifier.clickable { + if(isReadMoreRequired == true) + isReadMoreEnabled = !isReadMoreEnabled + } + ) { + Text( + text = remember(description) { + removeExcessiveSpaces(removeHtmlTags(description)).trim() + }, + color = ListenBrainzTheme.colorScheme.listenText.copy(0.5f), + fontStyle = FontStyle.Italic, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + letterSpacing = 0.1.sp, + maxLines = if (isReadMoreEnabled) 2 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis, + onTextLayout = { onTextLayout -> + isReadMoreEnabled = onTextLayout.hasVisualOverflow + if(isReadMoreRequired == null) + isReadMoreRequired = onTextLayout.hasVisualOverflow + } + ) + Spacer(modifier = Modifier.height(4.dp)) + if(isReadMoreRequired == true) + Text( + text = if (isReadMoreEnabled) "read more" else "read less", + style = TextStyle( + fontWeight = FontWeight.Medium, + fontStyle = FontStyle.Italic + ), + color = ListenBrainzTheme.colorScheme.listenText + ) + + } + Spacer(modifier = Modifier.height(16.dp)) + PlayAndShareButtons( + modifier = Modifier.align(Alignment.End), + onShareClick = onShareClick, + onPlayAllClick = onPlayAllClick + ) + } +} + +// PlayAndShareButtons composable is used to display the Play All and Share buttons. +@Composable +fun PlayAndShareButtons( + modifier: Modifier = Modifier, + onPlayAllClick: () -> Unit, + onShareClick: () -> Unit +) { + Row(modifier = modifier) { + //Hiding play all button for now +// Button( +// onClick = onPlayAllClick, +// colors = ButtonColors( +// contentColor = Color.White, +// containerColor = lb_purple, +// disabledContentColor = Color.Gray, +// disabledContainerColor = Color.Gray +// ) +// ) { +// Image( +// painter = painterResource(R.drawable.playlist_play_btn), +// contentDescription = "Play All", +// modifier = Modifier.size(20.dp) +// ) +// Spacer(modifier = Modifier.width(8.dp)) +// Text(text = "Play All") +// } +// Spacer(modifier = Modifier.width(8.dp)) + Button( + onShareClick, + colors = ButtonColors( + contentColor = Color.White, + containerColor = lb_purple, + disabledContentColor = Color.Gray, + disabledContainerColor = Color.Gray + ) + ) { + Row { + Icon( + painter = painterResource(R.drawable.playlist_share_btn), + contentDescription = "Share", + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Share") + } + } + + } +} + +// formatDateLegacy function is used to format the date in MMM dd, h:mm a format.Eg: Jan 06, 12:14 AM +fun formatDateLegacy(inputDate: String): String { + // Parse the input date string + val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.ENGLISH) + isoFormat.timeZone = TimeZone.getTimeZone("UTC") + val date = isoFormat.parse(inputDate) + + // Format to the desired output pattern + val outputFormat = SimpleDateFormat("MMM dd, h:mm a", Locale.ENGLISH) + outputFormat.timeZone = TimeZone.getDefault() // Adjust to local time zone + return outputFormat.format(date!!) +} + +fun removeExcessiveSpaces(input: String): String { + return input.trim().replace(Regex("\\s+"), " ") +} + + +@Preview(showBackground = true) +@Composable +fun WeeklyExplorationCardPreview() { + ListenBrainzTheme { + PlaylistHeadingAndDescription( + title = "Weekly Exploration", + tracksCount = 10, + lastUpdatedDate = "2025-01-06T00:14:18.151711+00:00", + description = "This is a weekly exploration playlist. It contains tracks that you might like. This is a weekly exploration playlist. It contains tracks that you might like.", + onShareClick = {}, + onPlayAllClick = {} + ) + } +} + +@Preview(uiMode = UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun WeeklyExplorationCardPreviewDark() { + ListenBrainzTheme() { + PlaylistHeadingAndDescription( + title = "Weekly Exploration", + tracksCount = 10, + lastUpdatedDate = "2025-01-06T00:14:18.151711+00:00", + description = "This is a weekly exploration playlist. It contains tracks that you might like. This is a weekly exploration playlist. It contains tracks that you might like.", + onShareClick = {}, + onPlayAllClick = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/util/Utils.kt b/app/src/main/java/org/listenbrainz/android/util/Utils.kt index ece8bae1..cc4765da 100644 --- a/app/src/main/java/org/listenbrainz/android/util/Utils.kt +++ b/app/src/main/java/org/listenbrainz/android/util/Utils.kt @@ -506,4 +506,34 @@ object Utils { return resultUrl } + + // Function to remove HTML tags from a string + fun removeHtmlTags(input: String): String { + // Regular expression pattern to match HTML tags + val regex = "<[^>]*>".toRegex() + // Replace all matches of the pattern with an empty string + return input.replace(regex, "") + } + + // Function to share a link + fun shareLink(context: Context, link: String) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, link) + } + context.startActivity(Intent.createChooser(intent, "Share link via")) + } + + //Format duration in seconds to HH:MM:SS format or MM:SS format + fun formatDurationSeconds(seconds: Int): String { + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val secs = seconds % 60 + + return if (hours > 0) { + String.format("%02d:%02d:%02d", hours, minutes, secs) + } else { + String.format("%02d:%02d", minutes, secs) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/viewmodel/UserViewModel.kt b/app/src/main/java/org/listenbrainz/android/viewmodel/UserViewModel.kt index d7c005d2..2a3a39b7 100644 --- a/app/src/main/java/org/listenbrainz/android/viewmodel/UserViewModel.kt +++ b/app/src/main/java/org/listenbrainz/android/viewmodel/UserViewModel.kt @@ -16,10 +16,13 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.listenbrainz.android.di.IoDispatcher import org.listenbrainz.android.model.Listen +import org.listenbrainz.android.model.playlist.PlaylistData import org.listenbrainz.android.repository.listens.ListensRepository +import org.listenbrainz.android.repository.playlists.PlaylistDataRepository import org.listenbrainz.android.repository.preferences.AppPreferences import org.listenbrainz.android.repository.social.SocialRepository import org.listenbrainz.android.repository.user.UserRepository +import org.listenbrainz.android.ui.screens.profile.CreatedForTabUIState import org.listenbrainz.android.ui.screens.profile.ListensTabUiState import org.listenbrainz.android.ui.screens.profile.ProfileUiState import org.listenbrainz.android.ui.screens.profile.StatsTabUIState @@ -27,6 +30,7 @@ import org.listenbrainz.android.ui.screens.profile.TasteTabUIState import org.listenbrainz.android.ui.screens.profile.stats.StatsRange import org.listenbrainz.android.ui.screens.profile.stats.UserGlobal import org.listenbrainz.android.util.Constants.Strings.STATUS_LOGGED_OUT +import org.listenbrainz.android.util.Log import org.listenbrainz.android.util.Resource import javax.inject.Inject @@ -36,6 +40,7 @@ class UserViewModel @Inject constructor( private val userRepository: UserRepository, private val listensRepository: ListensRepository, private val socialRepository: SocialRepository, + private val playlistDataRepository: PlaylistDataRepository, @IoDispatcher val ioDispatcher: CoroutineDispatcher, ) : BaseViewModel() { @@ -51,6 +56,7 @@ class UserViewModel @Inject constructor( private val listenStateFlow : MutableStateFlow = MutableStateFlow(ListensTabUiState()) private val statsStateFlow : MutableStateFlow = MutableStateFlow(StatsTabUIState()) private val tasteStateFlow : MutableStateFlow = MutableStateFlow(TasteTabUIState()) + private val createdForFlow: MutableStateFlow = MutableStateFlow(CreatedForTabUIState()) private suspend fun getSimilarArtists(username: String?) : List { val currentUsername = appPreferences.username.get() @@ -113,9 +119,11 @@ class UserViewModel @Inject constructor( val listensTabData = async { getUserListensData(inputUsername) } val statsTabData = async {getUserStatsData(inputUsername)} val tasteTabData = async {getUserTasteData(inputUsername)} + val createdForTabData = async {getCreatedForYouPlaylists(inputUsername)} listensTabData.await() statsTabData.await() tasteTabData.await() + createdForTabData.await() } @@ -307,6 +315,65 @@ class UserViewModel @Inject constructor( tasteStateFlow.emit(tastesTabState) } + //This function gets the createdForYou playlists and fetches the playlist data for each playlist + private suspend fun getCreatedForYouPlaylists(inputUsername: String?) { + createdForFlow.value = createdForFlow.value.copy(isLoading = true) + val createdForYouPlaylists = userRepository.getCreatedForYouPlaylists(inputUsername).data + //Map to store the playlist data for each playlist + val createdForYouPlaylistData = mutableMapOf() + //Fetch the playlist data for each playlist + createdForYouPlaylists?.playlists?.forEach { data-> + val playlistMbid = data.getPlaylistMBID() + if (playlistMbid != null) { + val playlistData = playlistDataRepository.fetchPlaylist(playlistMbid) + if(playlistData.status == Resource.Status.FAILED){ + emitError(playlistData.error) + } + if (playlistData.data != null) { + createdForYouPlaylistData[playlistMbid] = playlistData.data.playlist + } + } + + } + val createdForTabState = CreatedForTabUIState( + isLoading = false, + createdForYouPlaylists = createdForYouPlaylists?.playlists, + createdForYouPlaylistData = createdForYouPlaylistData + ) + + createdForFlow.emit(createdForTabState) + } + + + fun retryFetchAPlaylist(playlistMbid: String?){ + viewModelScope.launch(ioDispatcher) { + val playlistData = playlistDataRepository.fetchPlaylist(playlistMbid) + if(playlistData.status == Resource.Status.FAILED){ + emitError(playlistData.error) + } + if (playlistMbid!= null && playlistData.data != null) { + val currentData = createdForFlow.value.createdForYouPlaylistData?.toMutableMap() + currentData?.set(playlistMbid, playlistData.data.playlist) + createdForFlow.emit(createdForFlow.value.copy(createdForYouPlaylistData = currentData)) + } + } + } + + //This function saves the createdForYou playlist to the user's account + fun saveCreatedForPlaylist(playlistMbid: String?, + onCompletion: (String)->Unit + ){ + viewModelScope.launch(ioDispatcher) { + val result = playlistDataRepository.copyPlaylist(playlistMbid) + if (result.status == Resource.Status.SUCCESS){ + //Show a snackbar with the playlist id + onCompletion("Playlist saved successfully with id ${result.data?.playlistMbid}") + } + else{ + emitError(result.error) + } + } + } override val uiState: StateFlow = createUiStateFlow() @@ -316,13 +383,15 @@ class UserViewModel @Inject constructor( listenStateFlow, statsStateFlow, tasteStateFlow, + createdForFlow ) { - listensUIState, statsUIState, tasteUIState -> + listensUIState, statsUIState, tasteUIState, createdForUIState -> ProfileUiState( isSelf = isLoggedInUser, listensTabUiState = listensUIState, statsTabUIState = statsUIState, tasteTabUIState = tasteUIState, + createdForTabUIState = createdForUIState ) }.stateIn( viewModelScope, diff --git a/app/src/main/res/drawable/ic_reorder.xml b/app/src/main/res/drawable/ic_reorder.xml new file mode 100644 index 00000000..9de3c4e2 --- /dev/null +++ b/app/src/main/res/drawable/ic_reorder.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/playlist_card_bg1.xml b/app/src/main/res/drawable/playlist_card_bg1.xml new file mode 100644 index 00000000..09d10ece --- /dev/null +++ b/app/src/main/res/drawable/playlist_card_bg1.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/playlist_card_bg2.xml b/app/src/main/res/drawable/playlist_card_bg2.xml new file mode 100644 index 00000000..0225fb25 --- /dev/null +++ b/app/src/main/res/drawable/playlist_card_bg2.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/playlist_card_bg3.xml b/app/src/main/res/drawable/playlist_card_bg3.xml new file mode 100644 index 00000000..fa1d8d26 --- /dev/null +++ b/app/src/main/res/drawable/playlist_card_bg3.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/playlist_play_btn.xml b/app/src/main/res/drawable/playlist_play_btn.xml new file mode 100644 index 00000000..c375aadc --- /dev/null +++ b/app/src/main/res/drawable/playlist_play_btn.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/playlist_reorder.xml b/app/src/main/res/drawable/playlist_reorder.xml new file mode 100644 index 00000000..26c5a768 --- /dev/null +++ b/app/src/main/res/drawable/playlist_reorder.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/playlist_save.xml b/app/src/main/res/drawable/playlist_save.xml new file mode 100644 index 00000000..f4882f09 --- /dev/null +++ b/app/src/main/res/drawable/playlist_save.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/playlist_share_btn.xml b/app/src/main/res/drawable/playlist_share_btn.xml new file mode 100644 index 00000000..669f6872 --- /dev/null +++ b/app/src/main/res/drawable/playlist_share_btn.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/test/java/org/listenbrainz/android/user/UserViewModelTest.kt b/app/src/test/java/org/listenbrainz/android/user/UserViewModelTest.kt index b0559c66..036f218c 100644 --- a/app/src/test/java/org/listenbrainz/android/user/UserViewModelTest.kt +++ b/app/src/test/java/org/listenbrainz/android/user/UserViewModelTest.kt @@ -15,6 +15,7 @@ import org.listenbrainz.android.ui.screens.profile.stats.UserGlobal import org.listenbrainz.android.viewmodel.UserViewModel import org.listenbrainz.sharedtest.mocks.MockAppPreferences import org.listenbrainz.sharedtest.mocks.MockListensRepository +import org.listenbrainz.sharedtest.mocks.MockPlaylistDataRepository import org.listenbrainz.sharedtest.mocks.MockSocialRepository import org.listenbrainz.sharedtest.mocks.MockUserRepository import org.listenbrainz.sharedtest.utils.EntityTestUtils.testUsername @@ -25,7 +26,7 @@ class UserViewModelTest { @Before fun setup(){ Dispatchers.setMain(StandardTestDispatcher()) - viewModel = UserViewModel(MockAppPreferences(), MockUserRepository(), MockListensRepository(), MockSocialRepository(), Dispatchers.Default) + viewModel = UserViewModel(MockAppPreferences(), MockUserRepository(), MockListensRepository(), MockSocialRepository(),MockPlaylistDataRepository(), Dispatchers.Default) } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/sharedTest/src/main/java/org/listenbrainz/sharedtest/mocks/MockPlaylistDataRepository.kt b/sharedTest/src/main/java/org/listenbrainz/sharedtest/mocks/MockPlaylistDataRepository.kt new file mode 100644 index 00000000..5144fd7f --- /dev/null +++ b/sharedTest/src/main/java/org/listenbrainz/sharedtest/mocks/MockPlaylistDataRepository.kt @@ -0,0 +1,22 @@ +package org.listenbrainz.sharedtest.mocks + +import org.listenbrainz.android.model.playlist.CopyPlaylistResponse +import org.listenbrainz.android.model.playlist.PlaylistPayload +import org.listenbrainz.android.repository.playlists.PlaylistDataRepository +import org.listenbrainz.android.util.Resource +import org.listenbrainz.sharedtest.testdata.PlaylistDataRepositoryTestData.playlistDetailsTestData + + +class MockPlaylistDataRepository : PlaylistDataRepository { + override suspend fun fetchPlaylist(playlistMbid: String?): Resource { + return Resource(Resource.Status.SUCCESS, playlistDetailsTestData) + } + + override suspend fun copyPlaylist(playlistMbid: String?): Resource { + return Resource( + Resource.Status.SUCCESS, + CopyPlaylistResponse("new_playlist_mbid", "Playlist copied successfully") + ) + } + +} \ No newline at end of file diff --git a/sharedTest/src/main/java/org/listenbrainz/sharedtest/mocks/MockUserRepository.kt b/sharedTest/src/main/java/org/listenbrainz/sharedtest/mocks/MockUserRepository.kt index 3e01e1b2..ec32b909 100644 --- a/sharedTest/src/main/java/org/listenbrainz/sharedtest/mocks/MockUserRepository.kt +++ b/sharedTest/src/main/java/org/listenbrainz/sharedtest/mocks/MockUserRepository.kt @@ -2,6 +2,7 @@ package org.listenbrainz.sharedtest.mocks import org.listenbrainz.android.model.CurrentPins import org.listenbrainz.android.model.Listens +import org.listenbrainz.android.model.createdForYou.CreatedForYouPayload import org.listenbrainz.android.model.user.AllPinnedRecordings import org.listenbrainz.android.model.user.TopAlbums import org.listenbrainz.android.model.user.TopArtists @@ -12,6 +13,7 @@ import org.listenbrainz.android.model.user.UserSimilarityPayload import org.listenbrainz.android.repository.user.UserRepository import org.listenbrainz.android.util.Resource import org.listenbrainz.sharedtest.testdata.UserRepositoryTestData.allPinsTestData +import org.listenbrainz.sharedtest.testdata.UserRepositoryTestData.createdForYouPlaylistsTestData import org.listenbrainz.sharedtest.testdata.UserRepositoryTestData.currentPinsTestData import org.listenbrainz.sharedtest.testdata.UserRepositoryTestData.globalListeningActivityTestData import org.listenbrainz.sharedtest.testdata.UserRepositoryTestData.listenCountTestData @@ -77,4 +79,8 @@ class MockUserRepository : UserRepository { return Resource(Resource.Status.SUCCESS, topSongsTestData) } + override suspend fun getCreatedForYouPlaylists(username: String?): Resource { + return Resource(Resource.Status.SUCCESS, createdForYouPlaylistsTestData) + } + } \ No newline at end of file diff --git a/sharedTest/src/main/java/org/listenbrainz/sharedtest/testdata/PlaylistDataRepositoryTestData.kt b/sharedTest/src/main/java/org/listenbrainz/sharedtest/testdata/PlaylistDataRepositoryTestData.kt new file mode 100644 index 00000000..5b6f8c23 --- /dev/null +++ b/sharedTest/src/main/java/org/listenbrainz/sharedtest/testdata/PlaylistDataRepositoryTestData.kt @@ -0,0 +1,15 @@ +package org.listenbrainz.sharedtest.testdata + +import org.listenbrainz.android.model.playlist.PlaylistData +import org.listenbrainz.android.model.playlist.PlaylistPayload + +object PlaylistDataRepositoryTestData { + val playlistDetailsTestData: PlaylistPayload = PlaylistPayload( + playlist = PlaylistData( + annotation = "Description of the playlist", + title = "Weekly playlist", + date= "2025-01-12T11:05:24.966018+00:00", + creator = "hemang-mishra" + ) + ) +} \ No newline at end of file diff --git a/sharedTest/src/main/java/org/listenbrainz/sharedtest/testdata/UserRepositoryTestData.kt b/sharedTest/src/main/java/org/listenbrainz/sharedtest/testdata/UserRepositoryTestData.kt index ce7f4d1c..da34f46c 100644 --- a/sharedTest/src/main/java/org/listenbrainz/sharedtest/testdata/UserRepositoryTestData.kt +++ b/sharedTest/src/main/java/org/listenbrainz/sharedtest/testdata/UserRepositoryTestData.kt @@ -4,6 +4,9 @@ import com.google.gson.Gson import org.listenbrainz.android.model.CurrentPins import org.listenbrainz.android.model.Listens import org.listenbrainz.android.model.Payload +import org.listenbrainz.android.model.createdForYou.CreatedForYouPayload +import org.listenbrainz.android.model.createdForYou.CreatedForYouPlaylist +import org.listenbrainz.android.model.createdForYou.CreatedForYouPlaylists import org.listenbrainz.android.model.user.AllPinnedRecordings import org.listenbrainz.android.model.user.TopAlbums import org.listenbrainz.android.model.user.TopArtists @@ -31,6 +34,16 @@ object UserRepositoryTestData { ) ) + val createdForYouPlaylistsTestData: CreatedForYouPayload = CreatedForYouPayload( + playlists = listOf( + CreatedForYouPlaylists( + playlist = CreatedForYouPlaylist( + title = "Playlist 1", + ) + ) + ) + ) + val userSimilarityTestData: UserSimilarityPayload = UserSimilarityPayload( userSimilarity = UserSimilarity( similarity = 0.2580655f,