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 fc6aa2b9d..66c3cd983 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -85,6 +85,14 @@ class AlbumDetailFragment : DetailFragment() { detailAdapter.submitList(data) } + detailModel.showMenu.observe(viewLifecycleOwner) { config -> + if (config != null) { + showMenu(config) { id -> + id == R.id.option_sort_asc || id == R.id.option_sort_dsc + } + } + } + detailModel.navToItem.observe(viewLifecycleOwner) { item -> when (item) { // Songs should be scrolled to if the album matches, or a new detail 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 0aed4c8cc..4311d5485 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -24,6 +24,7 @@ import android.view.View import android.view.ViewGroup import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs +import org.oxycblt.auxio.R import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter import org.oxycblt.auxio.music.ActionHeader import org.oxycblt.auxio.music.Album @@ -85,6 +86,14 @@ class ArtistDetailFragment : DetailFragment() { detailAdapter.submitList(data) } + detailModel.showMenu.observe(viewLifecycleOwner) { config -> + if (config != null) { + showMenu(config) { id -> + id != R.id.option_sort_artist + } + } + } + detailModel.navToItem.observe(viewLifecycleOwner) { item -> when (item) { is Artist -> { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt index c38031d01..75434d813 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt @@ -22,14 +22,18 @@ import android.os.Bundle import android.view.View import androidx.activity.OnBackPressedCallback import androidx.annotation.MenuRes +import androidx.appcompat.widget.PopupMenu +import androidx.core.view.forEach import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.ui.SortMode import org.oxycblt.auxio.ui.memberBinding import org.oxycblt.auxio.util.applyEdge import org.oxycblt.auxio.util.isLandscape @@ -63,6 +67,13 @@ abstract class DetailFragment : Fragment() { callback.isEnabled = false } + override fun onStop() { + super.onStop() + + // Cancel all pending menus when this fragment stops to prevent bugs/crashes + detailModel.finishShowMenu(null, requireContext()) + } + /** * Shortcut method for doing setup of the detail toolbar. * @param menu Menu resource to use @@ -113,6 +124,37 @@ abstract class DetailFragment : Fragment() { } } + /** + * Shortcut method for spinning up the sorting [PopupMenu] + * @param config The initial configuration to apply to the menu. This is provided by [DetailViewModel.showMenu]. + * @param showItem Which menu items to keep + */ + protected fun showMenu(config: DetailViewModel.MenuConfig, showItem: ((Int) -> Boolean)? = null) { + PopupMenu(config.anchor.context, config.anchor).apply { + inflate(R.menu.menu_detail_sort) + + setOnMenuItemClickListener { item -> + item.isChecked = true + detailModel.finishShowMenu(SortMode.fromId(item.itemId)!!, config.anchor.context) + true + } + + setOnDismissListener { + detailModel.finishShowMenu(null, config.anchor.context) + } + + if (showItem != null) { + menu.forEach { item -> + item.isVisible = showItem(item.itemId) + } + } + + menu.findItem(config.sortMode.itemId).isChecked = true + + show() + } + } + // Override the back button so that going back will only exit the detail fragments instead of // the entire app. private val callback = object : OnBackPressedCallback(false) { 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 f4dc3dc67..1f17f79b1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -19,6 +19,7 @@ package org.oxycblt.auxio.detail import android.content.Context +import android.view.View import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -30,12 +31,16 @@ import org.oxycblt.auxio.music.BaseModel import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.MusicStore +import org.oxycblt.auxio.settings.SettingsManager +import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.SortMode /** - * ViewModel that stores data for the [DetailFragment]s, such as what they're showing & what - * [SortMode] they are currently on. - * TODO: Re-add sorting + * ViewModel that stores data for the [DetailFragment]s. This includes: + * - What item the fragment should be showing + * - The RecyclerView data for each fragment + * - Menu triggers for each fragment + * - Navigation triggers for each fragment [e.g "Go to artist"] * @author OxygenCobalt */ class DetailViewModel : ViewModel() { @@ -59,93 +64,67 @@ class DetailViewModel : ViewModel() { private val mAlbumData = MutableLiveData(listOf()) val albumData: LiveData> get() = mAlbumData - var isNavigating = false - private set + data class MenuConfig(val anchor: View, val sortMode: SortMode) + + private val mShowMenu = MutableLiveData(null) + val showMenu: LiveData = mShowMenu private val mNavToItem = MutableLiveData() - /** Flag for unified navigation. Observe this to coordinate navigation to an item's UI. */ val navToItem: LiveData get() = mNavToItem + var isNavigating = false + private set + + private var currentMenuContext: DisplayMode? = null + private val musicStore = MusicStore.getInstance() + private val settingsManager = SettingsManager.getInstance() fun setGenre(id: Long, context: Context) { if (mCurGenre.value?.id == id) return - mCurGenre.value = musicStore.genres.find { it.id == id } - - val data = mutableListOf(curGenre.value!!) - - data.add( - ActionHeader( - id = -2, - name = context.getString(R.string.lbl_songs), - icon = R.drawable.ic_sort, - desc = R.string.lbl_sort, - onClick = { - } - ) - ) - - data.addAll(SortMode.ASCENDING.sortGenre(curGenre.value!!)) - - mGenreData.value = data + refreshGenreData(context) } fun setArtist(id: Long, context: Context) { if (mCurArtist.value?.id == id) return - mCurArtist.value = musicStore.artists.find { it.id == id } - - val artist = curArtist.value!! - val data = mutableListOf(artist) - - data.add( - Header( - id = -2, - name = context.getString(R.string.lbl_albums) - ) - ) - - data.addAll(SortMode.YEAR.sortAlbums(artist.albums)) - - data.add( - ActionHeader( - id = -3, - name = context.getString(R.string.lbl_songs), - icon = R.drawable.ic_sort, - desc = R.string.lbl_sort, - onClick = { - } - ) - ) - - data.addAll(SortMode.YEAR.sortArtist(artist)) - - mArtistData.value = data.toList() + refreshArtistData(context) } fun setAlbum(id: Long, context: Context) { if (mCurAlbum.value?.id == id) return - mCurAlbum.value = musicStore.albums.find { it.id == id } + refreshAlbumData(context) + } - val data = mutableListOf(curAlbum.value!!) + /** + * Mark that the menu process is done with the new [SortMode]. + * Pass null if there was no change. + */ + fun finishShowMenu(newMode: SortMode?, context: Context) { + mShowMenu.value = null - data.add( - ActionHeader( - id = -2, - name = context.getString(R.string.lbl_songs), - icon = R.drawable.ic_sort, - desc = R.string.lbl_sort, - onClick = { + if (newMode != null) { + when (currentMenuContext) { + DisplayMode.SHOW_ALBUMS -> { + settingsManager.albumSortMode = newMode + refreshAlbumData(context) } - ) - ) + DisplayMode.SHOW_ARTISTS -> { + settingsManager.artistSortMode = newMode + refreshArtistData(context) + } + DisplayMode.SHOW_GENRES -> { + settingsManager.genreSortMode = newMode + refreshGenreData(context) + } + else -> {} + } + } - data.addAll(SortMode.ASCENDING.sortAlbum(curAlbum.value!!)) - - mAlbumData.value = data + currentMenuContext = null } /** @@ -168,4 +147,77 @@ class DetailViewModel : ViewModel() { fun setNavigating(navigating: Boolean) { isNavigating = navigating } + + private fun refreshGenreData(context: Context) { + val data = mutableListOf(curGenre.value!!) + + data.add( + ActionHeader( + id = -2, + name = context.getString(R.string.lbl_songs), + icon = R.drawable.ic_sort, + desc = R.string.lbl_sort, + onClick = { view -> + currentMenuContext = DisplayMode.SHOW_GENRES + mShowMenu.value = MenuConfig(view, settingsManager.genreSortMode) + } + ) + ) + + data.addAll(settingsManager.genreSortMode.sortGenre(curGenre.value!!)) + + mGenreData.value = data + } + + private fun refreshArtistData(context: Context) { + val artist = curArtist.value!! + val data = mutableListOf(artist) + + data.add( + Header( + id = -2, + name = context.getString(R.string.lbl_albums) + ) + ) + + data.addAll(SortMode.YEAR.sortAlbums(artist.albums)) + + data.add( + ActionHeader( + id = -3, + name = context.getString(R.string.lbl_songs), + icon = R.drawable.ic_sort, + desc = R.string.lbl_sort, + onClick = { view -> + currentMenuContext = DisplayMode.SHOW_ARTISTS + mShowMenu.value = MenuConfig(view, settingsManager.artistSortMode) + } + ) + ) + + data.addAll(settingsManager.artistSortMode.sortArtist(artist)) + + mArtistData.value = data.toList() + } + + private fun refreshAlbumData(context: Context) { + val data = mutableListOf(curAlbum.value!!) + + data.add( + ActionHeader( + id = -2, + name = context.getString(R.string.lbl_songs), + icon = R.drawable.ic_sort, + desc = R.string.lbl_sort, + onClick = { view -> + currentMenuContext = DisplayMode.SHOW_ALBUMS + mShowMenu.value = MenuConfig(view, settingsManager.albumSortMode) + } + ) + ) + + data.addAll(settingsManager.albumSortMode.sortAlbum(curAlbum.value!!)) + + mAlbumData.value = data + } } 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 67533f1cf..3aab76b95 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -109,6 +109,12 @@ class GenreDetailFragment : DetailFragment() { } } + detailModel.showMenu.observe(viewLifecycleOwner) { config -> + if (config != null) { + showMenu(config) + } + } + playbackModel.isInUserQueue.observe(viewLifecycleOwner) { inUserQueue -> if (inUserQueue) { detailAdapter.highlightSong(null, binding.detailRecycler) diff --git a/app/src/main/java/org/oxycblt/auxio/music/Models.kt b/app/src/main/java/org/oxycblt/auxio/music/Models.kt index 3859d0754..225f28938 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Models.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Models.kt @@ -237,5 +237,28 @@ data class ActionHeader( override val name: String, @DrawableRes val icon: Int, @StringRes val desc: Int, - val onClick: (View) -> Unit -) : BaseModel() + val onClick: (View) -> Unit, +) : BaseModel() { + // JVM can't into comparing lambdas, so we override equals/hashCode and exclude them. + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ActionHeader) return false + + if (id != other.id) return false + if (name != other.name) return false + if (icon != other.icon) return false + if (desc != other.desc) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + icon + result = 31 * result + desc + + return result + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt index 2fdb8d0ef..e1aadd187 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt @@ -26,6 +26,7 @@ import org.oxycblt.auxio.accent.ACCENTS import org.oxycblt.auxio.accent.Accent import org.oxycblt.auxio.playback.state.PlaybackMode import org.oxycblt.auxio.ui.DisplayMode +import org.oxycblt.auxio.ui.SortMode /** * Wrapper around the [SharedPreferences] class that writes & reads values without a context. @@ -118,6 +119,36 @@ class SettingsManager private constructor(context: Context) : } } + var albumSortMode: SortMode + get() = SortMode.fromInt(sharedPrefs.getInt(KEY_ALBUM_SORT, Int.MIN_VALUE)) + ?: SortMode.ASCENDING + set(value) { + sharedPrefs.edit { + putInt(KEY_ALBUM_SORT, value.toInt()) + apply() + } + } + + var artistSortMode: SortMode + get() = SortMode.fromInt(sharedPrefs.getInt(KEY_ARTIST_SORT, Int.MIN_VALUE)) + ?: SortMode.YEAR + set(value) { + sharedPrefs.edit { + putInt(KEY_ARTIST_SORT, value.toInt()) + apply() + } + } + + var genreSortMode: SortMode + get() = SortMode.fromInt(sharedPrefs.getInt(KEY_GENRE_SORT, Int.MIN_VALUE)) + ?: SortMode.ASCENDING + set(value) { + sharedPrefs.edit { + putInt(KEY_GENRE_SORT, value.toInt()) + apply() + } + } + // --- CALLBACKS --- private val callbacks = mutableListOf() @@ -181,6 +212,9 @@ class SettingsManager private constructor(context: Context) : const val KEY_BLACKLIST = "KEY_BLACKLIST" const val KEY_SEARCH_FILTER_MODE = "KEY_SEARCH_FILTER" + const val KEY_ALBUM_SORT = "KEY_ALBUM_SORT" + const val KEY_ARTIST_SORT = "KEY_ARTIST_SORT" + const val KEY_GENRE_SORT = "KEY_GENRE_SORT" @Volatile private var INSTANCE: SettingsManager? = null diff --git a/app/src/main/java/org/oxycblt/auxio/ui/SortMode.kt b/app/src/main/java/org/oxycblt/auxio/ui/SortMode.kt index 45629eb88..bfff59f12 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/SortMode.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/SortMode.kt @@ -147,7 +147,36 @@ enum class SortMode(@IdRes val itemId: Int) { return sortSongs(genre.songs) } + /** + * Converts this mode into an integer constant. Use this when writing a [SortMode] + * to storage, as it will be more efficent. + */ + fun toInt(): Int { + return ordinal + INT_ASCENDING + } + companion object { + private const val INT_ASCENDING = 0xA10C + private const val INT_DESCENDING = 0xA10D + private const val INT_ARTIST = 0xA10E + private const val INT_ALBUM = 0xA10F + private const val INT_YEAR = 0xA110 + + /** + * Returns a [SortMode] depending on the integer constant, use this when restoring + * a [SortMode] from storage. + */ + fun fromInt(value: Int): SortMode? { + return when (value) { + INT_ASCENDING -> ASCENDING + INT_DESCENDING -> DESCENDING + INT_ARTIST -> ASCENDING + INT_ALBUM -> ALBUM + INT_YEAR -> YEAR + else -> null + } + } + /** * Convert a menu [id] to an instance of [SortMode]. */ diff --git a/app/src/main/res/menu/menu_detail_sort.xml b/app/src/main/res/menu/menu_detail_sort.xml new file mode 100644 index 000000000..3b7440e1a --- /dev/null +++ b/app/src/main/res/menu/menu_detail_sort.xml @@ -0,0 +1,20 @@ + + + + + + + + + + \ No newline at end of file diff --git a/info/ARCHITECTURE.md b/info/ARCHITECTURE.md index 1c57ddf3a..0423277e4 100644 --- a/info/ARCHITECTURE.md +++ b/info/ARCHITECTURE.md @@ -76,6 +76,12 @@ To prevent any strange bugs, all integer representations must be unique. A table 0xA109 | DisplayMode.SHOW_ARTISTS 0xA10A | DisplayMode.SHOW_ALBUMS 0xA10B | DisplayMode.SHOW_SONGS + +0xA10C | SortMode.ASCENDING +0xA10D | SortMode.DESCENDING +0xA10E | SortMode.ARTIST +0xA10F | SortMode.ALBUM +0xA110 | SortMode.YEAR ``` #### Package structure overview