From 1fd6795b0d32749dafb72c7f06830f5926baf824 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 19 May 2023 19:54:36 -0600 Subject: [PATCH] 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. --- .../java/org/oxycblt/auxio/MainFragment.kt | 8 + .../auxio/detail/AlbumDetailFragment.kt | 15 +- .../auxio/detail/ArtistDetailFragment.kt | 15 +- .../auxio/detail/DetailAppBarLayout.kt | 2 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 18 +- .../auxio/detail/GenreDetailFragment.kt | 15 +- .../auxio/detail/PlaylistDetailFragment.kt | 64 ++++--- .../detail/list/PlaylistDetailListAdapter.kt | 21 +-- .../org/oxycblt/auxio/home/HomeFragment.kt | 25 +-- .../auxio/image/extractor/CoverExtractor.kt | 5 +- .../auxio/list/selection/SelectionFragment.kt | 11 +- .../list/selection/SelectionToolbarOverlay.kt | 178 ------------------ .../oxycblt/auxio/search/SearchFragment.kt | 18 +- .../java/org/oxycblt/auxio/ui/MultiToolbar.kt | 114 +++++++++++ app/src/main/res/drawable/ic_save_24.xml | 11 ++ app/src/main/res/layout/fragment_detail.xml | 27 ++- app/src/main/res/layout/fragment_home.xml | 17 +- app/src/main/res/layout/fragment_search.xml | 17 +- app/src/main/res/layout/item_edit_header.xml | 13 +- app/src/main/res/menu/menu_edit_actions.xml | 9 + 20 files changed, 317 insertions(+), 286 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/ui/MultiToolbar.kt create mode 100644 app/src/main/res/drawable/ic_save_24.xml create mode 100644 app/src/main/res/menu/menu_edit_actions.xml diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 90ada8b9c..665fc7bdc 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -38,6 +38,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlin.math.max import kotlin.math.min import org.oxycblt.auxio.databinding.FragmentMainBinding +import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicViewModel @@ -66,6 +67,7 @@ class MainFragment : private val musicModel: MusicViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels() private val selectionModel: SelectionViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() private val callback = DynamicBackPressedCallback() private var lastInsets: WindowInsets? = null private var elevationNormal = 0f @@ -458,6 +460,11 @@ class MainFragment : return } + // Clear out pending playlist edits. + if (detailModel.dropPlaylistEdit()) { + return + } + // Clear out any prior selections. if (selectionModel.drop()) { return @@ -487,6 +494,7 @@ class MainFragment : isEnabled = queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED || playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED || + detailModel.editedPlaylist.value != null || selectionModel.selected.value.isNotEmpty() || exploreNavController.currentDestination?.id != exploreNavController.graph.startDestinationId diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index b168f1afe..d1f13160c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -93,7 +93,7 @@ class AlbumDetailFragment : super.onBindingCreated(binding, savedInstanceState) // --- UI SETUP -- - binding.detailToolbar.apply { + binding.detailNormalToolbar.apply { inflateMenu(R.menu.menu_album_detail) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@AlbumDetailFragment) @@ -124,7 +124,7 @@ class AlbumDetailFragment : override fun onDestroyBinding(binding: FragmentDetailBinding) { super.onDestroyBinding(binding) - binding.detailToolbar.setOnMenuItemClickListener(null) + binding.detailNormalToolbar.setOnMenuItemClickListener(null) binding.detailRecycler.adapter = null // 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. @@ -214,7 +214,7 @@ class AlbumDetailFragment : findNavController().navigateUp() return } - requireBinding().detailToolbar.title = album.name.resolve(requireContext()) + requireBinding().detailNormalToolbar.title = album.name.resolve(requireContext()) albumHeaderAdapter.setParent(album) } @@ -313,6 +313,13 @@ class AlbumDetailFragment : private fun updateSelection(selected: List) { 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) + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index 20b055183..619a48211 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -91,7 +91,7 @@ class ArtistDetailFragment : super.onBindingCreated(binding, savedInstanceState) // --- UI SETUP --- - binding.detailToolbar.apply { + binding.detailNormalToolbar.apply { inflateMenu(R.menu.menu_parent_detail) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@ArtistDetailFragment) @@ -122,7 +122,7 @@ class ArtistDetailFragment : override fun onDestroyBinding(binding: FragmentDetailBinding) { super.onDestroyBinding(binding) - binding.detailToolbar.setOnMenuItemClickListener(null) + binding.detailNormalToolbar.setOnMenuItemClickListener(null) binding.detailRecycler.adapter = null // 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. @@ -223,7 +223,7 @@ class ArtistDetailFragment : findNavController().navigateUp() return } - requireBinding().detailToolbar.title = artist.name.resolve(requireContext()) + requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext()) artistHeaderAdapter.setParent(artist) } @@ -283,6 +283,13 @@ class ArtistDetailFragment : private fun updateSelection(selected: List) { 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) + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt index ae1325daf..15b803ae6 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt @@ -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 // used within the detail layouts. - val toolbar = findViewById(R.id.detail_toolbar) + val toolbar = findViewById(R.id.detail_normal_toolbar) // 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. diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index ccaf2c9e1..367ef3545 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -22,7 +22,6 @@ import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import java.lang.Exception import javax.inject.Inject import kotlinx.coroutines.Dispatchers 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 * prior editing session. */ - fun confirmPlaylistEdit() { + fun savePlaylistEdit() { val playlist = _currentPlaylist.value ?: return val editedPlaylist = (_editedPlaylist.value ?: return).also { _editedPlaylist.value = null } 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 * editing session. + * + * @return true if the session was ended, false otherwise. */ - fun dropPlaylistEdit() { - val playlist = _currentPlaylist.value ?: return + fun dropPlaylistEdit(): Boolean { + val playlist = _currentPlaylist.value ?: return false + if (_editedPlaylist.value == null) { + // Nothing to do. + return false + } _editedPlaylist.value = null refreshPlaylistList(playlist) + return true } /** @@ -319,8 +325,10 @@ constructor( * * @param from The start 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 { + // TODO: Song re-sorting val playlist = _currentPlaylist.value ?: return false val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList() val realFrom = from - 2 @@ -340,6 +348,7 @@ constructor( * @param at The position of the item to remove, in the list adapter data. */ fun removePlaylistSong(at: Int) { + // TODO: Remove header when empty val playlist = _currentPlaylist.value ?: return val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList() val realAt = at - 2 @@ -478,7 +487,6 @@ constructor( playlist: Playlist, instructions: UpdateInstructions = UpdateInstructions.Diff ) { - logD(Exception().stackTraceToString()) logD("Refreshing playlist list") val list = mutableListOf() diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index 2729267f7..862d5d2ef 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -84,7 +84,7 @@ class GenreDetailFragment : super.onBindingCreated(binding, savedInstanceState) // --- UI SETUP --- - binding.detailToolbar.apply { + binding.detailNormalToolbar.apply { inflateMenu(R.menu.menu_parent_detail) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@GenreDetailFragment) @@ -115,7 +115,7 @@ class GenreDetailFragment : override fun onDestroyBinding(binding: FragmentDetailBinding) { super.onDestroyBinding(binding) - binding.detailToolbar.setOnMenuItemClickListener(null) + binding.detailNormalToolbar.setOnMenuItemClickListener(null) binding.detailRecycler.adapter = null // 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. @@ -214,7 +214,7 @@ class GenreDetailFragment : findNavController().navigateUp() return } - requireBinding().detailToolbar.title = genre.name.resolve(requireContext()) + requireBinding().detailNormalToolbar.title = genre.name.resolve(requireContext()) genreHeaderAdapter.setParent(genre) } @@ -260,6 +260,13 @@ class GenreDetailFragment : private fun updateSelection(selected: List) { 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) + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index 18ecf7956..4b8c2650c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -90,12 +90,17 @@ class PlaylistDetailFragment : super.onBindingCreated(binding, savedInstanceState) // --- UI SETUP --- - binding.detailToolbar.apply { + binding.detailNormalToolbar.apply { inflateMenu(R.menu.menu_playlist_detail) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@PlaylistDetailFragment) } + binding.detailEditToolbar.apply { + setNavigationOnClickListener { detailModel.dropPlaylistEdit() } + setOnMenuItemClickListener(this@PlaylistDetailFragment) + } + binding.detailRecycler.apply { adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter) touchHelper = @@ -139,7 +144,7 @@ class PlaylistDetailFragment : override fun onDestroyBinding(binding: FragmentDetailBinding) { super.onDestroyBinding(binding) - binding.detailToolbar.setOnMenuItemClickListener(null) + binding.detailNormalToolbar.setOnMenuItemClickListener(null) touchHelper = null binding.detailRecycler.adapter = null // Avoid possible race conditions that could cause a bad replace instruction to be consumed @@ -159,7 +164,8 @@ class PlaylistDetailFragment : initialNavDestinationChange = true 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() } @@ -188,6 +194,10 @@ class PlaylistDetailFragment : musicModel.deletePlaylist(currentPlaylist) true } + R.id.action_save -> { + detailModel.savePlaylistEdit() + true + } else -> false } } @@ -214,21 +224,10 @@ class PlaylistDetailFragment : } override fun onStartEdit() { - selectionModel.drop() detailModel.startPlaylistEdit() } - override fun onConfirmEdit() { - detailModel.confirmPlaylistEdit() - } - - override fun onDropEdit() { - detailModel.dropPlaylistEdit() - } - - override fun onOpenSortMenu(anchor: View) { - throw IllegalStateException() - } + override fun onOpenSortMenu(anchor: View) {} private fun updatePlaylist(playlist: Playlist?) { if (playlist == null) { @@ -236,7 +235,9 @@ class PlaylistDetailFragment : findNavController().navigateUp() 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) } @@ -279,18 +280,35 @@ class PlaylistDetailFragment : } private fun updateEditedPlaylist(editedPlaylist: List?) { - // 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) 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) { 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) } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt index 75306da26..81fde8777 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt @@ -106,10 +106,6 @@ class PlaylistDetailListAdapter(private val listener: Listener) : interface Listener : DetailListAdapter.Listener, EditableListListener { /** Called when the "edit" option is selected in the edit header. */ 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) setOnClickListener { listener.onStartEdit() } } - binding.headerConfirm.apply { + binding.headerSort.apply { TooltipCompat.setTooltipText(this, contentDescription) - setOnClickListener { listener.onConfirmEdit() } - } - binding.headerCancel.apply { - TooltipCompat.setTooltipText(this, contentDescription) - setOnClickListener { listener.onDropEdit() } + setOnClickListener(listener::onOpenSortMenu) } } @@ -184,12 +176,8 @@ private class EditHeaderViewHolder private constructor(private val binding: Item isGone = editing jumpDrawablesToCurrentState() } - binding.headerConfirm.apply { - isVisible = editing - jumpDrawablesToCurrentState() - } - binding.headerCancel.apply { - isVisible = editing + binding.headerSort.apply { + isGone = !editing jumpDrawablesToCurrentState() } } @@ -238,6 +226,7 @@ private constructor(private val binding: ItemEditableSongBinding) : elevation = binding.context.getDimen(R.dimen.elevation_normal) alpha = 0 } + init { binding.body.background = LayerDrawable( diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 87ece65f2..5f26f32d5 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -102,7 +102,7 @@ class HomeFragment : // --- UI SETUP --- binding.homeAppbar.addOnOffsetChangedListener(this) - binding.homeToolbar.apply { + binding.homeNormalToolbar.apply { setOnMenuItemClickListener(this@HomeFragment) MenuCompat.setGroupDividerEnabled(menu, true) } @@ -169,7 +169,7 @@ class HomeFragment : super.onDestroyBinding(binding) storagePermissionLauncher = null binding.homeAppbar.removeOnOffsetChangedListener(this) - binding.homeToolbar.setOnMenuItemClickListener(null) + binding.homeNormalToolbar.setOnMenuItemClickListener(null) } 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, // the alpha transition is shifted such that the Toolbar becomes fully transparent // when the AppBarLayout is only at half-collapsed. - binding.homeSelectionToolbar.alpha = - 1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2)) + binding.homeToolbar.alpha = 1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2)) binding.homeContent.updatePadding( bottom = binding.homeAppbar.totalScrollRange + verticalOffset) } @@ -243,7 +242,7 @@ class HomeFragment : binding.homePager.adapter = 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) { // A single tab makes the tab layout redundant, hide it and disable the collapsing // behavior. @@ -285,7 +284,7 @@ class HomeFragment : } 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) for (option in sortMenu) { @@ -456,11 +455,15 @@ class HomeFragment : private fun updateSelection(selected: List) { val binding = requireBinding() - if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) && - selected.isNotEmpty()) { - // New selection started, show the AppBarLayout to indicate the new state. - logD("Significant selection occurred, expanding AppBar") - binding.homeAppbar.expandWithScrollingRecycler() + if (selected.isNotEmpty()) { + binding.homeSelectionToolbar.title = getString(R.string.fmt_selected, selected.size) + if (binding.homeToolbar.setVisible(R.id.home_selection_toolbar)) { + // New selection started, show the AppBarLayout to indicate the new state. + logD("Significant selection occurred, expanding AppBar") + binding.homeAppbar.expandWithScrollingRecycler() + } + } else { + binding.homeToolbar.setVisible(R.id.home_normal_toolbar) } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index 6b1965c58..f81ed13fb 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -50,6 +50,7 @@ import okio.buffer import okio.source import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.ImageSettings +import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.logD @@ -66,10 +67,10 @@ constructor( val albums = computeAlbumOrdering(songs) val streams = mutableListOf() for (album in albums) { + openInputStream(album)?.let(streams::add) if (streams.size == 4) { return createMosaic(streams, size) } - openInputStream(album)?.let(streams::add) } return streams.firstOrNull()?.let { stream -> @@ -81,7 +82,7 @@ constructor( } fun computeAlbumOrdering(songs: List): Collection = - 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? = try { diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt index bcba5195e..cb7bce063 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt @@ -39,20 +39,13 @@ abstract class SelectionFragment : protected abstract val musicModel: MusicViewModel protected abstract val playbackModel: PlaybackViewModel - /** - * 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 + open fun getSelectionToolbar(binding: VB): Toolbar? = null override fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) getSelectionToolbar(binding)?.apply { // Add cancel and menu item listeners to manage what occurs with the selection. - setOnSelectionCancelListener { selectionModel.drop() } + setNavigationOnClickListener { selectionModel.drop() } setOnMenuItemClickListener(this@SelectionFragment) } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt deleted file mode 100644 index 7db98bb97..000000000 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt +++ /dev/null @@ -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 . - */ - -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 - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index b0a0feb06..71a80eb62 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -81,7 +81,7 @@ class SearchFragment : ListFragment() { imm = binding.context.getSystemServiceCompat(InputMethodManager::class) - binding.searchToolbar.apply { + binding.searchNormalToolbar.apply { // Initialize the current filtering mode. menu.findItem(searchModel.getFilterOptionId()).isChecked = true @@ -126,7 +126,7 @@ class SearchFragment : ListFragment() { override fun onDestroyBinding(binding: FragmentSearchBinding) { super.onDestroyBinding(binding) - binding.searchToolbar.setOnMenuItemClickListener(null) + binding.searchNormalToolbar.setOnMenuItemClickListener(null) binding.searchRecycler.adapter = null } @@ -198,10 +198,16 @@ class SearchFragment : ListFragment() { private fun updateSelection(selected: List) { searchAdapter.setSelected(selected.toSet()) - if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) && - selected.isNotEmpty()) { - // Make selection of obscured items easier by hiding the keyboard. - hideKeyboard() + val binding = requireBinding() + if (selected.isNotEmpty()) { + binding.searchSelectionToolbar.title = getString(R.string.fmt_selected, selected.size) + 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) } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/MultiToolbar.kt b/app/src/main/java/org/oxycblt/auxio/ui/MultiToolbar.kt new file mode 100644 index 000000000..657b5c6ca --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/ui/MultiToolbar.kt @@ -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 . + */ + +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 + } + } +} diff --git a/app/src/main/res/drawable/ic_save_24.xml b/app/src/main/res/drawable/ic_save_24.xml new file mode 100644 index 000000000..3761438c0 --- /dev/null +++ b/app/src/main/res/drawable/ic_save_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/layout/fragment_detail.xml b/app/src/main/res/layout/fragment_detail.xml index 82a5fc5fa..a272ca07f 100644 --- a/app/src/main/res/layout/fragment_detail.xml +++ b/app/src/main/res/layout/fragment_detail.xml @@ -13,19 +13,38 @@ app:liftOnScroll="true" app:liftOnScrollTargetViewId="@id/detail_recycler"> - - + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 8fb877122..712509a65 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -12,20 +12,29 @@ android:id="@+id/home_appbar" style="@style/Widget.Auxio.AppBarLayout"> - - + + + - - + + + diff --git a/app/src/main/res/layout/item_edit_header.xml b/app/src/main/res/layout/item_edit_header.xml index 3b999323e..02d528635 100644 --- a/app/src/main/res/layout/item_edit_header.xml +++ b/app/src/main/res/layout/item_edit_header.xml @@ -29,22 +29,13 @@ app:layout_constraintEnd_toEndOf="parent" /> - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_edit_actions.xml b/app/src/main/res/menu/menu_edit_actions.xml new file mode 100644 index 000000000..10ac3d9ef --- /dev/null +++ b/app/src/main/res/menu/menu_edit_actions.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file