music: add playlist deletion dialog

Add a dialog that allows the user to confirm playlist deletion, instead
of it happening immediately.
This commit is contained in:
Alexander Capehart 2023-05-17 18:51:38 -06:00
parent d1f9200bf9
commit 97e144058a
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
13 changed files with 180 additions and 41 deletions

View file

@ -41,6 +41,7 @@ import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.navigation.MainNavigationAction import org.oxycblt.auxio.navigation.MainNavigationAction
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
@ -136,6 +137,7 @@ class MainFragment :
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker) collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist) collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist)
collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist) collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist)
collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist)
collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.song, ::updateSong)
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker) collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker) collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
@ -322,6 +324,13 @@ class MainFragment :
} }
} }
private fun handleDeletePlaylist(playlist: Playlist?) {
if (playlist != null) {
findNavController()
.navigateSafe(MainFragmentDirections.actionDeletePlaylist(playlist.uid))
musicModel.playlistToDelete.consume()
}
}
private fun handlePlaybackArtistPicker(song: Song?) { private fun handlePlaybackArtistPicker(song: Song?) {
if (song != null) { if (song != null) {
navModel.mainNavigateTo( navModel.mainNavigateTo(

View file

@ -99,7 +99,7 @@ class AlbumDetailFragment :
// -- VIEWMODEL SETUP --- // -- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setAlbumUid(args.albumUid) detailModel.setAlbum(args.albumUid)
collectImmediately(detailModel.currentAlbum, ::updateAlbum) collectImmediately(detailModel.currentAlbum, ::updateAlbum)
collectImmediately(detailModel.albumList, ::updateList) collectImmediately(detailModel.albumList, ::updateList)
collectImmediately( collectImmediately(

View file

@ -98,7 +98,7 @@ class ArtistDetailFragment :
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setArtistUid(args.artistUid) detailModel.setArtist(args.artistUid)
collectImmediately(detailModel.currentArtist, ::updateArtist) collectImmediately(detailModel.currentArtist, ::updateArtist)
collectImmediately(detailModel.artistList, ::updateList) collectImmediately(detailModel.artistList, ::updateList)
collectImmediately( collectImmediately(

View file

@ -224,47 +224,47 @@ constructor(
} }
/** /**
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] and * Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will
* [songAudioProperties] will be updated to align with the new [Song]. * be updated to align with the new [Song].
* *
* @param uid The UID of the [Song] to load. Must be valid. * @param uid The UID of the [Song] to load. Must be valid.
*/ */
fun setSongUid(uid: Music.UID) { fun setSong(uid: Music.UID) {
logD("Opening Song [uid: $uid]") logD("Opening Song [uid: $uid]")
_currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo) _currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo)
} }
/** /**
* Set a new [currentAlbum] from it's [Music.UID]. If the [Music.UID] differs, [currentAlbum] * Set a new [currentAlbum] from it's [Music.UID]. [currentAlbum] and [albumList] will be
* and [albumList] will be updated to align with the new [Album]. * updated to align with the new [Album].
* *
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid. * @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
*/ */
fun setAlbumUid(uid: Music.UID) { fun setAlbum(uid: Music.UID) {
logD("Opening Album [uid: $uid]") logD("Opening Album [uid: $uid]")
_currentAlbum.value = _currentAlbum.value =
musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList) musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList)
} }
/** /**
* Set a new [currentArtist] from it's [Music.UID]. If the [Music.UID] differs, [currentArtist] * Set a new [currentArtist] from it's [Music.UID]. [currentArtist] and [artistList] will be
* and [artistList] will be updated to align with the new [Artist]. * updated to align with the new [Artist].
* *
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid. * @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
*/ */
fun setArtistUid(uid: Music.UID) { fun setArtist(uid: Music.UID) {
logD("Opening Artist [uid: $uid]") logD("Opening Artist [uid: $uid]")
_currentArtist.value = _currentArtist.value =
musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList) musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList)
} }
/** /**
* Set a new [currentGenre] from it's [Music.UID]. If the [Music.UID] differs, [currentGenre] * Set a new [currentGenre] from it's [Music.UID]. [currentGenre] and [genreList] will be
* and [genreList] will be updated to align with the new album. * updated to align with the new album.
* *
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid. * @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
*/ */
fun setGenreUid(uid: Music.UID) { fun setGenre(uid: Music.UID) {
logD("Opening Genre [uid: $uid]") logD("Opening Genre [uid: $uid]")
_currentGenre.value = _currentGenre.value =
musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList) musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList)

View file

@ -91,7 +91,7 @@ class GenreDetailFragment :
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setGenreUid(args.genreUid) detailModel.setGenre(args.genreUid)
collectImmediately(detailModel.currentGenre, ::updatePlaylist) collectImmediately(detailModel.currentGenre, ::updatePlaylist)
collectImmediately(detailModel.genreList, ::updateList) collectImmediately(detailModel.genreList, ::updateList)
collectImmediately( collectImmediately(

View file

@ -67,7 +67,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
binding.detailProperties.adapter = detailAdapter binding.detailProperties.adapter = detailAdapter
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setSongUid(args.songUid) detailModel.setSong(args.songUid)
collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong) collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong)
} }

View file

@ -48,13 +48,18 @@ constructor(
val statistics: StateFlow<Statistics?> val statistics: StateFlow<Statistics?>
get() = _statistics get() = _statistics
private val _newPlaylistSongs = MutableEvent<List<Song>?>() private val _newPlaylistSongs = MutableEvent<List<Song>>()
/** Flag for opening a dialog to create a playlist of the given [Song]s. */ /** Flag for opening a dialog to create a playlist of the given [Song]s. */
val newPlaylistSongs: Event<List<Song>?> = _newPlaylistSongs val newPlaylistSongs: Event<List<Song>> = _newPlaylistSongs
private val _songsToAdd = MutableEvent<List<Song>?>() private val _songsToAdd = MutableEvent<List<Song>>()
/** Flag for opening a dialog to add the given [Song]s to a playlist. */ /** Flag for opening a dialog to add the given [Song]s to a playlist. */
val songsToAdd: Event<List<Song>?> = _songsToAdd val songsToAdd: Event<List<Song>> = _songsToAdd
private val _playlistToDelete = MutableEvent<Playlist>()
/** Flag for opening a dialog to confirm deletion of the given [Playlist]. */
val playlistToDelete: Event<Playlist>
get() = _playlistToDelete
init { init {
musicRepository.addUpdateListener(this) musicRepository.addUpdateListener(this)
@ -110,11 +115,15 @@ constructor(
* Delete a [Playlist]. * Delete a [Playlist].
* *
* @param playlist The playlist to delete. * @param playlist The playlist to delete.
* * @param rude Whether to immediately delete the playlist or prompt the user first. This should
* TODO: Prompt the user before deleting. * be false at almost all times.
*/ */
fun deletePlaylist(playlist: Playlist) { fun deletePlaylist(playlist: Playlist, rude: Boolean = false) {
musicRepository.deletePlaylist(playlist) if (rude) {
musicRepository.deletePlaylist(playlist)
} else {
_playlistToDelete.put(playlist)
}
} }
/** /**

View file

@ -71,8 +71,8 @@ class AddToPlaylistDialog :
} }
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
pickerModel.setPendingSongs(args.songUids) pickerModel.setSongsToAdd(args.songUids)
collectImmediately(pickerModel.currentPendingSongs, ::updatePendingSongs) collectImmediately(pickerModel.currentSongsToAdd, ::updatePendingSongs)
collectImmediately(pickerModel.playlistChoices, ::updatePlaylistChoices) collectImmediately(pickerModel.playlistChoices, ::updatePlaylistChoices)
} }
@ -82,13 +82,13 @@ class AddToPlaylistDialog :
} }
override fun onClick(item: PlaylistChoice, viewHolder: RecyclerView.ViewHolder) { override fun onClick(item: PlaylistChoice, viewHolder: RecyclerView.ViewHolder) {
musicModel.addToPlaylist(pickerModel.currentPendingSongs.value ?: return, item.playlist) musicModel.addToPlaylist(pickerModel.currentSongsToAdd.value ?: return, item.playlist)
requireContext().showToast(R.string.lng_playlist_added) requireContext().showToast(R.string.lng_playlist_added)
findNavController().navigateUp() findNavController().navigateUp()
} }
override fun onNewPlaylist() { override fun onNewPlaylist() {
musicModel.createPlaylist(songs = pickerModel.currentPendingSongs.value ?: return) musicModel.createPlaylist(songs = pickerModel.currentSongsToAdd.value ?: return)
} }
private fun updatePendingSongs(songs: List<Song>?) { private fun updatePendingSongs(songs: List<Song>?) {

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2023 Auxio Project
* DeletePlaylistDialog.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.picker
import android.os.Bundle
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* A [ViewBindingDialogFragment] that asks the user to confirm the deletion of a [Playlist].
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class DeletePlaylistDialog : ViewBindingDialogFragment<DialogDeletePlaylistBinding>() {
private val pickerModel: PlaylistPickerViewModel by viewModels()
private val musicModel: MusicViewModel by activityViewModels()
// Information about what playlist to name for is initially within the navigation arguments
// as UIDs, as that is the only safe way to parcel playlist information.
private val args: DeletePlaylistDialogArgs by navArgs()
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder
.setTitle(R.string.lbl_confirm_delete_playlist)
.setPositiveButton(R.string.lbl_delete) { _, _ ->
// Now we can delete the playlist for-real this time.
musicModel.deletePlaylist(
unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true)
}
.setNegativeButton(R.string.lbl_cancel, null)
}
override fun onCreateBinding(inflater: LayoutInflater) =
DialogDeletePlaylistBinding.inflate(inflater)
override fun onBindingCreated(
binding: DialogDeletePlaylistBinding,
savedInstanceState: Bundle?
) {
super.onBindingCreated(binding, savedInstanceState)
// --- VIEWMODEL SETUP ---
pickerModel.setPlaylistToDelete(args.playlistUid)
collectImmediately(pickerModel.currentPlaylistToDelete, ::updatePlaylistToDelete)
}
private fun updatePlaylistToDelete(playlist: Playlist?) {
if (playlist == null) {
// Playlist does not exist anymore, leave
findNavController().navigateUp()
return
}
requireBinding().deletionInfo.text =
getString(R.string.fmt_deletion_info, playlist.name.resolve(requireContext()))
}
}

View file

@ -48,14 +48,18 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
val chosenName: StateFlow<ChosenName> val chosenName: StateFlow<ChosenName>
get() = _chosenName get() = _chosenName
private val _currentPendingSongs = MutableStateFlow<List<Song>?>(null) private val _currentSongsToAdd = MutableStateFlow<List<Song>?>(null)
val currentPendingSongs: StateFlow<List<Song>?> val currentSongsToAdd: StateFlow<List<Song>?>
get() = _currentPendingSongs get() = _currentSongsToAdd
private val _playlistChoices = MutableStateFlow<List<PlaylistChoice>>(listOf()) private val _playlistChoices = MutableStateFlow<List<PlaylistChoice>>(listOf())
val playlistChoices: StateFlow<List<PlaylistChoice>> val playlistChoices: StateFlow<List<PlaylistChoice>>
get() = _playlistChoices get() = _playlistChoices
private val _currentPlaylistToDelete = MutableStateFlow<Playlist?>(null)
val currentPlaylistToDelete: StateFlow<Playlist?>
get() = _currentPlaylistToDelete
init { init {
musicRepository.addUpdateListener(this) musicRepository.addUpdateListener(this)
} }
@ -70,8 +74,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
pendingPlaylist.preferredName, pendingPlaylist.preferredName,
pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) }) pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) })
} }
_currentPendingSongs.value = _currentSongsToAdd.value =
_currentPendingSongs.value?.let { pendingSongs -> _currentSongsToAdd.value?.let { pendingSongs ->
pendingSongs pendingSongs
.mapNotNull { deviceLibrary.findSong(it.uid) } .mapNotNull { deviceLibrary.findSong(it.uid) }
.ifEmpty { null } .ifEmpty { null }
@ -88,7 +92,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
// Nothing to do. // Nothing to do.
} }
} }
refreshChoicesWith = refreshChoicesWith ?: _currentPendingSongs.value refreshChoicesWith = refreshChoicesWith ?: _currentSongsToAdd.value
} }
refreshChoicesWith?.let(::refreshPlaylistChoices) refreshChoicesWith?.let(::refreshPlaylistChoices)
@ -99,7 +103,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
} }
/** /**
* Update the current [PendingPlaylist]. Will do nothing if already equal. * Set a new [currentPendingPlaylist] from a new batch of pending [Song] [Music.UID]s.
* *
* @param context [Context] required to generate a playlist name. * @param context [Context] required to generate a playlist name.
* @param songUids The [Music.UID]s of songs to be present in the playlist. * @param songUids The [Music.UID]s of songs to be present in the playlist.
@ -121,7 +125,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
} }
/** /**
* Update the current [ChosenName] based on new user input. * Update the current [chosenName] based on new user input.
* *
* @param name The new user-inputted name, or null if not present. * @param name The new user-inputted name, or null if not present.
*/ */
@ -143,16 +147,14 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
} }
/** /**
* Update the current [Song]s that to show playlist add choices for. Will do nothing if already * Set a new [currentSongsToAdd] from a new batch of pending [Song] [Music.UID]s.
* equal.
* *
* @param songUids The [Music.UID]s of songs to add to a playlist. * @param songUids The [Music.UID]s of songs to add to a playlist.
*/ */
fun setPendingSongs(songUids: Array<Music.UID>) { fun setSongsToAdd(songUids: Array<Music.UID>) {
if (currentPendingSongs.value?.map { it.uid } == songUids) return
val deviceLibrary = musicRepository.deviceLibrary ?: return val deviceLibrary = musicRepository.deviceLibrary ?: return
val songs = songUids.mapNotNull(deviceLibrary::findSong) val songs = songUids.mapNotNull(deviceLibrary::findSong)
_currentPendingSongs.value = songs _currentSongsToAdd.value = songs
refreshPlaylistChoices(songs) refreshPlaylistChoices(songs)
} }
@ -164,6 +166,15 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
PlaylistChoice(it, songs.all(songSet::contains)) PlaylistChoice(it, songs.all(songSet::contains))
} }
} }
/**
* Set a new [currentPendingPlaylist] from a new [Playlist] [Music.UID].
*
* @param playlistUid The [Music.UID] of the [Playlist] to delete.
*/
fun setPlaylistToDelete(playlistUid: Music.UID) {
_currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
}
} }
/** /**

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/deletion_info"
android:layout_width="match_parent"
android:paddingTop="@dimen/spacing_medium"
android:paddingEnd="@dimen/spacing_large"
android:paddingStart="@dimen/spacing_large"
android:layout_height="match_parent"
android:textAppearance="@style/TextAppearance.Auxio.BodyLarge"
xmlns:tools="http://schemas.android.com/tools"
tools:text="Delete Playlist 16? This cannot be undone."/>

View file

@ -23,6 +23,9 @@
<action <action
android:id="@+id/action_add_to_playlist" android:id="@+id/action_add_to_playlist"
app:destination="@id/add_to_playlist_dialog" /> app:destination="@id/add_to_playlist_dialog" />
<action
android:id="@+id/action_delete_playlist"
app:destination="@id/delete_playlist_dialog" />
<action <action
android:id="@+id/action_pick_navigation_artist" android:id="@+id/action_pick_navigation_artist"
app:destination="@id/navigate_to_artist_dialog" /> app:destination="@id/navigate_to_artist_dialog" />
@ -67,6 +70,16 @@
app:destination="@id/new_playlist_dialog" /> app:destination="@id/new_playlist_dialog" />
</dialog> </dialog>
<dialog
android:id="@+id/delete_playlist_dialog"
android:name="org.oxycblt.auxio.music.picker.DeletePlaylistDialog"
android:label="delete_playlist_dialog"
tools:layout="@layout/dialog_playlist_name">
<argument
android:name="playlistUid"
app:argType="org.oxycblt.auxio.music.Music$UID" />
</dialog>
<dialog <dialog
android:id="@+id/navigate_to_artist_dialog" android:id="@+id/navigate_to_artist_dialog"
android:name="org.oxycblt.auxio.navigation.picker.NavigateToArtistDialog" android:name="org.oxycblt.auxio.navigation.picker.NavigateToArtistDialog"

View file

@ -80,6 +80,7 @@
<string name="lbl_playlists">Playlists</string> <string name="lbl_playlists">Playlists</string>
<string name="lbl_new_playlist">New playlist</string> <string name="lbl_new_playlist">New playlist</string>
<string name="lbl_delete">Delete</string> <string name="lbl_delete">Delete</string>
<string name="lbl_confirm_delete_playlist">Delete playlist?</string>
<!-- Search for music --> <!-- Search for music -->
<string name="lbl_search">Search</string> <string name="lbl_search">Search</string>
@ -395,6 +396,7 @@
<string name="fmt_sample_rate">%d Hz</string> <string name="fmt_sample_rate">%d Hz</string>
<string name="fmt_indexing">Loading your music library… (%1$d/%2$d)</string> <string name="fmt_indexing">Loading your music library… (%1$d/%2$d)</string>
<string name="fmt_deletion_info">Delete %s? This cannot be undone.</string>
<string name="fmt_lib_song_count">Songs loaded: %d</string> <string name="fmt_lib_song_count">Songs loaded: %d</string>
<string name="fmt_lib_album_count">Albums loaded: %d</string> <string name="fmt_lib_album_count">Albums loaded: %d</string>