detail: move editing state to toolbar

Move the music editing state to the toolbar.

This should be signifigantly clearer than prior, at the cost of it's
"universality" implying that renaming should be available when it
actually won't be.
This commit is contained in:
Alexander Capehart 2023-05-19 19:54:36 -06:00
parent cee92c8087
commit 1fd6795b0d
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
20 changed files with 317 additions and 286 deletions

View file

@ -38,6 +38,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.detail.DetailViewModel
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
@ -66,6 +67,7 @@ class MainFragment :
private val musicModel: MusicViewModel by activityViewModels() private val musicModel: MusicViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val selectionModel: SelectionViewModel by activityViewModels() private val selectionModel: SelectionViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
private val callback = DynamicBackPressedCallback() private val callback = DynamicBackPressedCallback()
private var lastInsets: WindowInsets? = null private var lastInsets: WindowInsets? = null
private var elevationNormal = 0f private var elevationNormal = 0f
@ -458,6 +460,11 @@ class MainFragment :
return return
} }
// Clear out pending playlist edits.
if (detailModel.dropPlaylistEdit()) {
return
}
// Clear out any prior selections. // Clear out any prior selections.
if (selectionModel.drop()) { if (selectionModel.drop()) {
return return
@ -487,6 +494,7 @@ class MainFragment :
isEnabled = isEnabled =
queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED || queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED || playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
detailModel.editedPlaylist.value != null ||
selectionModel.selected.value.isNotEmpty() || selectionModel.selected.value.isNotEmpty() ||
exploreNavController.currentDestination?.id != exploreNavController.currentDestination?.id !=
exploreNavController.graph.startDestinationId exploreNavController.graph.startDestinationId

View file

@ -93,7 +93,7 @@ class AlbumDetailFragment :
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP -- // --- UI SETUP --
binding.detailToolbar.apply { binding.detailNormalToolbar.apply {
inflateMenu(R.menu.menu_album_detail) inflateMenu(R.menu.menu_album_detail)
setNavigationOnClickListener { findNavController().navigateUp() } setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@AlbumDetailFragment) setOnMenuItemClickListener(this@AlbumDetailFragment)
@ -124,7 +124,7 @@ class AlbumDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) { override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null) binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed // Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough. // during list initialization and crash the app. Could happen if the user is fast enough.
@ -214,7 +214,7 @@ class AlbumDetailFragment :
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
requireBinding().detailToolbar.title = album.name.resolve(requireContext()) requireBinding().detailNormalToolbar.title = album.name.resolve(requireContext())
albumHeaderAdapter.setParent(album) albumHeaderAdapter.setParent(album)
} }
@ -313,6 +313,13 @@ class AlbumDetailFragment :
private fun updateSelection(selected: List<Music>) { private fun updateSelection(selected: List<Music>) {
albumListAdapter.setSelected(selected.toSet()) albumListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
val binding = requireBinding()
if (selected.isNotEmpty()) {
binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
binding.detailToolbar.setVisible(R.id.detail_selection_toolbar)
} else {
binding.detailToolbar.setVisible(R.id.detail_normal_toolbar)
}
} }
} }

View file

@ -91,7 +91,7 @@ class ArtistDetailFragment :
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP --- // --- UI SETUP ---
binding.detailToolbar.apply { binding.detailNormalToolbar.apply {
inflateMenu(R.menu.menu_parent_detail) inflateMenu(R.menu.menu_parent_detail)
setNavigationOnClickListener { findNavController().navigateUp() } setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@ArtistDetailFragment) setOnMenuItemClickListener(this@ArtistDetailFragment)
@ -122,7 +122,7 @@ class ArtistDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) { override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null) binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed // Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough. // during list initialization and crash the app. Could happen if the user is fast enough.
@ -223,7 +223,7 @@ class ArtistDetailFragment :
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
requireBinding().detailToolbar.title = artist.name.resolve(requireContext()) requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext())
artistHeaderAdapter.setParent(artist) artistHeaderAdapter.setParent(artist)
} }
@ -283,6 +283,13 @@ class ArtistDetailFragment :
private fun updateSelection(selected: List<Music>) { private fun updateSelection(selected: List<Music>) {
artistListAdapter.setSelected(selected.toSet()) artistListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
val binding = requireBinding()
if (selected.isNotEmpty()) {
binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
binding.detailToolbar.setVisible(R.id.detail_selection_toolbar)
} else {
binding.detailToolbar.setVisible(R.id.detail_normal_toolbar)
}
} }
} }

View file

@ -69,7 +69,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Assume that we have a Toolbar with a detail_toolbar ID, as this view is only // Assume that we have a Toolbar with a detail_toolbar ID, as this view is only
// used within the detail layouts. // used within the detail layouts.
val toolbar = findViewById<Toolbar>(R.id.detail_toolbar) val toolbar = findViewById<Toolbar>(R.id.detail_normal_toolbar)
// The Toolbar's title view is actually hidden. To avoid having to create our own // The Toolbar's title view is actually hidden. To avoid having to create our own
// title view, we just reflect into Toolbar and grab the hidden field. // title view, we just reflect into Toolbar and grab the hidden field.

View file

@ -22,7 +22,6 @@ import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.lang.Exception
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -298,7 +297,7 @@ constructor(
* End a playlist editing session and commits it to the database. Does nothing if there was no * End a playlist editing session and commits it to the database. Does nothing if there was no
* prior editing session. * prior editing session.
*/ */
fun confirmPlaylistEdit() { fun savePlaylistEdit() {
val playlist = _currentPlaylist.value ?: return val playlist = _currentPlaylist.value ?: return
val editedPlaylist = (_editedPlaylist.value ?: return).also { _editedPlaylist.value = null } val editedPlaylist = (_editedPlaylist.value ?: return).also { _editedPlaylist.value = null }
musicRepository.rewritePlaylist(playlist, editedPlaylist) musicRepository.rewritePlaylist(playlist, editedPlaylist)
@ -307,11 +306,18 @@ constructor(
/** /**
* End a playlist editing session and keep the prior state. Does nothing if there was no prior * End a playlist editing session and keep the prior state. Does nothing if there was no prior
* editing session. * editing session.
*
* @return true if the session was ended, false otherwise.
*/ */
fun dropPlaylistEdit() { fun dropPlaylistEdit(): Boolean {
val playlist = _currentPlaylist.value ?: return val playlist = _currentPlaylist.value ?: return false
if (_editedPlaylist.value == null) {
// Nothing to do.
return false
}
_editedPlaylist.value = null _editedPlaylist.value = null
refreshPlaylistList(playlist) refreshPlaylistList(playlist)
return true
} }
/** /**
@ -319,8 +325,10 @@ constructor(
* *
* @param from The start position, in the list adapter data. * @param from The start position, in the list adapter data.
* @param to The destination position, in the list adapter data. * @param to The destination position, in the list adapter data.
* @return true if the song was moved, false otherwise.
*/ */
fun movePlaylistSongs(from: Int, to: Int): Boolean { fun movePlaylistSongs(from: Int, to: Int): Boolean {
// TODO: Song re-sorting
val playlist = _currentPlaylist.value ?: return false val playlist = _currentPlaylist.value ?: return false
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList() val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
val realFrom = from - 2 val realFrom = from - 2
@ -340,6 +348,7 @@ constructor(
* @param at The position of the item to remove, in the list adapter data. * @param at The position of the item to remove, in the list adapter data.
*/ */
fun removePlaylistSong(at: Int) { fun removePlaylistSong(at: Int) {
// TODO: Remove header when empty
val playlist = _currentPlaylist.value ?: return val playlist = _currentPlaylist.value ?: return
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList() val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
val realAt = at - 2 val realAt = at - 2
@ -478,7 +487,6 @@ constructor(
playlist: Playlist, playlist: Playlist,
instructions: UpdateInstructions = UpdateInstructions.Diff instructions: UpdateInstructions = UpdateInstructions.Diff
) { ) {
logD(Exception().stackTraceToString())
logD("Refreshing playlist list") logD("Refreshing playlist list")
val list = mutableListOf<Item>() val list = mutableListOf<Item>()

View file

@ -84,7 +84,7 @@ class GenreDetailFragment :
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP --- // --- UI SETUP ---
binding.detailToolbar.apply { binding.detailNormalToolbar.apply {
inflateMenu(R.menu.menu_parent_detail) inflateMenu(R.menu.menu_parent_detail)
setNavigationOnClickListener { findNavController().navigateUp() } setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@GenreDetailFragment) setOnMenuItemClickListener(this@GenreDetailFragment)
@ -115,7 +115,7 @@ class GenreDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) { override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null) binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed // Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough. // during list initialization and crash the app. Could happen if the user is fast enough.
@ -214,7 +214,7 @@ class GenreDetailFragment :
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
requireBinding().detailToolbar.title = genre.name.resolve(requireContext()) requireBinding().detailNormalToolbar.title = genre.name.resolve(requireContext())
genreHeaderAdapter.setParent(genre) genreHeaderAdapter.setParent(genre)
} }
@ -260,6 +260,13 @@ class GenreDetailFragment :
private fun updateSelection(selected: List<Music>) { private fun updateSelection(selected: List<Music>) {
genreListAdapter.setSelected(selected.toSet()) genreListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
val binding = requireBinding()
if (selected.isNotEmpty()) {
binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
binding.detailToolbar.setVisible(R.id.detail_selection_toolbar)
} else {
binding.detailToolbar.setVisible(R.id.detail_normal_toolbar)
}
} }
} }

View file

@ -90,12 +90,17 @@ class PlaylistDetailFragment :
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP --- // --- UI SETUP ---
binding.detailToolbar.apply { binding.detailNormalToolbar.apply {
inflateMenu(R.menu.menu_playlist_detail) inflateMenu(R.menu.menu_playlist_detail)
setNavigationOnClickListener { findNavController().navigateUp() } setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@PlaylistDetailFragment) setOnMenuItemClickListener(this@PlaylistDetailFragment)
} }
binding.detailEditToolbar.apply {
setNavigationOnClickListener { detailModel.dropPlaylistEdit() }
setOnMenuItemClickListener(this@PlaylistDetailFragment)
}
binding.detailRecycler.apply { binding.detailRecycler.apply {
adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter) adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter)
touchHelper = touchHelper =
@ -139,7 +144,7 @@ class PlaylistDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) { override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null) binding.detailNormalToolbar.setOnMenuItemClickListener(null)
touchHelper = null touchHelper = null
binding.detailRecycler.adapter = null binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed // Avoid possible race conditions that could cause a bad replace instruction to be consumed
@ -159,7 +164,8 @@ class PlaylistDetailFragment :
initialNavDestinationChange = true initialNavDestinationChange = true
return return
} }
// Drop any pending playlist edits when navigating away. // Drop any pending playlist edits when navigating away. This could actually happen
// if the user is quick enough.
detailModel.dropPlaylistEdit() detailModel.dropPlaylistEdit()
} }
@ -188,6 +194,10 @@ class PlaylistDetailFragment :
musicModel.deletePlaylist(currentPlaylist) musicModel.deletePlaylist(currentPlaylist)
true true
} }
R.id.action_save -> {
detailModel.savePlaylistEdit()
true
}
else -> false else -> false
} }
} }
@ -214,21 +224,10 @@ class PlaylistDetailFragment :
} }
override fun onStartEdit() { override fun onStartEdit() {
selectionModel.drop()
detailModel.startPlaylistEdit() detailModel.startPlaylistEdit()
} }
override fun onConfirmEdit() { override fun onOpenSortMenu(anchor: View) {}
detailModel.confirmPlaylistEdit()
}
override fun onDropEdit() {
detailModel.dropPlaylistEdit()
}
override fun onOpenSortMenu(anchor: View) {
throw IllegalStateException()
}
private fun updatePlaylist(playlist: Playlist?) { private fun updatePlaylist(playlist: Playlist?) {
if (playlist == null) { if (playlist == null) {
@ -236,7 +235,9 @@ class PlaylistDetailFragment :
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
requireBinding().detailToolbar.title = playlist.name.resolve(requireContext()) val binding = requireBinding()
binding.detailNormalToolbar.title = playlist.name.resolve(requireContext())
binding.detailEditToolbar.title = "Editing ${playlist.name.resolve(requireContext())}"
playlistHeaderAdapter.setParent(playlist) playlistHeaderAdapter.setParent(playlist)
} }
@ -279,18 +280,35 @@ class PlaylistDetailFragment :
} }
private fun updateEditedPlaylist(editedPlaylist: List<Song>?) { private fun updateEditedPlaylist(editedPlaylist: List<Song>?) {
// TODO: Disable check item when no edits have been made
// TODO: Massively improve how this UI is indicated:
// - Add an additional toolbar to indicate editing
// - Header should flip to re-sort button eventually
playlistListAdapter.setEditing(editedPlaylist != null) playlistListAdapter.setEditing(editedPlaylist != null)
playlistHeaderAdapter.setEditedPlaylist(editedPlaylist) playlistHeaderAdapter.setEditedPlaylist(editedPlaylist)
selectionModel.drop()
logD(editedPlaylist == detailModel.currentPlaylist.value?.songs)
requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).isEnabled =
editedPlaylist != detailModel.currentPlaylist.value?.songs
updateMultiToolbar()
} }
private fun updateSelection(selected: List<Music>) { private fun updateSelection(selected: List<Music>) {
playlistListAdapter.setSelected(selected.toSet()) playlistListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
val binding = requireBinding()
if (selected.isNotEmpty()) {
binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
}
updateMultiToolbar()
}
private fun updateMultiToolbar() {
val id =
when {
detailModel.editedPlaylist.value != null -> R.id.detail_edit_toolbar
selectionModel.selected.value.isNotEmpty() -> R.id.detail_selection_toolbar
else -> R.id.detail_normal_toolbar
}
requireBinding().detailToolbar.setVisible(id)
} }
} }

View file

@ -106,10 +106,6 @@ class PlaylistDetailListAdapter(private val listener: Listener) :
interface Listener : DetailListAdapter.Listener<Song>, EditableListListener { interface Listener : DetailListAdapter.Listener<Song>, EditableListListener {
/** Called when the "edit" option is selected in the edit header. */ /** Called when the "edit" option is selected in the edit header. */
fun onStartEdit() fun onStartEdit()
/** Called when the "confirm" option is selected in the edit header. */
fun onConfirmEdit()
/** Called when the "cancel" option is selected in the edit header. */
fun onDropEdit()
} }
/** /**
@ -169,13 +165,9 @@ private class EditHeaderViewHolder private constructor(private val binding: Item
TooltipCompat.setTooltipText(this, contentDescription) TooltipCompat.setTooltipText(this, contentDescription)
setOnClickListener { listener.onStartEdit() } setOnClickListener { listener.onStartEdit() }
} }
binding.headerConfirm.apply { binding.headerSort.apply {
TooltipCompat.setTooltipText(this, contentDescription) TooltipCompat.setTooltipText(this, contentDescription)
setOnClickListener { listener.onConfirmEdit() } setOnClickListener(listener::onOpenSortMenu)
}
binding.headerCancel.apply {
TooltipCompat.setTooltipText(this, contentDescription)
setOnClickListener { listener.onDropEdit() }
} }
} }
@ -184,12 +176,8 @@ private class EditHeaderViewHolder private constructor(private val binding: Item
isGone = editing isGone = editing
jumpDrawablesToCurrentState() jumpDrawablesToCurrentState()
} }
binding.headerConfirm.apply { binding.headerSort.apply {
isVisible = editing isGone = !editing
jumpDrawablesToCurrentState()
}
binding.headerCancel.apply {
isVisible = editing
jumpDrawablesToCurrentState() jumpDrawablesToCurrentState()
} }
} }
@ -238,6 +226,7 @@ private constructor(private val binding: ItemEditableSongBinding) :
elevation = binding.context.getDimen(R.dimen.elevation_normal) elevation = binding.context.getDimen(R.dimen.elevation_normal)
alpha = 0 alpha = 0
} }
init { init {
binding.body.background = binding.body.background =
LayerDrawable( LayerDrawable(

View file

@ -102,7 +102,7 @@ class HomeFragment :
// --- UI SETUP --- // --- UI SETUP ---
binding.homeAppbar.addOnOffsetChangedListener(this) binding.homeAppbar.addOnOffsetChangedListener(this)
binding.homeToolbar.apply { binding.homeNormalToolbar.apply {
setOnMenuItemClickListener(this@HomeFragment) setOnMenuItemClickListener(this@HomeFragment)
MenuCompat.setGroupDividerEnabled(menu, true) MenuCompat.setGroupDividerEnabled(menu, true)
} }
@ -169,7 +169,7 @@ class HomeFragment :
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
storagePermissionLauncher = null storagePermissionLauncher = null
binding.homeAppbar.removeOnOffsetChangedListener(this) binding.homeAppbar.removeOnOffsetChangedListener(this)
binding.homeToolbar.setOnMenuItemClickListener(null) binding.homeNormalToolbar.setOnMenuItemClickListener(null)
} }
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
@ -178,8 +178,7 @@ class HomeFragment :
// Fade out the toolbar as the AppBarLayout collapses. To prevent status bar overlap, // Fade out the toolbar as the AppBarLayout collapses. To prevent status bar overlap,
// the alpha transition is shifted such that the Toolbar becomes fully transparent // the alpha transition is shifted such that the Toolbar becomes fully transparent
// when the AppBarLayout is only at half-collapsed. // when the AppBarLayout is only at half-collapsed.
binding.homeSelectionToolbar.alpha = binding.homeToolbar.alpha = 1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2))
1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2))
binding.homeContent.updatePadding( binding.homeContent.updatePadding(
bottom = binding.homeAppbar.totalScrollRange + verticalOffset) bottom = binding.homeAppbar.totalScrollRange + verticalOffset)
} }
@ -243,7 +242,7 @@ class HomeFragment :
binding.homePager.adapter = binding.homePager.adapter =
HomePagerAdapter(homeModel.currentTabModes, childFragmentManager, viewLifecycleOwner) HomePagerAdapter(homeModel.currentTabModes, childFragmentManager, viewLifecycleOwner)
val toolbarParams = binding.homeSelectionToolbar.layoutParams as AppBarLayout.LayoutParams val toolbarParams = binding.homeToolbar.layoutParams as AppBarLayout.LayoutParams
if (homeModel.currentTabModes.size == 1) { if (homeModel.currentTabModes.size == 1) {
// A single tab makes the tab layout redundant, hide it and disable the collapsing // A single tab makes the tab layout redundant, hide it and disable the collapsing
// behavior. // behavior.
@ -285,7 +284,7 @@ class HomeFragment :
} }
val sortMenu = val sortMenu =
unlikelyToBeNull(binding.homeToolbar.menu.findItem(R.id.submenu_sorting).subMenu) unlikelyToBeNull(binding.homeNormalToolbar.menu.findItem(R.id.submenu_sorting).subMenu)
val toHighlight = homeModel.getSortForTab(tabMode) val toHighlight = homeModel.getSortForTab(tabMode)
for (option in sortMenu) { for (option in sortMenu) {
@ -456,11 +455,15 @@ class HomeFragment :
private fun updateSelection(selected: List<Music>) { private fun updateSelection(selected: List<Music>) {
val binding = requireBinding() val binding = requireBinding()
if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) && if (selected.isNotEmpty()) {
selected.isNotEmpty()) { binding.homeSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
// New selection started, show the AppBarLayout to indicate the new state. if (binding.homeToolbar.setVisible(R.id.home_selection_toolbar)) {
logD("Significant selection occurred, expanding AppBar") // New selection started, show the AppBarLayout to indicate the new state.
binding.homeAppbar.expandWithScrollingRecycler() logD("Significant selection occurred, expanding AppBar")
binding.homeAppbar.expandWithScrollingRecycler()
}
} else {
binding.homeToolbar.setVisible(R.id.home_normal_toolbar)
} }
} }

View file

@ -50,6 +50,7 @@ import okio.buffer
import okio.source import okio.source
import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -66,10 +67,10 @@ constructor(
val albums = computeAlbumOrdering(songs) val albums = computeAlbumOrdering(songs)
val streams = mutableListOf<InputStream>() val streams = mutableListOf<InputStream>()
for (album in albums) { for (album in albums) {
openInputStream(album)?.let(streams::add)
if (streams.size == 4) { if (streams.size == 4) {
return createMosaic(streams, size) return createMosaic(streams, size)
} }
openInputStream(album)?.let(streams::add)
} }
return streams.firstOrNull()?.let { stream -> return streams.firstOrNull()?.let { stream ->
@ -81,7 +82,7 @@ constructor(
} }
fun computeAlbumOrdering(songs: List<Song>): Collection<Album> = fun computeAlbumOrdering(songs: List<Song>): Collection<Album> =
songs.groupByTo(sortedMapOf(compareByDescending { it.songs.size })) { it.album }.keys Sort(Sort.Mode.ByCount, Sort.Direction.DESCENDING).albums(songs.groupBy { it.album }.keys)
private suspend fun openInputStream(album: Album): InputStream? = private suspend fun openInputStream(album: Album): InputStream? =
try { try {

View file

@ -39,20 +39,13 @@ abstract class SelectionFragment<VB : ViewBinding> :
protected abstract val musicModel: MusicViewModel protected abstract val musicModel: MusicViewModel
protected abstract val playbackModel: PlaybackViewModel protected abstract val playbackModel: PlaybackViewModel
/** open fun getSelectionToolbar(binding: VB): Toolbar? = null
* Get the [SelectionToolbarOverlay] of the concrete Fragment to be automatically managed by
* [SelectionFragment].
*
* @return The [SelectionToolbarOverlay] of the concrete [SelectionFragment]'s [VB], or null if
* there is not one.
*/
open fun getSelectionToolbar(binding: VB): SelectionToolbarOverlay? = null
override fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
getSelectionToolbar(binding)?.apply { getSelectionToolbar(binding)?.apply {
// Add cancel and menu item listeners to manage what occurs with the selection. // Add cancel and menu item listeners to manage what occurs with the selection.
setOnSelectionCancelListener { selectionModel.drop() } setNavigationOnClickListener { selectionModel.drop() }
setOnMenuItemClickListener(this@SelectionFragment) setOnMenuItemClickListener(this@SelectionFragment)
} }
} }

View file

@ -1,178 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* SelectionToolbarOverlay.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.list.selection
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.appcompat.widget.Toolbar.OnMenuItemClickListener
import androidx.core.view.isInvisible
import com.google.android.material.appbar.MaterialToolbar
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.logD
/**
* A wrapper around a [MaterialToolbar] that adds an additional [MaterialToolbar] showing the
* current selection state.
*
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Generalize this into a "view flipper" class and then derive it through other means?
*/
class SelectionToolbarOverlay
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
private lateinit var innerToolbar: MaterialToolbar
private val selectionToolbar =
MaterialToolbar(context).apply {
setNavigationIcon(R.drawable.ic_close_24)
inflateMenu(R.menu.menu_selection_actions)
if (isInEditMode) {
isInvisible = true
}
}
private var fadeThroughAnimator: ValueAnimator? = null
override fun onFinishInflate() {
super.onFinishInflate()
// Sanity check: Avoid incorrect views from being included in this layout.
check(childCount == 1 && getChildAt(0) is MaterialToolbar) {
"SelectionToolbarOverlay Must have only one MaterialToolbar child"
}
// The inner toolbar should be the first child.
innerToolbar = getChildAt(0) as MaterialToolbar
// Selection toolbar should appear on top of the inner toolbar.
addView(selectionToolbar)
}
/**
* Set an OnClickListener for when the "cancel" button in the selection [MaterialToolbar] is
* pressed.
*
* @param listener The OnClickListener to respond to this interaction.
* @see MaterialToolbar.setNavigationOnClickListener
*/
fun setOnSelectionCancelListener(listener: OnClickListener) {
selectionToolbar.setNavigationOnClickListener(listener)
}
/**
* Set an [OnMenuItemClickListener] for when a MenuItem is selected from the selection
* [MaterialToolbar].
*
* @param listener The [OnMenuItemClickListener] to respond to this interaction.
* @see MaterialToolbar.setOnMenuItemClickListener
*/
fun setOnMenuItemClickListener(listener: OnMenuItemClickListener?) {
selectionToolbar.setOnMenuItemClickListener(listener)
}
/**
* Update the selection [MaterialToolbar] to reflect the current selection amount.
*
* @param amount The amount of items that are currently selected.
* @return true if the selection [MaterialToolbar] changes, false otherwise.
*/
fun updateSelectionAmount(amount: Int): Boolean {
logD("Updating selection amount to $amount")
return if (amount > 0) {
// Only update the selected amount when it's non-zero to prevent a strange
// title text.
selectionToolbar.title = context.getString(R.string.fmt_selected, amount)
animateToolbarsVisibility(true)
} else {
animateToolbarsVisibility(false)
}
}
/**
* Animate the visibility of the inner and selection [MaterialToolbar]s to the given state.
*
* @param selectionVisible Whether the selection [MaterialToolbar] should be visible or not.
* @return true if the toolbars have changed, false otherwise.
*/
private fun animateToolbarsVisibility(selectionVisible: Boolean): Boolean {
// TODO: Animate nicer Material Fade transitions using animators (Normal transitions
// don't work due to translation)
// Set up the target transitions for both the inner and selection toolbars.
val targetInnerAlpha: Float
val targetSelectionAlpha: Float
val targetDuration: Long
if (selectionVisible) {
targetInnerAlpha = 0f
targetSelectionAlpha = 1f
targetDuration = context.getInteger(R.integer.anim_fade_enter_duration).toLong()
} else {
targetInnerAlpha = 1f
targetSelectionAlpha = 0f
targetDuration = context.getInteger(R.integer.anim_fade_exit_duration).toLong()
}
if (innerToolbar.alpha == targetInnerAlpha &&
selectionToolbar.alpha == targetSelectionAlpha) {
// Nothing to do.
return false
}
if (!isLaidOut) {
// Not laid out, just change it immediately while are not shown to the user.
// This is an initialization, so we return false despite changing.
setToolbarsAlpha(targetInnerAlpha)
return false
}
if (fadeThroughAnimator != null) {
fadeThroughAnimator?.cancel()
fadeThroughAnimator = null
}
fadeThroughAnimator =
ValueAnimator.ofFloat(innerToolbar.alpha, targetInnerAlpha).apply {
duration = targetDuration
addUpdateListener { setToolbarsAlpha(it.animatedValue as Float) }
start()
}
return true
}
/**
* Update the alpha of the inner and selection [MaterialToolbar]s.
*
* @param innerAlpha The opacity of the inner [MaterialToolbar]. This will map to the inverse
* opacity of the selection [MaterialToolbar].
*/
private fun setToolbarsAlpha(innerAlpha: Float) {
innerToolbar.apply {
alpha = innerAlpha
isInvisible = innerAlpha == 0f
}
selectionToolbar.apply {
alpha = 1 - innerAlpha
isInvisible = innerAlpha == 1f
}
}
}

View file

@ -81,7 +81,7 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
imm = binding.context.getSystemServiceCompat(InputMethodManager::class) imm = binding.context.getSystemServiceCompat(InputMethodManager::class)
binding.searchToolbar.apply { binding.searchNormalToolbar.apply {
// Initialize the current filtering mode. // Initialize the current filtering mode.
menu.findItem(searchModel.getFilterOptionId()).isChecked = true menu.findItem(searchModel.getFilterOptionId()).isChecked = true
@ -126,7 +126,7 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
override fun onDestroyBinding(binding: FragmentSearchBinding) { override fun onDestroyBinding(binding: FragmentSearchBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
binding.searchToolbar.setOnMenuItemClickListener(null) binding.searchNormalToolbar.setOnMenuItemClickListener(null)
binding.searchRecycler.adapter = null binding.searchRecycler.adapter = null
} }
@ -198,10 +198,16 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
private fun updateSelection(selected: List<Music>) { private fun updateSelection(selected: List<Music>) {
searchAdapter.setSelected(selected.toSet()) searchAdapter.setSelected(selected.toSet())
if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) && val binding = requireBinding()
selected.isNotEmpty()) { if (selected.isNotEmpty()) {
// Make selection of obscured items easier by hiding the keyboard. binding.searchSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
hideKeyboard() if (binding.searchToolbar.setVisible(R.id.search_selection_toolbar)) {
// New selection started, show the keyboard to make selection easier.
logD("Significant selection occurred, hiding keyboard")
hideKeyboard()
}
} else {
binding.searchToolbar.setVisible(R.id.search_normal_toolbar)
} }
} }

View file

@ -0,0 +1,114 @@
/*
* Copyright (c) 2023 Auxio Project
* MultiToolbar.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.ui
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.annotation.IdRes
import androidx.appcompat.widget.Toolbar
import androidx.core.view.children
import androidx.core.view.isInvisible
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.logD
class MultiToolbar
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
private var fadeThroughAnimator: ValueAnimator? = null
private var currentlyVisible = 0
override fun onFinishInflate() {
super.onFinishInflate()
for (i in 1 until childCount) {
getChildAt(i).apply {
alpha = 0f
isInvisible = true
}
}
}
fun setVisible(@IdRes viewId: Int): Boolean {
val index = children.indexOfFirst { it.id == viewId }
if (index == currentlyVisible) return false
return animateToolbarsVisibility(currentlyVisible, index).also { currentlyVisible = index }
}
private fun animateToolbarsVisibility(from: Int, to: Int): Boolean {
// TODO: Animate nicer Material Fade transitions using animators (Normal transitions
// don't work due to translation)
// Set up the target transitions for both the inner and selection toolbars.
val targetFromAlpha = 0f
val targetToAlpha = 1f
val targetDuration =
if (from < to) {
context.getInteger(R.integer.anim_fade_enter_duration).toLong()
} else {
context.getInteger(R.integer.anim_fade_exit_duration).toLong()
}
logD(targetDuration)
val fromView = getChildAt(from) as Toolbar
val toView = getChildAt(to) as Toolbar
if (fromView.alpha == targetFromAlpha && toView.alpha == targetToAlpha) {
// Nothing to do.
return false
}
if (!isLaidOut) {
// Not laid out, just change it immediately while are not shown to the user.
// This is an initialization, so we return false despite changing.
setToolbarsAlpha(fromView, toView, targetFromAlpha)
return false
}
if (fadeThroughAnimator != null) {
fadeThroughAnimator?.cancel()
fadeThroughAnimator = null
}
fadeThroughAnimator =
ValueAnimator.ofFloat(fromView.alpha, targetFromAlpha).apply {
duration = targetDuration
addUpdateListener { setToolbarsAlpha(fromView, toView, it.animatedValue as Float) }
start()
}
return true
}
private fun setToolbarsAlpha(from: Toolbar, to: Toolbar, innerAlpha: Float) {
logD("${to.id == R.id.detail_edit_toolbar} ${1 - innerAlpha}")
from.apply {
alpha = innerAlpha
isInvisible = innerAlpha == 0f
}
to.apply {
alpha = 1 - innerAlpha
isInvisible = innerAlpha == 1f
}
}
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M840,280L840,840L120,840L120,120L680,120L840,280ZM760,314L646,200L200,200L200,760L760,760L760,314ZM480,720Q530,720 565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720ZM240,400L600,400L600,240L240,240L240,400ZM200,314L200,760L200,760L200,200L200,200L200,314Z"/>
</vector>

View file

@ -13,19 +13,38 @@
app:liftOnScroll="true" app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/detail_recycler"> app:liftOnScrollTargetViewId="@id/detail_recycler">
<org.oxycblt.auxio.list.selection.SelectionToolbarOverlay <org.oxycblt.auxio.ui.MultiToolbar
android:id="@+id/detail_selection_toolbar" android:id="@+id/detail_toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/detail_toolbar" android:id="@+id/detail_normal_toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:navigationIcon="@drawable/ic_back_24" /> app:navigationIcon="@drawable/ic_back_24" />
</org.oxycblt.auxio.list.selection.SelectionToolbarOverlay> <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/detail_selection_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:navigationIcon="@drawable/ic_close_24"
app:menu="@menu/menu_selection_actions" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/detail_edit_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:navigationIcon="@drawable/ic_close_24"
app:menu="@menu/menu_edit_actions" />
</org.oxycblt.auxio.ui.MultiToolbar>
</org.oxycblt.auxio.detail.DetailAppBarLayout> </org.oxycblt.auxio.detail.DetailAppBarLayout>

View file

@ -12,20 +12,29 @@
android:id="@+id/home_appbar" android:id="@+id/home_appbar"
style="@style/Widget.Auxio.AppBarLayout"> style="@style/Widget.Auxio.AppBarLayout">
<org.oxycblt.auxio.list.selection.SelectionToolbarOverlay <org.oxycblt.auxio.ui.MultiToolbar
android:id="@+id/home_selection_toolbar" android:id="@+id/home_toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/home_toolbar" android:id="@+id/home_normal_toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|enterAlways" app:layout_scrollFlags="scroll|enterAlways"
app:menu="@menu/menu_home" app:menu="@menu/menu_home"
app:title="@string/info_app_name" /> app:title="@string/info_app_name" />
</org.oxycblt.auxio.list.selection.SelectionToolbarOverlay> <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/home_selection_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:navigationIcon="@drawable/ic_close_24"
app:menu="@menu/menu_selection_actions" />
</org.oxycblt.auxio.ui.MultiToolbar>
<com.google.android.material.tabs.TabLayout <com.google.android.material.tabs.TabLayout
android:id="@+id/home_tabs" android:id="@+id/home_tabs"

View file

@ -12,13 +12,13 @@
app:liftOnScroll="true" app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/search_recycler"> app:liftOnScrollTargetViewId="@id/search_recycler">
<org.oxycblt.auxio.list.selection.SelectionToolbarOverlay <org.oxycblt.auxio.ui.MultiToolbar
android:id="@+id/search_selection_toolbar" android:id="@+id/search_toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/search_toolbar" android:id="@+id/search_normal_toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:menu="@menu/menu_search" app:menu="@menu/menu_search"
@ -49,7 +49,16 @@
</com.google.android.material.appbar.MaterialToolbar> </com.google.android.material.appbar.MaterialToolbar>
</org.oxycblt.auxio.list.selection.SelectionToolbarOverlay> <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/search_selection_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:navigationIcon="@drawable/ic_close_24"
app:menu="@menu/menu_selection_actions" />
</org.oxycblt.auxio.ui.MultiToolbar>
</org.oxycblt.auxio.ui.CoordinatorAppBarLayout> </org.oxycblt.auxio.ui.CoordinatorAppBarLayout>

View file

@ -29,22 +29,13 @@
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton <org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/header_confirm" android:id="@+id/header_sort"
style="@style/Widget.Auxio.Button.Icon.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/lbl_ok"
app:icon="@drawable/ic_check_24"
app:layout_constraintEnd_toEndOf="parent" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/header_cancel"
style="@style/Widget.Auxio.Button.Icon.Small" style="@style/Widget.Auxio.Button.Icon.Small"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_mid_medium" android:layout_marginEnd="@dimen/spacing_mid_medium"
android:contentDescription="@string/lbl_cancel" android:contentDescription="@string/lbl_cancel"
app:icon="@drawable/ic_close_24" app:icon="@drawable/ic_sort_24"
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent" />
</LinearLayout> </LinearLayout>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_save"
android:title="@string/lbl_save"
app:showAsAction="always"
android:icon="@drawable/ic_save_24" />
</menu>