From 3ddf0347eadb3a2ffe59235a8da544a9d7d3cc00 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 26 Jun 2023 19:35:58 -0600 Subject: [PATCH 01/72] build: downgrade navigation to 2.5.0 Temporarily band-aids #492. --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 5c4648215..efe210373 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { kotlin_version = '1.8.22' - navigation_version = "2.6.0" + navigation_version = "2.5.0" hilt_version = '2.46.1' } From 07e9ca8ef6bd1a49be786b57b4bb4aa270af3d3b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 27 Jun 2023 17:30:11 -0600 Subject: [PATCH 02/72] ui: flatten nav graph Flatten the navigation graph into a single "main" graph that links home to both explore and preference fragments. ***This massively breaks the app in it's current state***. Further changes and refactors are needed to get this back to working. --- .../java/org/oxycblt/auxio/MainFragment.kt | 123 ++---- .../auxio/detail/AlbumDetailFragment.kt | 98 +++-- .../auxio/detail/ArtistDetailFragment.kt | 96 +++-- .../oxycblt/auxio/detail/DetailViewModel.kt | 52 +++ .../auxio/detail/GenreDetailFragment.kt | 101 +++-- .../auxio/detail/PlaylistDetailFragment.kt | 116 ++++-- .../oxycblt/auxio/detail/SongDetailDialog.kt | 10 + .../picker/ArtistShowChoice.kt} | 10 +- .../picker/NavigationPickerViewModel.kt | 60 +-- .../picker/ShowArtistDialog.kt} | 18 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 81 ++-- .../auxio/home/list/AlbumListFragment.kt | 6 +- .../auxio/home/list/ArtistListFragment.kt | 6 +- .../auxio/home/list/GenreListFragment.kt | 6 +- .../auxio/home/list/PlaylistListFragment.kt | 6 +- .../auxio/home/list/SongListFragment.kt | 4 +- .../org/oxycblt/auxio/list/ListFragment.kt | 16 +- .../auxio/navigation/MainNavigationAction.kt | 44 -- .../auxio/navigation/NavigationViewModel.kt | 127 ------ .../auxio/playback/PlaybackBarFragment.kt | 9 +- .../auxio/playback/PlaybackPanelFragment.kt | 24 +- .../auxio/playback/PlaybackViewModel.kt | 24 ++ .../oxycblt/auxio/search/SearchFragment.kt | 70 +++- .../auxio/settings/RootPreferenceFragment.kt | 12 +- .../categories/AudioPreferenceFragment.kt | 2 +- .../categories/MusicPreferenceFragment.kt | 3 +- .../PersonalizePreferenceFragment.kt | 3 +- .../categories/UIPreferenceFragment.kt | 2 +- .../res/layout-w600dp-land/fragment_main.xml | 3 +- app/src/main/res/layout/activity_main.xml | 4 +- app/src/main/res/layout/fragment_main.xml | 3 +- app/src/main/res/navigation/main.xml | 388 ++++++++++++++++++ app/src/main/res/navigation/nav_explore.xml | 106 ----- app/src/main/res/navigation/nav_main.xml | 214 ---------- build.gradle | 2 +- 35 files changed, 968 insertions(+), 881 deletions(-) rename app/src/main/java/org/oxycblt/auxio/{navigation/picker/ArtistNavigationChoiceAdapter.kt => detail/picker/ArtistShowChoice.kt} (91%) rename app/src/main/java/org/oxycblt/auxio/{navigation => detail}/picker/NavigationPickerViewModel.kt (68%) rename app/src/main/java/org/oxycblt/auxio/{navigation/picker/NavigateToArtistDialog.kt => detail/picker/ShowArtistDialog.kt} (85%) delete mode 100644 app/src/main/java/org/oxycblt/auxio/navigation/MainNavigationAction.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/navigation/NavigationViewModel.kt create mode 100644 app/src/main/res/navigation/main.xml delete mode 100644 app/src/main/res/navigation/nav_explore.xml delete mode 100644 app/src/main/res/navigation/nav_main.xml diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index ecc8f21f5..00c88bd53 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -31,7 +31,6 @@ import androidx.fragment.app.activityViewModels import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.findNavController -import androidx.navigation.fragment.findNavController import com.google.android.material.R as MR import com.google.android.material.bottomsheet.BackportBottomSheetBehavior import com.google.android.material.shape.MaterialShapeDrawable @@ -43,23 +42,18 @@ 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 -import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.navigation.MainNavigationAction -import org.oxycblt.auxio.navigation.NavigationViewModel +import org.oxycblt.auxio.playback.Panel import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior import org.oxycblt.auxio.ui.ViewBindingFragment -import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.coordinatorLayoutBehavior import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.unlikelyToBeNull @@ -76,8 +70,6 @@ class MainFragment : ViewBindingFragment(), ViewTreeObserver.OnPreDrawListener, NavController.OnDestinationChangedListener { - private val navModel: NavigationViewModel by activityViewModels() - private val musicModel: MusicViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels() private val selectionModel: SelectionViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() @@ -148,11 +140,7 @@ class MainFragment : // In portrait mode, set up click listeners on the stacked sheets. logD("Configuring stacked bottom sheets") unlikelyToBeNull(binding.queueHandleWrapper).setOnClickListener { - if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED && - queueSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_COLLAPSED) { - // Playback sheet is expanded and queue sheet is collapsed, we can expand it. - queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED - } + playbackModel.openQueue() } } else { // Dual-pane mode, manually style the static queue sheet. @@ -175,16 +163,8 @@ class MainFragment : // --- VIEWMODEL SETUP --- collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) collectImmediately(selectionModel.selected, selectionBackCallback::invalidateEnabled) - collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist) - collect(musicModel.playlistToRename.flow, ::handleRenamePlaylist) - collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist) - collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist) - collect(navModel.mainNavigationAction.flow, ::handleMainNavigation) - collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation) - collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker) collectImmediately(playbackModel.song, ::updateSong) - collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker) - collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker) + collectImmediately(playbackModel.openPanel.flow, ::handlePanel) } override fun onStart() { @@ -322,33 +302,6 @@ class MainFragment : selectionModel.drop() } - private fun handleMainNavigation(action: MainNavigationAction?) { - if (action != null) { - when (action) { - is MainNavigationAction.OpenPlaybackPanel -> tryOpenPlaybackPanel() - is MainNavigationAction.ClosePlaybackPanel -> tryClosePlaybackPanel() - is MainNavigationAction.Directions -> - findNavController().navigateSafe(action.directions) - } - navModel.mainNavigationAction.consume() - } - } - - private fun handleExploreNavigation(item: Music?) { - if (item != null) { - tryClosePlaybackPanel() - } - } - - private fun handleArtistNavigationPicker(item: Music?) { - if (item != null) { - navModel.mainNavigateTo( - MainNavigationAction.Directions( - MainFragmentDirections.actionPickNavigationArtist(item.uid))) - navModel.exploreArtistNavigationItem.consume() - } - } - private fun updateSong(song: Song?) { if (song != null) { tryShowSheets() @@ -357,56 +310,15 @@ class MainFragment : } } - private fun handleNewPlaylist(songs: List?) { - if (songs != null) { - findNavController() - .navigateSafe( - MainFragmentDirections.actionNewPlaylist(songs.map { it.uid }.toTypedArray())) - musicModel.newPlaylistSongs.consume() - } - } - - private fun handleRenamePlaylist(playlist: Playlist?) { - if (playlist != null) { - findNavController() - .navigateSafe(MainFragmentDirections.actionRenamePlaylist(playlist.uid)) - musicModel.playlistToRename.consume() - } - } - - private fun handleDeletePlaylist(playlist: Playlist?) { - if (playlist != null) { - findNavController() - .navigateSafe(MainFragmentDirections.actionDeletePlaylist(playlist.uid)) - musicModel.playlistToDelete.consume() - } - } - - private fun handleAddToPlaylist(songs: List?) { - if (songs != null) { - findNavController() - .navigateSafe( - MainFragmentDirections.actionAddToPlaylist(songs.map { it.uid }.toTypedArray())) - musicModel.songsToAdd.consume() - } - } - - private fun handlePlaybackArtistPicker(song: Song?) { - if (song != null) { - navModel.mainNavigateTo( - MainNavigationAction.Directions( - MainFragmentDirections.actionPickPlaybackArtist(song.uid))) - playbackModel.artistPickerSong.consume() - } - } - - private fun handlePlaybackGenrePicker(song: Song?) { - if (song != null) { - navModel.mainNavigateTo( - MainNavigationAction.Directions( - MainFragmentDirections.actionPickPlaybackGenre(song.uid))) - playbackModel.genrePickerSong.consume() + private fun handlePanel(panel: Panel?) { + if (panel == null) return + logD("Trying to update panel to $panel") + when (panel) { + is Panel.Main -> tryClosePlaybackPanel() + is Panel.Playback -> tryOpenPlaybackPanel() + is Panel.Queue -> tryOpenQueuePanel() } + playbackModel.openPanel.consume() } private fun tryOpenPlaybackPanel() { @@ -446,6 +358,19 @@ class MainFragment : } } + private fun tryOpenQueuePanel() { + val binding = requireBinding() + val playbackSheetBehavior = + binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior + val queueSheetBehavior = + (binding.queueSheet.coordinatorLayoutBehavior ?: return) as QueueBottomSheetBehavior + if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED && + queueSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_COLLAPSED) { + // Playback sheet is expanded and queue sheet is collapsed, we can expand it. + queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED + } + } + private fun tryShowSheets() { val binding = requireBinding() val playbackSheetBehavior = 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 d32f5254b..8844de56e 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -42,14 +42,12 @@ import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.info.Disc -import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.canScroll import org.oxycblt.auxio.util.collect @@ -72,11 +70,10 @@ class AlbumDetailFragment : ListFragment(), AlbumDetailHeaderAdapter.Listener, DetailListAdapter.Listener { - private val detailModel: DetailViewModel by activityViewModels() - override val navModel: NavigationViewModel by activityViewModels() - override val playbackModel: PlaybackViewModel by activityViewModels() - override val musicModel: MusicViewModel by activityViewModels() + override val detailModel: DetailViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() + override val musicModel: MusicViewModel by activityViewModels() + override val playbackModel: PlaybackViewModel by activityViewModels() // Information about what album to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an album. private val args: AlbumDetailFragmentArgs by navArgs() @@ -125,10 +122,12 @@ class AlbumDetailFragment : detailModel.setAlbum(args.albumUid) collectImmediately(detailModel.currentAlbum, ::updateAlbum) collectImmediately(detailModel.albumList, ::updateList) + collect(detailModel.toShow.flow, ::handleShow) + collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) - collect(navModel.exploreNavigationItem.flow, ::handleNavigation) - collectImmediately(selectionModel.selected, ::updateSelection) + collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist) + collect(playbackModel.genrePickerSong.flow, ::handlePlayFromGenre) } override fun onDestroyBinding(binding: FragmentDetailBinding) { @@ -221,7 +220,7 @@ class AlbumDetailFragment : } override fun onNavigateToParentArtist() { - navModel.exploreNavigateToParentArtist(unlikelyToBeNull(detailModel.currentAlbum.value)) + detailModel.showAlbum(unlikelyToBeNull(detailModel.currentAlbum.value)) } private fun updateAlbum(album: Album?) { @@ -234,53 +233,88 @@ class AlbumDetailFragment : albumHeaderAdapter.setParent(album) } - private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - albumListAdapter.setPlaying( - song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying) + private fun updateList(list: List) { + albumListAdapter.update(list, detailModel.albumInstructions.consume()) } - private fun handleNavigation(item: Music?) { + private fun handleShow(show: Show?) { val binding = requireBinding() - when (item) { + when (show) { + is Show.SongDetails -> { + logD("Navigating to ${show.song}") + findNavController() + .navigateSafe(AlbumDetailFragmentDirections.showSong(show.song.uid)) + } + // Songs should be scrolled to if the album matches, or a new detail // fragment should be launched otherwise. - is Song -> { - if (unlikelyToBeNull(detailModel.currentAlbum.value) == item.album) { - logD("Navigating to a song in this album") - scrollToAlbumSong(item) - navModel.exploreNavigationItem.consume() + is Show.SongAlbumDetails -> { + if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.song.album) { + logD("Navigating to a ${show.song} in this album") + scrollToAlbumSong(show.song) + detailModel.toShow.consume() } else { - logD("Navigating to another album") + logD("Navigating to the album of ${show.song}") findNavController() - .navigateSafe(AlbumDetailFragmentDirections.actionShowAlbum(item.album.uid)) + .navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.song.album.uid)) } } // If the album matches, no need to do anything. Otherwise launch a new // detail fragment. - is Album -> { - if (unlikelyToBeNull(detailModel.currentAlbum.value) == item) { + is Show.AlbumDetails -> { + if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.album) { logD("Navigating to the top of this album") binding.detailRecycler.scrollToPosition(0) - navModel.exploreNavigationItem.consume() + detailModel.toShow.consume() } else { - logD("Navigating to another album") + logD("Navigating to ${show.album}") findNavController() - .navigateSafe(AlbumDetailFragmentDirections.actionShowAlbum(item.uid)) + .navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.album.uid)) } } // Always launch a new ArtistDetailFragment. - is Artist -> { - logD("Navigating to another artist") + is Show.ArtistDetails -> { + logD("Navigating to ${show.artist}") + findNavController() + .navigateSafe(AlbumDetailFragmentDirections.showArtist(show.artist.uid)) + } + is Show.SongArtistDetails -> { + logD("Navigating to artist choices for ${show.song}") findNavController() - .navigateSafe(AlbumDetailFragmentDirections.actionShowArtist(item.uid)) + .navigateSafe(AlbumDetailFragmentDirections.showArtist(show.song.uid)) + } + is Show.AlbumArtistDetails -> { + logD("Navigating to artist choices for ${show.album}") + findNavController() + .navigateSafe(AlbumDetailFragmentDirections.showArtist(show.album.uid)) + } + is Show.GenreDetails, + is Show.PlaylistDetails -> { + error("Unexpected show command $show") } null -> {} - else -> error("Unexpected datatype: ${item::class.java}") } } + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { + albumListAdapter.setPlaying( + song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying) + } + + private fun handlePlayFromArtist(song: Song?) { + if (song == null) return + logD("Launching play from artist dialog for $song") + findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromArtist(song.uid)) + } + + private fun handlePlayFromGenre(song: Song?) { + if (song == null) return + logD("Launching play from genre dialog for $song") + findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromGenre(song.uid)) + } + private fun scrollToAlbumSong(song: Song) { // Calculate where the item for the currently played song is val pos = detailModel.albumList.value.indexOf(song) @@ -319,10 +353,6 @@ class AlbumDetailFragment : } } - private fun updateList(list: List) { - albumListAdapter.update(list, detailModel.albumInstructions.consume()) - } - private fun updateSelection(selected: List) { albumListAdapter.setSelected(selected.toSet()) 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 601c2ed50..b55bc9be1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -47,7 +47,6 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately @@ -69,11 +68,10 @@ class ArtistDetailFragment : ListFragment(), DetailHeaderAdapter.Listener, DetailListAdapter.Listener { - private val detailModel: DetailViewModel by activityViewModels() - override val navModel: NavigationViewModel by activityViewModels() - override val playbackModel: PlaybackViewModel by activityViewModels() - override val musicModel: MusicViewModel by activityViewModels() + override val detailModel: DetailViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() + override val musicModel: MusicViewModel by activityViewModels() + override val playbackModel: PlaybackViewModel by activityViewModels() // Information about what artist to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an artist. private val args: ArtistDetailFragmentArgs by navArgs() @@ -125,9 +123,11 @@ class ArtistDetailFragment : detailModel.setArtist(args.artistUid) collectImmediately(detailModel.currentArtist, ::updateArtist) collectImmediately(detailModel.artistList, ::updateList) + collect(detailModel.toShow.flow, ::handleShow) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) - collect(navModel.exploreNavigationItem.flow, ::handleNavigation) + collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist) + collect(playbackModel.genrePickerSong.flow, ::handlePlayFromGenre) collectImmediately(selectionModel.selected, ::updateSelection) } @@ -174,7 +174,7 @@ class ArtistDetailFragment : override fun onRealClick(item: Music) { when (item) { - is Album -> navModel.exploreNavigateTo(item) + is Album -> detailModel.showAlbum(item) is Song -> { val playbackMode = detailModel.playbackMode if (playbackMode != null) { @@ -257,58 +257,82 @@ class ArtistDetailFragment : artistHeaderAdapter.setParent(artist) } - private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value) - val playingItem = - when (parent) { - // Always highlight a playing album if it's from this artist, and if the currently - // playing song is contained within. - is Album -> parent.takeIf { song?.album == it } - // If the parent is the artist itself, use the currently playing song. - currentArtist -> song - // Nothing is playing from this artist. - else -> null - } - artistListAdapter.setPlaying(playingItem, isPlaying) + private fun updateList(list: List) { + artistListAdapter.update(list, detailModel.artistInstructions.consume()) } - private fun handleNavigation(item: Music?) { + private fun handleShow(show: Show?) { val binding = requireBinding() + when (show) { + is Show.SongDetails -> { + logD("Navigating to ${show.song}") + findNavController() + .navigateSafe(ArtistDetailFragmentDirections.showSong(show.song.uid)) + } - when (item) { // Songs should be shown in their album, not in their artist. - is Song -> { - logD("Navigating to another album") + is Show.SongAlbumDetails -> { + logD("Navigating to the album of ${show.song}") findNavController() - .navigateSafe(ArtistDetailFragmentDirections.actionShowAlbum(item.album.uid)) + .navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.song.album.uid)) } + // Launch a new detail view for an album, even if it is part of // this artist. - is Album -> { - logD("Navigating to another album") + is Show.AlbumDetails -> { + logD("Navigating to ${show.album}") findNavController() - .navigateSafe(ArtistDetailFragmentDirections.actionShowAlbum(item.uid)) + .navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.album.uid)) } + // If the artist that should be navigated to is this artist, then // scroll back to the top. Otherwise launch a new detail view. - is Artist -> { - if (item.uid == detailModel.currentArtist.value?.uid) { + is Show.ArtistDetails -> { + if (show.artist == detailModel.currentArtist.value) { logD("Navigating to the top of this artist") binding.detailRecycler.scrollToPosition(0) - navModel.exploreNavigationItem.consume() + detailModel.toShow.consume() } else { - logD("Navigating to another artist") + logD("Navigating to ${show.artist}") findNavController() - .navigateSafe(ArtistDetailFragmentDirections.actionShowArtist(item.uid)) + .navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid)) } } + is Show.SongArtistDetails, + is Show.AlbumArtistDetails, + is Show.GenreDetails, + is Show.PlaylistDetails -> { + error("Unexpected show command $show") + } null -> {} - else -> error("Unexpected datatype: ${item::class.java}") } } - private fun updateList(list: List) { - artistListAdapter.update(list, detailModel.artistInstructions.consume()) + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { + val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value) + val playingItem = + when (parent) { + // Always highlight a playing album if it's from this artist, and if the currently + // playing song is contained within. + is Album -> parent.takeIf { song?.album == it } + // If the parent is the artist itself, use the currently playing song. + currentArtist -> song + // Nothing is playing from this artist. + else -> null + } + artistListAdapter.setPlaying(playingItem, isPlaying) + } + + private fun handlePlayFromArtist(song: Song?) { + if (song == null) return + logD("Launching play from artist dialog for $song") + findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromArtist(song.uid)) + } + + private fun handlePlayFromGenre(song: Song?) { + if (song == null) return + logD("Launching play from genre dialog for $song") + findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromGenre(song.uid)) } private fun updateSelection(selected: List) { 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 c63c76151..de285ffa9 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -70,6 +70,10 @@ constructor( private val musicSettings: MusicSettings, private val playbackSettings: PlaybackSettings ) : ViewModel(), MusicRepository.UpdateListener { + private val _toShow = MutableEvent() + val toShow: Event + get() = _toShow + // --- SONG --- private var currentSongJob: Job? = null @@ -237,6 +241,43 @@ constructor( } } + fun showSong(song: Song) = showImpl(Show.SongDetails(song)) + + fun showAlbum(song: Song) = showImpl(Show.SongAlbumDetails(song)) + + fun showAlbum(album: Album) = showImpl(Show.AlbumDetails(album)) + + fun showArtist(song: Song) = + showImpl( + if (song.artists.size > 1) { + Show.SongArtistDetails(song) + } else { + Show.ArtistDetails(song.artists.first()) + }) + + fun showArtist(album: Album) = + showImpl( + if (album.artists.size > 1) { + Show.AlbumArtistDetails(album) + } else { + Show.ArtistDetails(album.artists.first()) + }) + + fun showArtist(artist: Artist) = showImpl(Show.ArtistDetails(artist)) + + fun showGenre(genre: Genre) = showImpl(Show.GenreDetails(genre)) + + fun showPlaylist(playlist: Playlist) = showImpl(Show.PlaylistDetails(playlist)) + + private fun showImpl(show: Show) { + val existing = toShow.flow.value + if (existing != null) { + logD("Already have pending show command $existing, ignoring $show") + return + } + _toShow.put(show) + } + /** * Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will * be updated to align with the new [Song]. @@ -582,3 +623,14 @@ constructor( val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) } } + +sealed interface Show { + data class SongDetails(val song: Song) : Show + data class AlbumDetails(val album: Album) : Show + data class SongAlbumDetails(val song: Song) : Show + data class ArtistDetails(val artist: Artist) : Show + data class SongArtistDetails(val song: Song) : Show + data class AlbumArtistDetails(val album: Album) : Show + data class GenreDetails(val genre: Genre) : Show + data class PlaylistDetails(val playlist: Playlist) : Show +} 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 3968c1379..5934d4a11 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -41,14 +41,12 @@ import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.selection.SelectionViewModel -import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately @@ -70,11 +68,10 @@ class GenreDetailFragment : ListFragment(), DetailHeaderAdapter.Listener, DetailListAdapter.Listener { - private val detailModel: DetailViewModel by activityViewModels() - override val navModel: NavigationViewModel by activityViewModels() - override val playbackModel: PlaybackViewModel by activityViewModels() - override val musicModel: MusicViewModel by activityViewModels() + override val detailModel: DetailViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() + override val musicModel: MusicViewModel by activityViewModels() + override val playbackModel: PlaybackViewModel by activityViewModels() // Information about what genre to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an genre. private val args: GenreDetailFragmentArgs by navArgs() @@ -124,9 +121,11 @@ class GenreDetailFragment : detailModel.setGenre(args.genreUid) collectImmediately(detailModel.currentGenre, ::updatePlaylist) collectImmediately(detailModel.genreList, ::updateList) + collect(detailModel.toShow.flow, ::handleShow) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) - collect(navModel.exploreNavigationItem.flow, ::handleNavigation) + collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist) + collect(playbackModel.genrePickerSong.flow, ::handlePlayFromGenre) collectImmediately(selectionModel.selected, ::updateSelection) } @@ -173,7 +172,7 @@ class GenreDetailFragment : override fun onRealClick(item: Music) { when (item) { - is Artist -> navModel.exploreNavigateTo(item) + is Artist -> detailModel.showArtist(item) is Song -> { val playbackMode = detailModel.playbackMode if (playbackMode != null) { @@ -242,6 +241,60 @@ class GenreDetailFragment : genreHeaderAdapter.setParent(genre) } + private fun updateList(list: List) { + genreListAdapter.update(list, detailModel.genreInstructions.consume()) + } + + private fun handleShow(show: Show?) { + when (show) { + is Show.SongDetails -> { + logD("Navigating to ${show.song}") + findNavController() + .navigateSafe(GenreDetailFragmentDirections.showSong(show.song.uid)) + } + + // Songs should be scrolled to if the album matches, or a new detail + // fragment should be launched otherwise. + is Show.SongAlbumDetails -> { + logD("Navigating to the album of ${show.song}") + findNavController() + .navigateSafe(GenreDetailFragmentDirections.showAlbum(show.song.album.uid)) + } + + // If the album matches, no need to do anything. Otherwise launch a new + // detail fragment. + is Show.AlbumDetails -> { + logD("Navigating to ${show.album}") + findNavController() + .navigateSafe(GenreDetailFragmentDirections.showAlbum(show.album.uid)) + } + + // Always launch a new ArtistDetailFragment. + is Show.ArtistDetails -> { + logD("Navigating to ${show.artist}") + findNavController() + .navigateSafe(GenreDetailFragmentDirections.showArtist(show.artist.uid)) + } + is Show.SongArtistDetails -> { + logD("Navigating to artist choices for ${show.song}") + findNavController() + .navigateSafe(GenreDetailFragmentDirections.showArtist(show.song.uid)) + } + is Show.AlbumArtistDetails -> { + logD("Navigating to artist choices for ${show.album}") + findNavController() + .navigateSafe(GenreDetailFragmentDirections.showArtist(show.album.uid)) + } + is Show.GenreDetails -> { + logD("Navigated to this genre") + } + is Show.PlaylistDetails -> { + error("Unexpected show command $show") + } + null -> {} + } + } + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value) val playingItem = @@ -257,32 +310,16 @@ class GenreDetailFragment : genreListAdapter.setPlaying(playingItem, isPlaying) } - private fun handleNavigation(item: Music?) { - when (item) { - is Song -> { - logD("Navigating to another song") - findNavController() - .navigateSafe(GenreDetailFragmentDirections.actionShowAlbum(item.album.uid)) - } - is Album -> { - logD("Navigating to another album") - findNavController() - .navigateSafe(GenreDetailFragmentDirections.actionShowAlbum(item.uid)) - } - is Artist -> { - logD("Navigating to another artist") - findNavController() - .navigateSafe(GenreDetailFragmentDirections.actionShowArtist(item.uid)) - } - is Genre -> { - navModel.exploreNavigationItem.consume() - } - else -> {} - } + private fun handlePlayFromArtist(song: Song?) { + if (song == null) return + logD("Launching play from artist dialog for $song") + findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromArtist(song.uid)) } - private fun updateList(list: List) { - genreListAdapter.update(list, detailModel.genreInstructions.consume()) + private fun handlePlayFromGenre(song: Song?) { + if (song == null) return + logD("Launching play from genre dialog for $song") + findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromGenre(song.uid)) } private fun updateSelection(selected: List) { 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 7cdf9443c..e2ae60c93 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -44,14 +44,11 @@ import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.selection.SelectionViewModel -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately @@ -74,11 +71,10 @@ class PlaylistDetailFragment : DetailHeaderAdapter.Listener, PlaylistDetailListAdapter.Listener, NavController.OnDestinationChangedListener { - private val detailModel: DetailViewModel by activityViewModels() - override val navModel: NavigationViewModel by activityViewModels() - override val playbackModel: PlaybackViewModel by activityViewModels() - override val musicModel: MusicViewModel by activityViewModels() + override val detailModel: DetailViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() + override val musicModel: MusicViewModel by activityViewModels() + override val playbackModel: PlaybackViewModel by activityViewModels() // Information about what playlist to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an playlist. private val args: PlaylistDetailFragmentArgs by navArgs() @@ -139,10 +135,12 @@ class PlaylistDetailFragment : detailModel.setPlaylist(args.playlistUid) collectImmediately(detailModel.currentPlaylist, ::updatePlaylist) collectImmediately(detailModel.playlistList, ::updateList) - collectImmediately(detailModel.editedPlaylist, ::updateEditedPlaylist) + collectImmediately(detailModel.editedPlaylist, ::updateEditedList) + collect(detailModel.toShow.flow, ::handleShow) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) - collect(navModel.exploreNavigationItem.flow, ::handleNavigation) + collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist) + collect(playbackModel.genrePickerSong.flow, ::handlePlayFromGenre) collectImmediately(selectionModel.selected, ::updateSelection) } @@ -275,41 +273,11 @@ class PlaylistDetailFragment : playlistHeaderAdapter.setParent(playlist) } - private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - // Prefer songs that are playing from this playlist. - playlistListAdapter.setPlaying( - song.takeIf { parent == detailModel.currentPlaylist.value }, isPlaying) - } - - private fun handleNavigation(item: Music?) { - when (item) { - is Song -> { - logD("Navigating to another song") - findNavController() - .navigateSafe(PlaylistDetailFragmentDirections.actionShowAlbum(item.album.uid)) - } - is Album -> { - logD("Navigating to another album") - findNavController() - .navigateSafe(PlaylistDetailFragmentDirections.actionShowAlbum(item.uid)) - } - is Artist -> { - logD("Navigating to another artist") - findNavController() - .navigateSafe(PlaylistDetailFragmentDirections.actionShowArtist(item.uid)) - } - is Playlist -> { - navModel.exploreNavigationItem.consume() - } - else -> {} - } - } - private fun updateList(list: List) { playlistListAdapter.update(list, detailModel.playlistInstructions.consume()) } - private fun updateEditedPlaylist(editedPlaylist: List?) { + private fun updateEditedList(editedPlaylist: List?) { playlistListAdapter.setEditing(editedPlaylist != null) playlistHeaderAdapter.setEditedPlaylist(editedPlaylist) selectionModel.drop() @@ -324,6 +292,74 @@ class PlaylistDetailFragment : updateMultiToolbar() } + private fun handleShow(show: Show?) { + when (show) { + is Show.SongDetails -> { + logD("Navigating to ${show.song}") + findNavController() + .navigateSafe(PlaylistDetailFragmentDirections.showSong(show.song.uid)) + } + + // Songs should be scrolled to if the album matches, or a new detail + // fragment should be launched otherwise. + is Show.SongAlbumDetails -> { + logD("Navigating to the album of ${show.song}") + findNavController() + .navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.song.album.uid)) + } + + // If the album matches, no need to do anything. Otherwise launch a new + // detail fragment. + is Show.AlbumDetails -> { + logD("Navigating to ${show.album}") + findNavController() + .navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.album.uid)) + } + + // Always launch a new ArtistDetailFragment. + is Show.ArtistDetails -> { + logD("Navigating to ${show.artist}") + findNavController() + .navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.artist.uid)) + } + is Show.SongArtistDetails -> { + logD("Navigating to artist choices for ${show.song}") + findNavController() + .navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.song.uid)) + } + is Show.AlbumArtistDetails -> { + logD("Navigating to artist choices for ${show.album}") + findNavController() + .navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.album.uid)) + } + is Show.PlaylistDetails -> { + logD("Navigated to this playlist") + } + is Show.GenreDetails -> { + error("Unexpected show command $show") + } + null -> {} + } + } + + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { + // Prefer songs that are playing from this playlist. + playlistListAdapter.setPlaying( + song.takeIf { parent == detailModel.currentPlaylist.value }, isPlaying) + } + + private fun handlePlayFromArtist(song: Song?) { + if (song == null) return + logD("Launching play from artist dialog for $song") + findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromArtist(song.uid)) + } + + private fun handlePlayFromGenre(song: Song?) { + if (song == null) return + logD("Launching play from genre dialog for $song") + findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromGenre(song.uid)) + } + private fun updateSelection(selected: List) { playlistListAdapter.setSelected(selected.toSet()) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt index ca38c061e..c46d23ea7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -70,6 +70,7 @@ class SongDetailDialog : ViewBindingDialogFragment() { // DetailViewModel handles most initialization from the navigation argument. detailModel.setSong(args.songUid) collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong) + collectImmediately(detailModel.toShow.flow, ::handleShow) } private fun updateSong(song: Song?, info: AudioProperties?) { @@ -125,6 +126,15 @@ class SongDetailDialog : ViewBindingDialogFragment() { } } + private fun handleShow(show: Show?) { + if (show == null) return + if (show is Show.SongDetails) { + logD("Navigated to this song") + } else { + error("Unexpected show command $show") + } + } + private fun T.zipName(context: Context): String { val name = name return if (name is Name.Known && name.sort != null) { diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/picker/ArtistShowChoice.kt similarity index 91% rename from app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationChoiceAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/detail/picker/ArtistShowChoice.kt index a397ec23b..f28b9d765 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationChoiceAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/picker/ArtistShowChoice.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * ArtistNavigationChoiceAdapter.kt is part of Auxio. + * ArtistShowChoice.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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.navigation.picker +package org.oxycblt.auxio.detail.picker import android.view.View import android.view.ViewGroup @@ -31,11 +31,11 @@ import org.oxycblt.auxio.util.inflater /** * A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with - * [NavigateToArtistDialog]. + * [ShowArtistDialog]. * * @param listener A [ClickableListListener] to bind interactions to. */ -class ArtistNavigationChoiceAdapter(private val listener: ClickableListListener) : +class ArtistShowChoice(private val listener: ClickableListListener) : FlexibleListAdapter( ArtistNavigationChoiceViewHolder.DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = @@ -48,7 +48,7 @@ class ArtistNavigationChoiceAdapter(private val listener: ClickableListListener< /** * A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [Artist] item, for - * use [ArtistNavigationChoiceAdapter]. Use [from] to create an instance. + * use [ArtistShowChoice]. Use [from] to create an instance. * * @author Alexander Capehart (OxygenCobalt) */ diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/picker/NavigationPickerViewModel.kt similarity index 68% rename from app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt rename to app/src/main/java/org/oxycblt/auxio/detail/picker/NavigationPickerViewModel.kt index f02621d5b..a510b6e4a 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/picker/NavigationPickerViewModel.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.navigation.picker +package org.oxycblt.auxio.detail.picker import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel @@ -28,7 +28,9 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW /** * A [ViewModel] that stores the current information required for navigation picker dialogs @@ -38,9 +40,9 @@ import org.oxycblt.auxio.util.logD @HiltViewModel class NavigationPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : ViewModel(), MusicRepository.UpdateListener { - private val _artistChoices = MutableStateFlow(null) + private val _artistChoices = MutableStateFlow(null) /** The current set of [Artist] choices to show in the picker, or null if to show nothing. */ - val artistChoices: StateFlow + val artistChoices: StateFlow get() = _artistChoices init { @@ -51,18 +53,7 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository: if (!changes.deviceLibrary) return val deviceLibrary = musicRepository.deviceLibrary ?: return // Need to sanitize different items depending on the current set of choices. - _artistChoices.value = - when (val choices = _artistChoices.value) { - is SongArtistNavigationChoices -> - deviceLibrary.findSong(choices.song.uid)?.let { - SongArtistNavigationChoices(it) - } - is AlbumArtistNavigationChoices -> - deviceLibrary.findAlbum(choices.album.uid)?.let { - AlbumArtistNavigationChoices(it) - } - else -> null - } + _artistChoices.value = _artistChoices.value?.sanitize(deviceLibrary) logD("Updated artist choices: ${_artistChoices.value}") } @@ -83,14 +74,14 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository: when (val music = musicRepository.find(itemUid)) { is Song -> { logD("Creating navigation choices for song") - SongArtistNavigationChoices(music) + ArtistShowChoices.FromSong(music) } is Album -> { logD("Creating navigation choices for album") - AlbumArtistNavigationChoices(music) + ArtistShowChoices.FromAlbum(music) } else -> { - logD("Given song/album UID was invalid") + logW("Given song/album UID was invalid") null } } @@ -102,20 +93,29 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository: * * @author Alexander Capehart (OxygenCobalt) */ -sealed interface ArtistNavigationChoices { +sealed interface ArtistShowChoices { + /** The UID of the item. */ + val uid: Music.UID /** The current [Artist] choices. */ val choices: List -} + /** Sanitize this instance with a [DeviceLibrary]. */ + fun sanitize(newLibrary: DeviceLibrary): ArtistShowChoices? -/** Backing implementation of [ArtistNavigationChoices] that is based on a [Song]. */ -private data class SongArtistNavigationChoices(val song: Song) : ArtistNavigationChoices { - override val choices = song.artists -} + /** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */ + class FromSong(val song: Song) : ArtistShowChoices { + override val uid = song.uid + override val choices = song.artists + override fun sanitize(newLibrary: DeviceLibrary) = + newLibrary.findSong(uid)?.let { FromSong(it) } + } -/** - * Backing implementation of [ArtistNavigationChoices] that is based on an - * [AlbumArtistNavigationChoices]. - */ -private data class AlbumArtistNavigationChoices(val album: Album) : ArtistNavigationChoices { - override val choices = album.artists + /** + * Backing implementation of [ArtistShowChoices] that is based on an [AlbumArtistShowChoices]. + */ + data class FromAlbum(val album: Album) : ArtistShowChoices { + override val uid = album.uid + override val choices = album.artists + override fun sanitize(newLibrary: DeviceLibrary) = + newLibrary.findAlbum(uid)?.let { FromAlbum(it) } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/picker/ShowArtistDialog.kt similarity index 85% rename from app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt rename to app/src/main/java/org/oxycblt/auxio/detail/picker/ShowArtistDialog.kt index ade74f930..a98dfb162 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/picker/ShowArtistDialog.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2022 Auxio Project - * NavigateToArtistDialog.kt is part of Auxio. + * ShowArtistDialog.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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.navigation.picker +package org.oxycblt.auxio.detail.picker import android.os.Bundle import android.view.LayoutInflater @@ -29,27 +29,27 @@ import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding +import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.collectImmediately /** - * A picker [ViewBindingDialogFragment] intended for when [Artist] navigation is ambiguous. + * A picker [ViewBindingDialogFragment] intended for when the [Artist] to show is ambiguous. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class NavigateToArtistDialog : +class ShowArtistDialog : ViewBindingDialogFragment(), ClickableListListener { - private val navigationModel: NavigationViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() private val pickerModel: NavigationPickerViewModel by viewModels() // Information about what artists to show choices for is initially within the navigation // arguments as UIDs, as that is the only safe way to parcel an artist. - private val args: NavigateToArtistDialogArgs by navArgs() - private val choiceAdapter = ArtistNavigationChoiceAdapter(this) + private val args: ShowArtistDialogArgs by navArgs() + private val choiceAdapter = ArtistShowChoice(this) override fun onConfigDialog(builder: AlertDialog.Builder) { builder.setTitle(R.string.lbl_artists).setNegativeButton(R.string.lbl_cancel, null) @@ -83,7 +83,7 @@ class NavigateToArtistDialog : override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { // User made a choice, navigate to the artist. - navigationModel.exploreNavigateTo(item) + detailModel.showArtist(item) findNavController().navigateUp() } } 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 f39a54e1f..0d2e40ad3 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -43,9 +43,10 @@ import dagger.hilt.android.AndroidEntryPoint import java.lang.reflect.Field import kotlin.math.abs import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeBinding +import org.oxycblt.auxio.detail.DetailViewModel +import org.oxycblt.auxio.detail.Show import org.oxycblt.auxio.home.list.AlbumListFragment import org.oxycblt.auxio.home.list.ArtistListFragment import org.oxycblt.auxio.home.list.GenreListFragment @@ -56,9 +57,6 @@ import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.selection.SelectionFragment import org.oxycblt.auxio.list.selection.SelectionViewModel -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.IndexingProgress import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.Music @@ -67,10 +65,7 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.NoAudioPermissionException import org.oxycblt.auxio.music.NoMusicException import org.oxycblt.auxio.music.PERMISSION_READ_AUDIO -import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.navigation.MainNavigationAction -import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately @@ -94,7 +89,7 @@ class HomeFragment : override val selectionModel: SelectionViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels() - private val navModel: NavigationViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() private var storagePermissionLauncher: ActivityResultLauncher? = null override fun onCreate(savedInstanceState: Bundle?) { @@ -177,7 +172,7 @@ class HomeFragment : collectImmediately(homeModel.currentTabMode, ::updateCurrentTab) collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab) collectImmediately(musicModel.indexingState, ::updateIndexerState) - collect(navModel.exploreNavigationItem.flow, ::handleNavigation) + collect(detailModel.toShow.flow, ::handleShow) collectImmediately(selectionModel.selected, ::updateSelection) } @@ -218,19 +213,17 @@ class HomeFragment : R.id.action_search -> { logD("Navigating to search") setupAxisTransitions(MaterialSharedAxis.Z) - findNavController().navigateSafe(HomeFragmentDirections.actionShowSearch()) + findNavController().navigateSafe(HomeFragmentDirections.search()) true } R.id.action_settings -> { - logD("Navigating to settings") - navModel.mainNavigateTo( - MainNavigationAction.Directions(MainFragmentDirections.actionShowSettings())) + logD("Navigating to preferences") + findNavController().navigateSafe(HomeFragmentDirections.preferences()) true } R.id.action_about -> { logD("Navigating to about") - navModel.mainNavigateTo( - MainNavigationAction.Directions(MainFragmentDirections.actionShowAbout())) + findNavController().navigateSafe(HomeFragmentDirections.about()) true } @@ -500,19 +493,55 @@ class HomeFragment : } } - private fun handleNavigation(item: Music?) { - val action = - when (item) { - is Song -> HomeFragmentDirections.actionShowAlbum(item.album.uid) - is Album -> HomeFragmentDirections.actionShowAlbum(item.uid) - is Artist -> HomeFragmentDirections.actionShowArtist(item.uid) - is Genre -> HomeFragmentDirections.actionShowGenre(item.uid) - is Playlist -> HomeFragmentDirections.actionShowPlaylist(item.uid) - null -> return + private fun handleShow(show: Show?) { + when (show) { + is Show.SongDetails -> { + logD("Navigating to ${show.song}") + findNavController().navigateSafe(HomeFragmentDirections.showSong(show.song.uid)) } - setupAxisTransitions(MaterialSharedAxis.X) - findNavController().navigateSafe(action) + // Songs should be scrolled to if the album matches, or a new detail + // fragment should be launched otherwise. + is Show.SongAlbumDetails -> { + logD("Navigating to the album of ${show.song}") + setupAxisTransitions(MaterialSharedAxis.X) + findNavController() + .navigateSafe(HomeFragmentDirections.showAlbum(show.song.album.uid)) + } + + // If the album matches, no need to do anything. Otherwise launch a new + // detail fragment. + is Show.AlbumDetails -> { + logD("Navigating to ${show.album}") + setupAxisTransitions(MaterialSharedAxis.X) + findNavController().navigateSafe(HomeFragmentDirections.showAlbum(show.album.uid)) + } + + // Always launch a new ArtistDetailFragment. + is Show.ArtistDetails -> { + logD("Navigating to ${show.artist}") + setupAxisTransitions(MaterialSharedAxis.X) + findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.artist.uid)) + } + is Show.SongArtistDetails -> { + logD("Navigating to artist choices for ${show.song}") + findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.song.uid)) + } + is Show.AlbumArtistDetails -> { + logD("Navigating to artist choices for ${show.album}") + findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.album.uid)) + } + is Show.GenreDetails -> { + logD("Navigating to ${show.genre}") + findNavController().navigateSafe(HomeFragmentDirections.showGenre(show.genre.uid)) + } + is Show.PlaylistDetails -> { + logD("Navigating to ${show.playlist}") + findNavController() + .navigateSafe(HomeFragmentDirections.showGenre(show.playlist.uid)) + } + null -> {} + } } private fun updateSelection(selected: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt index 3495bc85a..b4aac9121 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt @@ -28,6 +28,7 @@ import dagger.hilt.android.AndroidEntryPoint import java.util.Formatter import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeListBinding +import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.ListFragment @@ -42,7 +43,6 @@ import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.secsToMs @@ -59,7 +59,7 @@ class AlbumListFragment : FastScrollRecyclerView.Listener, FastScrollRecyclerView.PopupProvider { private val homeModel: HomeViewModel by activityViewModels() - override val navModel: NavigationViewModel by activityViewModels() + override val detailModel: DetailViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() @@ -138,7 +138,7 @@ class AlbumListFragment : } override fun onRealClick(item: Album) { - navModel.exploreNavigateTo(item) + detailModel.showAlbum(item) } override fun onOpenMenu(item: Album, anchor: View) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt index e270fa7d2..b66c6e965 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt @@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeListBinding +import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.ListFragment @@ -40,7 +41,6 @@ import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.collectImmediately @@ -57,7 +57,7 @@ class ArtistListFragment : FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - override val navModel: NavigationViewModel by activityViewModels() + override val detailModel: DetailViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() @@ -114,7 +114,7 @@ class ArtistListFragment : } override fun onRealClick(item: Artist) { - navModel.exploreNavigateTo(item) + detailModel.showArtist(item) } override fun onOpenMenu(item: Artist, anchor: View) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index ee9544d55..d751e3699 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeListBinding +import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.ListFragment @@ -40,7 +41,6 @@ import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.collectImmediately @@ -56,7 +56,7 @@ class GenreListFragment : FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - override val navModel: NavigationViewModel by activityViewModels() + override val detailModel: DetailViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() @@ -113,7 +113,7 @@ class GenreListFragment : } override fun onRealClick(item: Genre) { - navModel.exploreNavigateTo(item) + detailModel.showGenre(item) } override fun onOpenMenu(item: Genre, anchor: View) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt index 61fa54b7c..405dbe312 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt @@ -25,6 +25,7 @@ import android.view.ViewGroup import androidx.fragment.app.activityViewModels import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeListBinding +import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.ListFragment @@ -39,7 +40,6 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.collectImmediately @@ -54,7 +54,7 @@ class PlaylistListFragment : FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - override val navModel: NavigationViewModel by activityViewModels() + override val detailModel: DetailViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() @@ -111,7 +111,7 @@ class PlaylistListFragment : } override fun onRealClick(item: Playlist) { - navModel.exploreNavigateTo(item) + detailModel.showPlaylist(item) } override fun onOpenMenu(item: Playlist, anchor: View) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt index 62643f4cf..d827adbf7 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt @@ -28,6 +28,7 @@ import dagger.hilt.android.AndroidEntryPoint import java.util.Formatter import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeListBinding +import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.ListFragment @@ -41,7 +42,6 @@ import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.secsToMs @@ -58,7 +58,7 @@ class SongListFragment : FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - override val navModel: NavigationViewModel by activityViewModels() + override val detailModel: DetailViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt index bca4fb774..7931f63e6 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -24,8 +24,8 @@ import androidx.appcompat.widget.PopupMenu import androidx.core.view.MenuCompat import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding -import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.R +import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.list.selection.SelectionFragment import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist @@ -33,8 +33,6 @@ import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.navigation.MainNavigationAction -import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.share @@ -47,7 +45,7 @@ import org.oxycblt.auxio.util.showToast */ abstract class ListFragment : SelectionFragment(), SelectableListListener { - protected abstract val navModel: NavigationViewModel + protected abstract val detailModel: DetailViewModel private var currentMenu: PopupMenu? = null override fun onDestroyBinding(binding: VB) { @@ -103,11 +101,11 @@ abstract class ListFragment : true } R.id.action_go_artist -> { - navModel.exploreNavigateToParentArtist(song) + detailModel.showArtist(song) true } R.id.action_go_album -> { - navModel.exploreNavigateTo(song.album) + detailModel.showAlbum(song.album) true } R.id.action_share -> { @@ -119,9 +117,7 @@ abstract class ListFragment : true } R.id.action_song_detail -> { - navModel.mainNavigateTo( - MainNavigationAction.Directions( - MainFragmentDirections.actionShowDetails(song.uid))) + detailModel.showSong(song) true } else -> { @@ -166,7 +162,7 @@ abstract class ListFragment : true } R.id.action_go_artist -> { - navModel.exploreNavigateToParentArtist(album) + detailModel.showArtist(album) true } R.id.action_playlist_add -> { diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/MainNavigationAction.kt b/app/src/main/java/org/oxycblt/auxio/navigation/MainNavigationAction.kt deleted file mode 100644 index 59c778320..000000000 --- a/app/src/main/java/org/oxycblt/auxio/navigation/MainNavigationAction.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * MainNavigationAction.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.navigation - -import androidx.navigation.NavDirections - -/** - * Represents the possible actions within the main navigation graph. This can be used with - * [NavigationViewModel] to initiate navigation in the main navigation graph from anywhere in the - * app, including outside the main navigation graph. - * - * @author Alexander Capehart (OxygenCobalt) - */ -sealed interface MainNavigationAction { - /** Expand the playback panel. */ - object OpenPlaybackPanel : MainNavigationAction - - /** Collapse the playback bottom sheet. */ - object ClosePlaybackPanel : MainNavigationAction - - /** - * Navigate to the given [NavDirections]. - * - * @param directions The [NavDirections] to navigate to. Assumed to be part of the main - * navigation graph. - */ - data class Directions(val directions: NavDirections) : MainNavigationAction -} diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/NavigationViewModel.kt b/app/src/main/java/org/oxycblt/auxio/navigation/NavigationViewModel.kt deleted file mode 100644 index 5271e49c7..000000000 --- a/app/src/main/java/org/oxycblt/auxio/navigation/NavigationViewModel.kt +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * NavigationViewModel.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.navigation - -import androidx.lifecycle.ViewModel -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.util.Event -import org.oxycblt.auxio.util.MutableEvent -import org.oxycblt.auxio.util.logD - -/** - * A [ViewModel] that handles complicated navigation functionality. - * - * @author Alexander Capehart (OxygenCobalt) - * - * TODO: Unwind this into ViewModel-specific actions, and then reference those. - */ -class NavigationViewModel : ViewModel() { - private val _mainNavigationAction = MutableEvent() - /** - * Flag for navigation within the main navigation graph. Only intended for use by MainFragment. - */ - val mainNavigationAction: Event - get() = _mainNavigationAction - - private val _exploreNavigationItem = MutableEvent() - /** - * Flag for navigation within the explore navigation graph. Observe this to coordinate - * navigation to a specific [Music] item. - */ - val exploreNavigationItem: Event - get() = _exploreNavigationItem - - private val _exploreArtistNavigationItem = MutableEvent() - /** - * Variation of [exploreNavigationItem] for situations where the choice of parent [Artist] to - * navigate to is ambiguous. Only intended for use by MainFragment, as the resolved choice will - * eventually be assigned to [exploreNavigationItem]. - */ - val exploreArtistNavigationItem: Event - get() = _exploreArtistNavigationItem - - /** - * Navigate to something in the main navigation graph. This can be used by UIs in the explore - * navigation graph to trigger navigation in the higher-level main navigation graph. Will do - * nothing if already navigating. - * - * @param action The [MainNavigationAction] to perform. - */ - fun mainNavigateTo(action: MainNavigationAction) { - if (_mainNavigationAction.flow.value != null) { - logD("Already navigating, not doing main action") - return - } - logD("Navigating with action $action") - _mainNavigationAction.put(action) - } - - /** - * Navigate to a given [Music] item. Will do nothing if already navigating. - * - * @param music The [Music] to navigate to. - */ - fun exploreNavigateTo(music: Music) { - if (_exploreNavigationItem.flow.value != null) { - logD("Already navigating, not doing explore action") - return - } - logD("Navigating to ${music.name}") - _exploreNavigationItem.put(music) - } - - /** - * Navigate to one of the parent [Artist]'s of the given [Song]. - * - * @param song The [Song] to navigate with. If there are multiple parent [Artist]s, a picker - * dialog will be shown. - */ - fun exploreNavigateToParentArtist(song: Song) { - logD("Navigating to parent artist of $song") - exploreNavigateToParentArtistImpl(song, song.artists) - } - - /** - * Navigate to one of the parent [Artist]'s of the given [Album]. - * - * @param album The [Album] to navigate with. If there are multiple parent [Artist]s, a picker - * dialog will be shown. - */ - fun exploreNavigateToParentArtist(album: Album) { - logD("Navigating to parent artist of $album") - exploreNavigateToParentArtistImpl(album, album.artists) - } - - private fun exploreNavigateToParentArtistImpl(item: Music, artists: List) { - if (_exploreArtistNavigationItem.flow.value != null) { - logD("Already navigating, not doing explore action") - return - } - - if (artists.size == 1) { - exploreNavigateTo(artists[0]) - } else { - logD("Navigating to a choice of ${artists.map { it.name }}") - _exploreArtistNavigationItem.put(item) - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt index 05f438c38..036f07c99 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -25,10 +25,9 @@ import com.google.android.material.R as MR import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding +import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames -import org.oxycblt.auxio.navigation.MainNavigationAction -import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately @@ -44,7 +43,7 @@ import org.oxycblt.auxio.util.logD @AndroidEntryPoint class PlaybackBarFragment : ViewBindingFragment() { private val playbackModel: PlaybackViewModel by activityViewModels() - private val navModel: NavigationViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() override fun onCreateBinding(inflater: LayoutInflater) = FragmentPlaybackBarBinding.inflate(inflater) @@ -58,9 +57,9 @@ class PlaybackBarFragment : ViewBindingFragment() { // --- UI SETUP --- binding.root.apply { - setOnClickListener { navModel.mainNavigateTo(MainNavigationAction.OpenPlaybackPanel) } + setOnClickListener { playbackModel.openPlayback() } setOnLongClickListener { - playbackModel.song.value?.let(navModel::exploreNavigateTo) + playbackModel.song.value?.let(detailModel::showAlbum) true } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index 6bcc4114e..0f1c2b57b 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -30,15 +30,13 @@ import androidx.appcompat.widget.Toolbar import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels import dagger.hilt.android.AndroidEntryPoint -import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding +import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames -import org.oxycblt.auxio.navigation.MainNavigationAction -import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.ui.ViewBindingFragment @@ -63,7 +61,7 @@ class PlaybackPanelFragment : StyledSeekBar.Listener { private val playbackModel: PlaybackViewModel by activityViewModels() private val musicModel: MusicViewModel by activityViewModels() - private val navModel: NavigationViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() private var equalizerLauncher: ActivityResultLauncher? = null override fun onCreateBinding(inflater: LayoutInflater) = @@ -90,9 +88,7 @@ class PlaybackPanelFragment : } binding.playbackToolbar.apply { - setNavigationOnClickListener { - navModel.mainNavigateTo(MainNavigationAction.ClosePlaybackPanel) - } + setNavigationOnClickListener { playbackModel.openMain() } setOnMenuItemClickListener(this@PlaybackPanelFragment) } @@ -100,7 +96,7 @@ class PlaybackPanelFragment : // respective item. binding.playbackSong.apply { isSelected = true - setOnClickListener { playbackModel.song.value?.let(navModel::exploreNavigateTo) } + setOnClickListener { playbackModel.song.value?.let(detailModel::showAlbum) } } binding.playbackArtist.apply { isSelected = true @@ -176,11 +172,7 @@ class PlaybackPanelFragment : true } R.id.action_song_detail -> { - playbackModel.song.value?.let { song -> - navModel.mainNavigateTo( - MainNavigationAction.Directions( - MainFragmentDirections.actionShowDetails(song.uid))) - } + playbackModel.song.value?.let(detailModel::showSong) true } R.id.action_share -> { @@ -237,12 +229,10 @@ class PlaybackPanelFragment : } private fun navigateToCurrentArtist() { - val song = playbackModel.song.value ?: return - navModel.exploreNavigateToParentArtist(song) + playbackModel.song.value?.let(detailModel::showArtist) } private fun navigateToCurrentAlbum() { - val song = playbackModel.song.value ?: return - navModel.exploreNavigateTo(song.album) + playbackModel.song.value?.let(detailModel::showAlbum) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index 115d00344..ea6b7b53a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -89,6 +89,10 @@ constructor( val isShuffled: StateFlow get() = _isShuffled + private val _openPanel = MutableEvent() + val openPanel: Event + get() = _openPanel + private val _artistPlaybackPickerSong = MutableEvent() /** * Flag signaling to open a picker dialog in order to resolve an ambiguous choice when playing a @@ -555,6 +559,20 @@ constructor( playbackManager.repeatMode = playbackManager.repeatMode.increment() } + // --- UI CONTROL --- + fun openMain() = openImpl(Panel.Main) + fun openPlayback() = openImpl(Panel.Playback) + fun openQueue() = openImpl(Panel.Queue) + + private fun openImpl(panel: Panel) { + val existing = openPanel.flow.value + if (existing != null) { + logD("Already opening $existing, ignoring opening $panel") + return + } + _openPanel.put(panel) + } + // --- SAVE/RESTORE FUNCTIONS --- /** @@ -598,3 +616,9 @@ constructor( } } } + +sealed interface Panel { + object Main : Panel + object Playback : Panel + object Queue : Panel +} 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 0839d12fb..90184814b 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -34,6 +34,8 @@ import com.google.android.material.transition.MaterialSharedAxis import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentSearchBinding +import org.oxycblt.auxio.detail.DetailViewModel +import org.oxycblt.auxio.detail.Show import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item @@ -47,7 +49,6 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately @@ -67,7 +68,7 @@ import org.oxycblt.auxio.util.setFullWidthLookup */ @AndroidEntryPoint class SearchFragment : ListFragment() { - override val navModel: NavigationViewModel by activityViewModels() + override val detailModel: DetailViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() @@ -137,7 +138,7 @@ class SearchFragment : ListFragment() { collectImmediately(searchModel.searchResults, ::updateSearchResults) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) - collect(navModel.exploreNavigationItem.flow, ::handleNavigation) + collect(detailModel.toShow.flow, ::handleShow) collectImmediately(selectionModel.selected, ::updateSelection) } @@ -167,8 +168,11 @@ class SearchFragment : ListFragment() { override fun onRealClick(item: Music) { when (item) { - is MusicParent -> navModel.exploreNavigateTo(item) is Song -> playbackModel.playFrom(item, searchModel.playbackMode) + is Album -> detailModel.showAlbum(item) + is Artist -> detailModel.showArtist(item) + is Genre -> detailModel.showGenre(item) + is Playlist -> detailModel.showPlaylist(item) } } @@ -200,19 +204,57 @@ class SearchFragment : ListFragment() { searchAdapter.setPlaying(parent ?: song, isPlaying) } - private fun handleNavigation(item: Music?) { - val action = - when (item) { - is Song -> SearchFragmentDirections.actionShowAlbum(item.album.uid) - is Album -> SearchFragmentDirections.actionShowAlbum(item.uid) - is Artist -> SearchFragmentDirections.actionShowArtist(item.uid) - is Genre -> SearchFragmentDirections.actionShowGenre(item.uid) - is Playlist -> SearchFragmentDirections.actionShowPlaylist(item.uid) - null -> return + private fun handleShow(show: Show?) { + when (show) { + is Show.SongDetails -> { + logD("Navigating to ${show.song}") + findNavController().navigateSafe(SearchFragmentDirections.showSong(show.song.uid)) } + + // Songs should be scrolled to if the album matches, or a new detail + // fragment should be launched otherwise. + is Show.SongAlbumDetails -> { + logD("Navigating to the album of ${show.song}") + findNavController() + .navigateSafe(SearchFragmentDirections.showAlbum(show.song.album.uid)) + } + + // If the album matches, no need to do anything. Otherwise launch a new + // detail fragment. + is Show.AlbumDetails -> { + logD("Navigating to ${show.album}") + findNavController().navigateSafe(SearchFragmentDirections.showAlbum(show.album.uid)) + } + + // Always launch a new ArtistDetailFragment. + is Show.ArtistDetails -> { + logD("Navigating to ${show.artist}") + findNavController() + .navigateSafe(SearchFragmentDirections.showArtist(show.artist.uid)) + } + is Show.SongArtistDetails -> { + logD("Navigating to artist choices for ${show.song}") + findNavController().navigateSafe(SearchFragmentDirections.showArtist(show.song.uid)) + } + is Show.AlbumArtistDetails -> { + logD("Navigating to artist choices for ${show.album}") + findNavController() + .navigateSafe(SearchFragmentDirections.showArtist(show.album.uid)) + } + is Show.GenreDetails -> { + logD("Navigating to ${show.genre}") + findNavController().navigateSafe(SearchFragmentDirections.showGenre(show.genre.uid)) + } + is Show.PlaylistDetails -> { + logD("Navigating to ${show.playlist}") + findNavController() + .navigateSafe(SearchFragmentDirections.showGenre(show.playlist.uid)) + } + null -> {} + } + // Keyboard is no longer needed. hideKeyboard() - findNavController().navigateSafe(action) } private fun updateSelection(selected: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt index fe2bf0066..8e8f3d589 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt @@ -55,7 +55,7 @@ class RootPreferenceFragment : BasePreferenceFragment(R.xml.preferences_root) { override fun onOpenDialogPreference(preference: WrappedDialogPreference) { if (preference.key == getString(R.string.set_key_music_dirs)) { - findNavController().navigate(RootPreferenceFragmentDirections.goToMusicDirsDialog()) + findNavController().navigate(RootPreferenceFragmentDirections.musicDirsSettings()) } } @@ -66,23 +66,21 @@ class RootPreferenceFragment : BasePreferenceFragment(R.xml.preferences_root) { when (preference.key) { getString(R.string.set_key_ui) -> { logD("Navigating to UI preferences") - findNavController() - .navigateSafe(RootPreferenceFragmentDirections.goToUiPreferences()) + findNavController().navigateSafe(RootPreferenceFragmentDirections.uiPreferences()) } getString(R.string.set_key_personalize) -> { logD("Navigating to personalization preferences") findNavController() - .navigateSafe(RootPreferenceFragmentDirections.goToPersonalizePreferences()) + .navigateSafe(RootPreferenceFragmentDirections.personalizePreferences()) } getString(R.string.set_key_music) -> { logD("Navigating to music preferences") findNavController() - .navigateSafe(RootPreferenceFragmentDirections.goToMusicPreferences()) + .navigateSafe(RootPreferenceFragmentDirections.musicPreferences()) } getString(R.string.set_key_audio) -> { logD("Navigating to audio preferences") - findNavController() - .navigateSafe(RootPreferenceFragmentDirections.goToAudioPreferences()) + findNavController().navigateSafe(RootPreferenceFragmentDirections.audioPeferences()) } getString(R.string.set_key_reindex) -> musicModel.refresh() getString(R.string.set_key_rescan) -> musicModel.rescan() diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/AudioPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/AudioPreferenceFragment.kt index 5bcee531f..765c86987 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/categories/AudioPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/AudioPreferenceFragment.kt @@ -35,7 +35,7 @@ class AudioPreferenceFragment : BasePreferenceFragment(R.xml.preferences_audio) override fun onOpenDialogPreference(preference: WrappedDialogPreference) { if (preference.key == getString(R.string.set_key_pre_amp)) { logD("Navigating to pre-amp dialog") - findNavController().navigateSafe(AudioPreferenceFragmentDirections.goToPreAmpDialog()) + findNavController().navigateSafe(AudioPreferenceFragmentDirections.preAmpSettings()) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt index 9e1e83af5..19b507f1d 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt @@ -41,8 +41,7 @@ class MusicPreferenceFragment : BasePreferenceFragment(R.xml.preferences_music) override fun onOpenDialogPreference(preference: WrappedDialogPreference) { if (preference.key == getString(R.string.set_key_separators)) { logD("Navigating to separator dialog") - findNavController() - .navigateSafe(MusicPreferenceFragmentDirections.goToSeparatorsDialog()) + findNavController().navigateSafe(MusicPreferenceFragmentDirections.separatorsSettings()) } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/PersonalizePreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/PersonalizePreferenceFragment.kt index f284e1d69..361ec1162 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/categories/PersonalizePreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/PersonalizePreferenceFragment.kt @@ -34,8 +34,7 @@ class PersonalizePreferenceFragment : BasePreferenceFragment(R.xml.preferences_p override fun onOpenDialogPreference(preference: WrappedDialogPreference) { if (preference.key == getString(R.string.set_key_home_tabs)) { logD("Navigating to home tab dialog") - findNavController() - .navigateSafe(PersonalizePreferenceFragmentDirections.goToTabDialog()) + findNavController().navigateSafe(PersonalizePreferenceFragmentDirections.tabSettings()) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt index 8d7ba5114..b756559dc 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt @@ -43,7 +43,7 @@ class UIPreferenceFragment : BasePreferenceFragment(R.xml.preferences_ui) { override fun onOpenDialogPreference(preference: WrappedDialogPreference) { if (preference.key == getString(R.string.set_key_accent)) { logD("Navigating to accent dialog") - findNavController().navigateSafe(UIPreferenceFragmentDirections.goToAccentDialog()) + findNavController().navigateSafe(UIPreferenceFragmentDirections.accentSettings()) } } diff --git a/app/src/main/res/layout-w600dp-land/fragment_main.xml b/app/src/main/res/layout-w600dp-land/fragment_main.xml index a8a98f12d..e7213924f 100644 --- a/app/src/main/res/layout-w600dp-land/fragment_main.xml +++ b/app/src/main/res/layout-w600dp-land/fragment_main.xml @@ -13,7 +13,8 @@ android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior" - app:navGraph="@navigation/nav_explore" + app:navGraph="@navigation/main" + app:defaultNavHost="true" tools:layout="@layout/fragment_home" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index 5b2e5c96b..81141391f 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -14,7 +14,8 @@ android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior" - app:navGraph="@navigation/nav_explore" + app:navGraph="@navigation/main" + app:defaultNavHost="true" tools:layout="@layout/fragment_home" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_explore.xml b/app/src/main/res/navigation/nav_explore.xml deleted file mode 100644 index dfce49cb9..000000000 --- a/app/src/main/res/navigation/nav_explore.xml +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml deleted file mode 100644 index 54a1a4f37..000000000 --- a/app/src/main/res/navigation/nav_main.xml +++ /dev/null @@ -1,214 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/build.gradle b/build.gradle index efe210373..5c4648215 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { kotlin_version = '1.8.22' - navigation_version = "2.5.0" + navigation_version = "2.6.0" hilt_version = '2.46.1' } From 9b0e39919b96909c7cf0bdd7d0c010ebd2da3610 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 27 Jun 2023 19:43:15 -0600 Subject: [PATCH 03/72] music: simplify playlist decision events Simplify the 4 stateflows controlling when playlist decision dialogs must be opened to just one enum. This is like the detail view, and makes the amount of observers I have to spin up much smaller. Eventually, most of even these observer calls will be collapsed into the menu itself. --- .../java/org/oxycblt/auxio/MainFragment.kt | 2 - .../auxio/detail/AlbumDetailFragment.kt | 44 ++++++++++++----- .../auxio/detail/ArtistDetailFragment.kt | 46 +++++++++++++----- .../auxio/detail/GenreDetailFragment.kt | 46 +++++++++++++----- .../auxio/detail/PlaylistDetailFragment.kt | 47 +++++++++++++----- .../oxycblt/auxio/detail/SongDetailDialog.kt | 1 + .../org/oxycblt/auxio/home/HomeFragment.kt | 39 ++++++++++++++- .../org/oxycblt/auxio/music/MusicViewModel.kt | 34 +++++-------- .../oxycblt/auxio/search/SearchFragment.kt | 48 +++++++++++++++++-- 9 files changed, 228 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 00c88bd53..9b6b47a08 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -62,8 +62,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * high-level navigation features. * * @author Alexander Capehart (OxygenCobalt) - * - * TODO: Break up the god navigation setup going on here */ @AndroidEntryPoint class MainFragment : 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 8844de56e..b0c60107f 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -46,6 +46,7 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.playback.PlaybackViewModel @@ -124,6 +125,7 @@ class AlbumDetailFragment : collectImmediately(detailModel.albumList, ::updateList) collect(detailModel.toShow.flow, ::handleShow) collectImmediately(selectionModel.selected, ::updateSelection) + collect(musicModel.playlistDecision.flow, ::handleDecision) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist) @@ -298,6 +300,36 @@ class AlbumDetailFragment : } } + private fun updateSelection(selected: List) { + albumListAdapter.setSelected(selected.toSet()) + + 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) + } + } + + private fun handleDecision(decision: PlaylistDecision?) { + when (decision) { + is PlaylistDecision.Add ->{ + logD("Adding ${decision.songs.size} songs to a playlist") + findNavController().navigateSafe( + AlbumDetailFragmentDirections.addToPlaylist( + decision.songs.map { it.uid }.toTypedArray()) + ) + musicModel.playlistDecision.consume() + } + + is PlaylistDecision.New, is PlaylistDecision.Rename, is PlaylistDecision.Delete -> + error("Unexpected decision $decision") + + null -> {} + } + } + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { albumListAdapter.setPlaying( song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying) @@ -352,16 +384,4 @@ class AlbumDetailFragment : } } } - - private fun updateSelection(selected: List) { - albumListAdapter.setSelected(selected.toSet()) - - 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 b55bc9be1..347c13a1b 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -46,6 +46,7 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.collect @@ -124,11 +125,12 @@ class ArtistDetailFragment : collectImmediately(detailModel.currentArtist, ::updateArtist) collectImmediately(detailModel.artistList, ::updateList) collect(detailModel.toShow.flow, ::handleShow) + collectImmediately(selectionModel.selected, ::updateSelection) + collect(musicModel.playlistDecision.flow, ::handleDecision) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist) collect(playbackModel.genrePickerSong.flow, ::handlePlayFromGenre) - collectImmediately(selectionModel.selected, ::updateSelection) } override fun onDestroyBinding(binding: FragmentDetailBinding) { @@ -308,6 +310,36 @@ class ArtistDetailFragment : } } + private fun updateSelection(selected: List) { + artistListAdapter.setSelected(selected.toSet()) + + 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) + } + } + + private fun handleDecision(decision: PlaylistDecision?) { + when (decision) { + is PlaylistDecision.Add ->{ + logD("Adding ${decision.songs.size} songs to a playlist") + findNavController().navigateSafe( + ArtistDetailFragmentDirections.addToPlaylist( + decision.songs.map { it.uid }.toTypedArray()) + ) + musicModel.playlistDecision.consume() + } + + is PlaylistDecision.New, is PlaylistDecision.Rename, is PlaylistDecision.Delete -> + error("Unexpected decision $decision") + + null -> {} + } + } + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value) val playingItem = @@ -334,16 +366,4 @@ class ArtistDetailFragment : logD("Launching play from genre dialog for $song") findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromGenre(song.uid)) } - - private fun updateSelection(selected: List) { - artistListAdapter.setSelected(selected.toSet()) - - 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/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index 5934d4a11..d0d4ff1bd 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -46,6 +46,7 @@ import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.collect @@ -122,11 +123,12 @@ class GenreDetailFragment : collectImmediately(detailModel.currentGenre, ::updatePlaylist) collectImmediately(detailModel.genreList, ::updateList) collect(detailModel.toShow.flow, ::handleShow) + collectImmediately(selectionModel.selected, ::updateSelection) + collect(musicModel.playlistDecision.flow, ::handleDecision) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist) collect(playbackModel.genrePickerSong.flow, ::handlePlayFromGenre) - collectImmediately(selectionModel.selected, ::updateSelection) } override fun onDestroyBinding(binding: FragmentDetailBinding) { @@ -295,6 +297,36 @@ class GenreDetailFragment : } } + private fun updateSelection(selected: List) { + genreListAdapter.setSelected(selected.toSet()) + + 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) + } + } + + private fun handleDecision(decision: PlaylistDecision?) { + when (decision) { + is PlaylistDecision.Add ->{ + logD("Adding ${decision.songs.size} songs to a playlist") + findNavController().navigateSafe( + GenreDetailFragmentDirections.addToPlaylist( + decision.songs.map { it.uid }.toTypedArray()) + ) + musicModel.playlistDecision.consume() + } + + is PlaylistDecision.New, is PlaylistDecision.Rename, is PlaylistDecision.Delete -> + error("Unexpected decision $decision") + + null -> {} + } + } + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value) val playingItem = @@ -321,16 +353,4 @@ class GenreDetailFragment : logD("Launching play from genre dialog for $song") findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromGenre(song.uid)) } - - private fun updateSelection(selected: List) { - genreListAdapter.setSelected(selected.toSet()) - - 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 e2ae60c93..38aac6dcf 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -48,6 +48,7 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.collect @@ -137,11 +138,12 @@ class PlaylistDetailFragment : collectImmediately(detailModel.playlistList, ::updateList) collectImmediately(detailModel.editedPlaylist, ::updateEditedList) collect(detailModel.toShow.flow, ::handleShow) + collectImmediately(selectionModel.selected, ::updateSelection) + collect(musicModel.playlistDecision.flow, ::handleDecision) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist) collect(playbackModel.genrePickerSong.flow, ::handlePlayFromGenre) - collectImmediately(selectionModel.selected, ::updateSelection) } override fun onStart() { @@ -342,6 +344,38 @@ class PlaylistDetailFragment : } } + private fun updateSelection(selected: List) { + playlistListAdapter.setSelected(selected.toSet()) + + val binding = requireBinding() + if (selected.isNotEmpty()) { + binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size) + } + updateMultiToolbar() + } + + private fun handleDecision(decision: PlaylistDecision?) { + if (decision == null) return + when (decision) { + is PlaylistDecision.Rename -> { + logD("Renaming ${decision.playlist}") + findNavController().navigateSafe( + PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid) + ) + } + + is PlaylistDecision.Delete -> { + logD("Deleting ${decision.playlist}") + findNavController().navigateSafe( + PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid) + ) + } + + is PlaylistDecision.Add, is PlaylistDecision.New -> error("Unexpected decision $decision") + } + musicModel.playlistDecision.consume() + } + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { // Prefer songs that are playing from this playlist. playlistListAdapter.setPlaying( @@ -359,17 +393,6 @@ class PlaylistDetailFragment : logD("Launching play from genre dialog for $song") findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromGenre(song.uid)) } - - private fun updateSelection(selected: List) { - playlistListAdapter.setSelected(selected.toSet()) - - val binding = requireBinding() - if (selected.isNotEmpty()) { - binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size) - } - updateMultiToolbar() - } - private fun updateMultiToolbar() { val id = when { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt index c46d23ea7..f7b293a06 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -130,6 +130,7 @@ class SongDetailDialog : ViewBindingDialogFragment() { if (show == null) return if (show is Show.SongDetails) { logD("Navigated to this song") + detailModel.toShow.consume() } else { error("Unexpected show command $show") } 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 0d2e40ad3..3d08cdfa0 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -65,6 +65,7 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.NoAudioPermissionException import org.oxycblt.auxio.music.NoMusicException import org.oxycblt.auxio.music.PERMISSION_READ_AUDIO +import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.collect @@ -85,9 +86,9 @@ import org.oxycblt.auxio.util.unlikelyToBeNull @AndroidEntryPoint class HomeFragment : SelectionFragment(), AppBarLayout.OnOffsetChangedListener { - override val playbackModel: PlaybackViewModel by activityViewModels() override val selectionModel: SelectionViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() + override val playbackModel: PlaybackViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() private var storagePermissionLauncher: ActivityResultLauncher? = null @@ -171,9 +172,10 @@ class HomeFragment : collect(homeModel.recreateTabs.flow, ::handleRecreate) collectImmediately(homeModel.currentTabMode, ::updateCurrentTab) collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab) + collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(musicModel.indexingState, ::updateIndexerState) + collect(musicModel.playlistDecision.flow, ::handleDecision) collect(detailModel.toShow.flow, ::handleShow) - collectImmediately(selectionModel.selected, ::updateSelection) } override fun onSaveInstanceState(outState: Bundle) { @@ -479,6 +481,39 @@ class HomeFragment : } } + private fun handleDecision(decision: PlaylistDecision?) { + if (decision == null) return + when (decision) { + is PlaylistDecision.New -> { + logD("Creating new playlist") + findNavController().navigateSafe( + HomeFragmentDirections.newPlaylist(decision.songs.map { it.uid }.toTypedArray())) + } + + is PlaylistDecision.Rename -> { + logD("Renaming ${decision.playlist}") + findNavController().navigateSafe( + HomeFragmentDirections.renamePlaylist(decision.playlist.uid) + ) + } + + is PlaylistDecision.Delete -> { + logD("Deleting ${decision.playlist}") + findNavController().navigateSafe( + HomeFragmentDirections.deletePlaylist(decision.playlist.uid) + ) + } + + is PlaylistDecision.Add -> { + logD("Adding ${decision.songs.size} to a playlist") + findNavController().navigateSafe( + HomeFragmentDirections.addToPlaylist(decision.songs.map { it.uid }.toTypedArray()) + ) + } + } + musicModel.playlistDecision.consume() + } + private fun updateFab(songs: List, isFastScrolling: Boolean) { val binding = requireBinding() // If there are no songs, it's likely that the library has not been loaded, so diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 6390929b7..b8717d220 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -52,23 +52,8 @@ constructor( val statistics: StateFlow get() = _statistics - private val _newPlaylistSongs = MutableEvent>() - /** Flag for opening a dialog to create a playlist of the given [Song]s. */ - val newPlaylistSongs: Event> = _newPlaylistSongs - - private val _playlistToRename = MutableEvent() - /** Flag for opening a dialog to rename the given [Playlist]. */ - val playlistToRename: Event - get() = _playlistToRename - - private val _playlistToDelete = MutableEvent() - /** Flag for opening a dialog to confirm deletion of the given [Playlist]. */ - val playlistToDelete: Event - get() = _playlistToDelete - - private val _songsToAdd = MutableEvent>() - /** Flag for opening a dialog to add the given [Song]s to a playlist. */ - val songsToAdd: Event> = _songsToAdd + private val _playlistDecision = MutableEvent() + val playlistDecision: Event get() = _playlistDecision init { musicRepository.addUpdateListener(this) @@ -121,7 +106,7 @@ constructor( viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) } } else { logD("Launching creation dialog for ${songs.size} songs") - _newPlaylistSongs.put(songs) + _playlistDecision.put(PlaylistDecision.New(songs)) } } @@ -137,7 +122,7 @@ constructor( viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) } } else { logD("Launching rename dialog for $playlist") - _playlistToRename.put(playlist) + _playlistDecision.put(PlaylistDecision.Rename(playlist)) } } @@ -154,7 +139,7 @@ constructor( viewModelScope.launch(Dispatchers.IO) { musicRepository.deletePlaylist(playlist) } } else { logD("Launching deletion dialog for $playlist") - _playlistToDelete.put(playlist) + _playlistDecision.put(PlaylistDecision.Delete(playlist)) } } @@ -214,7 +199,7 @@ constructor( viewModelScope.launch(Dispatchers.IO) { musicRepository.addToPlaylist(songs, playlist) } } else { logD("Launching addition dialog for songs=${songs.size}") - _songsToAdd.put(songs) + _playlistDecision.put(PlaylistDecision.Add(songs)) } } @@ -235,3 +220,10 @@ constructor( val durationMs: Long ) } + +sealed interface PlaylistDecision { + data class New(val songs: List) : PlaylistDecision + data class Rename(val playlist: Playlist) : PlaylistDecision + data class Delete(val playlist: Playlist) : PlaylistDecision + data class Add(val songs: List) : PlaylistDecision +} 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 90184814b..7239221f0 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -34,8 +34,10 @@ import com.google.android.material.transition.MaterialSharedAxis import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentSearchBinding +import org.oxycblt.auxio.detail.ArtistDetailFragmentDirections import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.Show +import org.oxycblt.auxio.home.HomeFragmentDirections import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item @@ -48,6 +50,7 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.collect @@ -136,10 +139,11 @@ class SearchFragment : ListFragment() { // --- VIEWMODEL SETUP --- collectImmediately(searchModel.searchResults, ::updateSearchResults) + collectImmediately(selectionModel.selected, ::updateSelection) + collect(musicModel.playlistDecision.flow, ::handleDecision) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(detailModel.toShow.flow, ::handleShow) - collectImmediately(selectionModel.selected, ::updateSelection) } override fun onDestroyBinding(binding: FragmentSearchBinding) { @@ -200,9 +204,7 @@ class SearchFragment : ListFragment() { } } - private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - searchAdapter.setPlaying(parent ?: song, isPlaying) - } + private fun handleShow(show: Show?) { when (show) { @@ -257,6 +259,44 @@ class SearchFragment : ListFragment() { hideKeyboard() } + + private fun handleDecision(decision: PlaylistDecision?) { + if (decision == null) return + when (decision) { + is PlaylistDecision.New -> { + logD("Creating new playlist") + findNavController().navigateSafe( + HomeFragmentDirections.newPlaylist(decision.songs.map { it.uid }.toTypedArray())) + } + + is PlaylistDecision.Rename -> { + logD("Renaming ${decision.playlist}") + findNavController().navigateSafe( + HomeFragmentDirections.renamePlaylist(decision.playlist.uid) + ) + } + + is PlaylistDecision.Delete -> { + logD("Deleting ${decision.playlist}") + findNavController().navigateSafe( + SearchFragmentDirections.deletePlaylist(decision.playlist.uid) + ) + } + + is PlaylistDecision.Add -> { + logD("Adding ${decision.songs.size} to a playlist") + findNavController().navigateSafe( + HomeFragmentDirections.addToPlaylist(decision.songs.map { it.uid }.toTypedArray()) + ) + } + } + musicModel.playlistDecision.consume() + } + + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { + searchAdapter.setPlaying(parent ?: song, isPlaying) + } + private fun updateSelection(selected: List) { searchAdapter.setSelected(selected.toSet()) val binding = requireBinding() From fcbce0fb98fca6d912fa03c4bc8e7a354a587bfd Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 27 Jun 2023 20:05:04 -0600 Subject: [PATCH 04/72] ui: fix issues from new nav Fix miscellanious issues from the flattened nav graph system. Nowhere near enough, largely counting on the planned bottom sheet menus to eventually be able to ignore most of these issues. --- app/build.gradle | 2 +- .../auxio/detail/AlbumDetailFragment.kt | 19 ++++++----- .../auxio/detail/ArtistDetailFragment.kt | 17 +++++----- .../auxio/detail/GenreDetailFragment.kt | 18 +++++------ .../auxio/detail/PlaylistDetailFragment.kt | 18 +++++------ .../org/oxycblt/auxio/home/HomeFragment.kt | 32 +++++++++---------- .../org/oxycblt/auxio/music/MusicViewModel.kt | 3 +- .../auxio/playback/PlaybackPanelFragment.kt | 17 ++++++++-- .../playback/persist/PersistenceDatabase.kt | 1 - .../org/oxycblt/auxio/search/SearchEngine.kt | 2 -- .../oxycblt/auxio/search/SearchFragment.kt | 30 +++++++---------- .../auxio/settings/RootPreferenceFragment.kt | 2 +- 12 files changed, 80 insertions(+), 81 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 0a14a0b74..c38c26e0d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -116,7 +116,7 @@ dependencies { implementation "androidx.preference:preference-ktx:1.2.0" // Database - def room_version = '2.6.0-alpha01' + def room_version = '2.6.0-alpha02' implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" 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 b0c60107f..8bf635e5e 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -222,7 +222,7 @@ class AlbumDetailFragment : } override fun onNavigateToParentArtist() { - detailModel.showAlbum(unlikelyToBeNull(detailModel.currentAlbum.value)) + detailModel.showArtist(unlikelyToBeNull(detailModel.currentAlbum.value)) } private fun updateAlbum(album: Album?) { @@ -314,18 +314,17 @@ class AlbumDetailFragment : private fun handleDecision(decision: PlaylistDecision?) { when (decision) { - is PlaylistDecision.Add ->{ + is PlaylistDecision.Add -> { logD("Adding ${decision.songs.size} songs to a playlist") - findNavController().navigateSafe( - AlbumDetailFragmentDirections.addToPlaylist( - decision.songs.map { it.uid }.toTypedArray()) - ) + findNavController() + .navigateSafe( + AlbumDetailFragmentDirections.addToPlaylist( + decision.songs.map { it.uid }.toTypedArray())) musicModel.playlistDecision.consume() } - - is PlaylistDecision.New, is PlaylistDecision.Rename, is PlaylistDecision.Delete -> - error("Unexpected decision $decision") - + is PlaylistDecision.New, + is PlaylistDecision.Rename, + is PlaylistDecision.Delete -> error("Unexpected decision $decision") null -> {} } } 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 347c13a1b..86208424b 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -324,18 +324,17 @@ class ArtistDetailFragment : private fun handleDecision(decision: PlaylistDecision?) { when (decision) { - is PlaylistDecision.Add ->{ + is PlaylistDecision.Add -> { logD("Adding ${decision.songs.size} songs to a playlist") - findNavController().navigateSafe( - ArtistDetailFragmentDirections.addToPlaylist( - decision.songs.map { it.uid }.toTypedArray()) - ) + findNavController() + .navigateSafe( + ArtistDetailFragmentDirections.addToPlaylist( + decision.songs.map { it.uid }.toTypedArray())) musicModel.playlistDecision.consume() } - - is PlaylistDecision.New, is PlaylistDecision.Rename, is PlaylistDecision.Delete -> - error("Unexpected decision $decision") - + is PlaylistDecision.New, + is PlaylistDecision.Rename, + is PlaylistDecision.Delete -> error("Unexpected decision $decision") null -> {} } } 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 d0d4ff1bd..a2d2e2cd9 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -289,6 +289,7 @@ class GenreDetailFragment : } is Show.GenreDetails -> { logD("Navigated to this genre") + detailModel.toShow.consume() } is Show.PlaylistDetails -> { error("Unexpected show command $show") @@ -311,18 +312,17 @@ class GenreDetailFragment : private fun handleDecision(decision: PlaylistDecision?) { when (decision) { - is PlaylistDecision.Add ->{ + is PlaylistDecision.Add -> { logD("Adding ${decision.songs.size} songs to a playlist") - findNavController().navigateSafe( - GenreDetailFragmentDirections.addToPlaylist( - decision.songs.map { it.uid }.toTypedArray()) - ) + findNavController() + .navigateSafe( + GenreDetailFragmentDirections.addToPlaylist( + decision.songs.map { it.uid }.toTypedArray())) musicModel.playlistDecision.consume() } - - is PlaylistDecision.New, is PlaylistDecision.Rename, is PlaylistDecision.Delete -> - error("Unexpected decision $decision") - + is PlaylistDecision.New, + is PlaylistDecision.Rename, + is PlaylistDecision.Delete -> error("Unexpected decision $decision") null -> {} } } 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 38aac6dcf..dc13ef831 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -336,6 +336,7 @@ class PlaylistDetailFragment : } is Show.PlaylistDetails -> { logD("Navigated to this playlist") + detailModel.toShow.consume() } is Show.GenreDetails -> { error("Unexpected show command $show") @@ -359,19 +360,18 @@ class PlaylistDetailFragment : when (decision) { is PlaylistDecision.Rename -> { logD("Renaming ${decision.playlist}") - findNavController().navigateSafe( - PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid) - ) + findNavController() + .navigateSafe( + PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid)) } - is PlaylistDecision.Delete -> { logD("Deleting ${decision.playlist}") - findNavController().navigateSafe( - PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid) - ) + findNavController() + .navigateSafe( + PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid)) } - - is PlaylistDecision.Add, is PlaylistDecision.New -> error("Unexpected decision $decision") + is PlaylistDecision.Add, + is PlaylistDecision.New -> error("Unexpected decision $decision") } musicModel.playlistDecision.consume() } 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 3d08cdfa0..7aa4dbe5f 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -486,29 +486,27 @@ class HomeFragment : when (decision) { is PlaylistDecision.New -> { logD("Creating new playlist") - findNavController().navigateSafe( - HomeFragmentDirections.newPlaylist(decision.songs.map { it.uid }.toTypedArray())) + findNavController() + .navigateSafe( + HomeFragmentDirections.newPlaylist( + decision.songs.map { it.uid }.toTypedArray())) } - is PlaylistDecision.Rename -> { logD("Renaming ${decision.playlist}") - findNavController().navigateSafe( - HomeFragmentDirections.renamePlaylist(decision.playlist.uid) - ) + findNavController() + .navigateSafe(HomeFragmentDirections.renamePlaylist(decision.playlist.uid)) } - is PlaylistDecision.Delete -> { logD("Deleting ${decision.playlist}") - findNavController().navigateSafe( - HomeFragmentDirections.deletePlaylist(decision.playlist.uid) - ) + findNavController() + .navigateSafe(HomeFragmentDirections.deletePlaylist(decision.playlist.uid)) } - is PlaylistDecision.Add -> { logD("Adding ${decision.songs.size} to a playlist") - findNavController().navigateSafe( - HomeFragmentDirections.addToPlaylist(decision.songs.map { it.uid }.toTypedArray()) - ) + findNavController() + .navigateSafe( + HomeFragmentDirections.addToPlaylist( + decision.songs.map { it.uid }.toTypedArray())) } } musicModel.playlistDecision.consume() @@ -560,11 +558,11 @@ class HomeFragment : } is Show.SongArtistDetails -> { logD("Navigating to artist choices for ${show.song}") - findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.song.uid)) + findNavController().navigateSafe(HomeFragmentDirections.showArtists(show.song.uid)) } is Show.AlbumArtistDetails -> { logD("Navigating to artist choices for ${show.album}") - findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.album.uid)) + findNavController().navigateSafe(HomeFragmentDirections.showArtists(show.album.uid)) } is Show.GenreDetails -> { logD("Navigating to ${show.genre}") @@ -573,7 +571,7 @@ class HomeFragment : is Show.PlaylistDetails -> { logD("Navigating to ${show.playlist}") findNavController() - .navigateSafe(HomeFragmentDirections.showGenre(show.playlist.uid)) + .navigateSafe(HomeFragmentDirections.showPlaylist(show.playlist.uid)) } null -> {} } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index b8717d220..5c2b2b86a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -53,7 +53,8 @@ constructor( get() = _statistics private val _playlistDecision = MutableEvent() - val playlistDecision: Event get() = _playlistDecision + val playlistDecision: Event + get() = _playlistDecision init { musicRepository.addUpdateListener(this) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index 0f1c2b57b..02b59c01e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -96,7 +96,12 @@ class PlaybackPanelFragment : // respective item. binding.playbackSong.apply { isSelected = true - setOnClickListener { playbackModel.song.value?.let(detailModel::showAlbum) } + setOnClickListener { + playbackModel.song.value?.let { + detailModel.showAlbum(it) + playbackModel.openMain() + } + } } binding.playbackArtist.apply { isSelected = true @@ -229,10 +234,16 @@ class PlaybackPanelFragment : } private fun navigateToCurrentArtist() { - playbackModel.song.value?.let(detailModel::showArtist) + playbackModel.song.value?.let { + detailModel.showArtist(it) + playbackModel.openMain() + } } private fun navigateToCurrentAlbum() { - playbackModel.song.value?.let(detailModel::showAlbum) + playbackModel.song.value?.let { + detailModel.showAlbum(it.album) + playbackModel.openMain() + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt index 466b56812..82ba84ddb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt @@ -136,7 +136,6 @@ interface QueueDao { } // TODO: Figure out how to get RepeatMode to map to an int instead of a string -// TODO: Use intrinsic table names rather than custom names @Entity data class PlaybackState( @PrimaryKey val id: Int, diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt index 24c5389fd..2c8d9158f 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -35,8 +35,6 @@ import org.oxycblt.auxio.util.logD * Implements the fuzzy-ish searching algorithm used in the search view. * * @author Alexander Capehart - * - * TODO: Handle locale changes */ interface SearchEngine { /** 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 7239221f0..4efc4c704 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -34,7 +34,6 @@ import com.google.android.material.transition.MaterialSharedAxis import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentSearchBinding -import org.oxycblt.auxio.detail.ArtistDetailFragmentDirections import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.Show import org.oxycblt.auxio.home.HomeFragmentDirections @@ -204,8 +203,6 @@ class SearchFragment : ListFragment() { } } - - private fun handleShow(show: Show?) { when (show) { is Show.SongDetails -> { @@ -259,35 +256,32 @@ class SearchFragment : ListFragment() { hideKeyboard() } - private fun handleDecision(decision: PlaylistDecision?) { if (decision == null) return when (decision) { is PlaylistDecision.New -> { logD("Creating new playlist") - findNavController().navigateSafe( - HomeFragmentDirections.newPlaylist(decision.songs.map { it.uid }.toTypedArray())) + findNavController() + .navigateSafe( + HomeFragmentDirections.newPlaylist( + decision.songs.map { it.uid }.toTypedArray())) } - is PlaylistDecision.Rename -> { logD("Renaming ${decision.playlist}") - findNavController().navigateSafe( - HomeFragmentDirections.renamePlaylist(decision.playlist.uid) - ) + findNavController() + .navigateSafe(HomeFragmentDirections.renamePlaylist(decision.playlist.uid)) } - is PlaylistDecision.Delete -> { logD("Deleting ${decision.playlist}") - findNavController().navigateSafe( - SearchFragmentDirections.deletePlaylist(decision.playlist.uid) - ) + findNavController() + .navigateSafe(SearchFragmentDirections.deletePlaylist(decision.playlist.uid)) } - is PlaylistDecision.Add -> { logD("Adding ${decision.songs.size} to a playlist") - findNavController().navigateSafe( - HomeFragmentDirections.addToPlaylist(decision.songs.map { it.uid }.toTypedArray()) - ) + findNavController() + .navigateSafe( + HomeFragmentDirections.addToPlaylist( + decision.songs.map { it.uid }.toTypedArray())) } } musicModel.playlistDecision.consume() diff --git a/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt index 8e8f3d589..4149cc0f5 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt @@ -55,7 +55,7 @@ class RootPreferenceFragment : BasePreferenceFragment(R.xml.preferences_root) { override fun onOpenDialogPreference(preference: WrappedDialogPreference) { if (preference.key == getString(R.string.set_key_music_dirs)) { - findNavController().navigate(RootPreferenceFragmentDirections.musicDirsSettings()) + findNavController().navigateSafe(RootPreferenceFragmentDirections.musicDirsSettings()) } } From a1efb0c34a2e2aad571bc6f63386c10423e9e5dc Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 30 Jun 2023 20:31:15 -0600 Subject: [PATCH 05/72] ui: rename menu resources Switch from redundant menu_* prefixes to more use-specific prefixes used elsewhere. --- .../org/oxycblt/auxio/detail/AlbumDetailFragment.kt | 6 +++--- .../org/oxycblt/auxio/detail/ArtistDetailFragment.kt | 8 ++++---- .../org/oxycblt/auxio/detail/GenreDetailFragment.kt | 8 ++++---- .../org/oxycblt/auxio/detail/PlaylistDetailFragment.kt | 4 ++-- .../org/oxycblt/auxio/home/list/AlbumListFragment.kt | 2 +- .../org/oxycblt/auxio/home/list/ArtistListFragment.kt | 2 +- .../org/oxycblt/auxio/home/list/GenreListFragment.kt | 2 +- .../oxycblt/auxio/home/list/PlaylistListFragment.kt | 2 +- .../org/oxycblt/auxio/home/list/SongListFragment.kt | 2 +- .../java/org/oxycblt/auxio/search/SearchFragment.kt | 10 +++++----- .../main/res/layout-h480dp/fragment_playback_panel.xml | 2 +- .../res/layout-sw600dp/fragment_playback_panel.xml | 2 +- app/src/main/res/layout/fragment_detail.xml | 4 ++-- app/src/main/res/layout/fragment_home.xml | 4 ++-- app/src/main/res/layout/fragment_playback_panel.xml | 2 +- app/src/main/res/layout/fragment_search.xml | 4 ++-- .../res/menu/{menu_album_detail.xml => item_album.xml} | 0 ...menu_album_song_actions.xml => item_album_song.xml} | 0 ..._artist_album_actions.xml => item_artist_album.xml} | 0 ...nu_artist_song_actions.xml => item_artist_song.xml} | 0 .../menu/{menu_parent_actions.xml => item_parent.xml} | 0 .../{menu_playlist_actions.xml => item_playlist.xml} | 0 ...laylist_song_actions.xml => item_playlist_song.xml} | 0 .../res/menu/{menu_song_actions.xml => item_song.xml} | 0 .../res/menu/{menu_album_sort.xml => sort_album.xml} | 0 .../res/menu/{menu_artist_sort.xml => sort_artist.xml} | 0 .../res/menu/{menu_genre_sort.xml => sort_genre.xml} | 0 .../menu/{menu_album_actions.xml => toolbar_album.xml} | 6 ------ .../menu/{menu_edit_actions.xml => toolbar_edit.xml} | 0 .../main/res/menu/{menu_home.xml => toolbar_home.xml} | 0 .../{menu_parent_detail.xml => toolbar_parent.xml} | 0 .../menu/{menu_playback.xml => toolbar_playback.xml} | 0 .../{menu_playlist_detail.xml => toolbar_playlist.xml} | 0 .../res/menu/{menu_search.xml => toolbar_search.xml} | 0 ...enu_selection_actions.xml => toolbar_selection.xml} | 0 35 files changed, 32 insertions(+), 38 deletions(-) rename app/src/main/res/menu/{menu_album_detail.xml => item_album.xml} (100%) rename app/src/main/res/menu/{menu_album_song_actions.xml => item_album_song.xml} (100%) rename app/src/main/res/menu/{menu_artist_album_actions.xml => item_artist_album.xml} (100%) rename app/src/main/res/menu/{menu_artist_song_actions.xml => item_artist_song.xml} (100%) rename app/src/main/res/menu/{menu_parent_actions.xml => item_parent.xml} (100%) rename app/src/main/res/menu/{menu_playlist_actions.xml => item_playlist.xml} (100%) rename app/src/main/res/menu/{menu_playlist_song_actions.xml => item_playlist_song.xml} (100%) rename app/src/main/res/menu/{menu_song_actions.xml => item_song.xml} (100%) rename app/src/main/res/menu/{menu_album_sort.xml => sort_album.xml} (100%) rename app/src/main/res/menu/{menu_artist_sort.xml => sort_artist.xml} (100%) rename app/src/main/res/menu/{menu_genre_sort.xml => sort_genre.xml} (100%) rename app/src/main/res/menu/{menu_album_actions.xml => toolbar_album.xml} (76%) rename app/src/main/res/menu/{menu_edit_actions.xml => toolbar_edit.xml} (100%) rename app/src/main/res/menu/{menu_home.xml => toolbar_home.xml} (100%) rename app/src/main/res/menu/{menu_parent_detail.xml => toolbar_parent.xml} (100%) rename app/src/main/res/menu/{menu_playback.xml => toolbar_playback.xml} (100%) rename app/src/main/res/menu/{menu_playlist_detail.xml => toolbar_playlist.xml} (100%) rename app/src/main/res/menu/{menu_search.xml => toolbar_search.xml} (100%) rename app/src/main/res/menu/{menu_selection_actions.xml => toolbar_selection.xml} (100%) 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 8bf635e5e..1197a5f78 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -101,7 +101,7 @@ class AlbumDetailFragment : // --- UI SETUP -- binding.detailNormalToolbar.apply { - inflateMenu(R.menu.menu_album_detail) + inflateMenu(R.menu.toolbar_album) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@AlbumDetailFragment) } @@ -183,7 +183,7 @@ class AlbumDetailFragment : } override fun onOpenMenu(item: Song, anchor: View) { - openMusicMenu(anchor, R.menu.menu_album_song_actions, item) + openMusicMenu(anchor, R.menu.item_album_song, item) } override fun onPlay() { @@ -195,7 +195,7 @@ class AlbumDetailFragment : } override fun onOpenSortMenu(anchor: View) { - openMenu(anchor, R.menu.menu_album_sort) { + openMenu(anchor, R.menu.sort_album) { // Select the corresponding sort mode option val sort = detailModel.albumSongSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true 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 86208424b..b281ec397 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -99,7 +99,7 @@ class ArtistDetailFragment : // --- UI SETUP --- binding.detailNormalToolbar.apply { - inflateMenu(R.menu.menu_parent_detail) + inflateMenu(R.menu.toolbar_parent) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@ArtistDetailFragment) } @@ -194,8 +194,8 @@ class ArtistDetailFragment : override fun onOpenMenu(item: Music, anchor: View) { when (item) { - is Song -> openMusicMenu(anchor, R.menu.menu_artist_song_actions, item) - is Album -> openMusicMenu(anchor, R.menu.menu_artist_album_actions, item) + is Song -> openMusicMenu(anchor, R.menu.item_artist_song, item) + is Album -> openMusicMenu(anchor, R.menu.item_artist_album, item) else -> error("Unexpected datatype: ${item::class.simpleName}") } } @@ -209,7 +209,7 @@ class ArtistDetailFragment : } override fun onOpenSortMenu(anchor: View) { - openMenu(anchor, R.menu.menu_artist_sort) { + openMenu(anchor, R.menu.sort_artist) { // Select the corresponding sort mode option val sort = detailModel.artistSongSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true 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 a2d2e2cd9..a8646fe24 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -97,7 +97,7 @@ class GenreDetailFragment : // --- UI SETUP --- binding.detailNormalToolbar.apply { - inflateMenu(R.menu.menu_parent_detail) + inflateMenu(R.menu.toolbar_parent) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@GenreDetailFragment) } @@ -192,8 +192,8 @@ class GenreDetailFragment : override fun onOpenMenu(item: Music, anchor: View) { when (item) { - is Artist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item) - is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item) + is Artist -> openMusicMenu(anchor, R.menu.item_parent, item) + is Song -> openMusicMenu(anchor, R.menu.item_song, item) else -> error("Unexpected datatype: ${item::class.simpleName}") } } @@ -207,7 +207,7 @@ class GenreDetailFragment : } override fun onOpenSortMenu(anchor: View) { - openMenu(anchor, R.menu.menu_genre_sort) { + openMenu(anchor, R.menu.sort_genre) { // Select the corresponding sort mode option val sort = detailModel.genreSongSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true 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 dc13ef831..b444abfdb 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -102,7 +102,7 @@ class PlaylistDetailFragment : // --- UI SETUP --- binding.detailNormalToolbar.apply { - inflateMenu(R.menu.menu_playlist_detail) + inflateMenu(R.menu.toolbar_playlist) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@PlaylistDetailFragment) } @@ -235,7 +235,7 @@ class PlaylistDetailFragment : } override fun onOpenMenu(item: Song, anchor: View) { - openMusicMenu(anchor, R.menu.menu_playlist_song_actions, item) + openMusicMenu(anchor, R.menu.item_playlist_song, item) } override fun onPlay() { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt index b4aac9121..f6ed98f88 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt @@ -142,7 +142,7 @@ class AlbumListFragment : } override fun onOpenMenu(item: Album, anchor: View) { - openMusicMenu(anchor, R.menu.menu_album_actions, item) + openMusicMenu(anchor, R.menu.item_album, item) } private fun updateAlbums(albums: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt index b66c6e965..86fdc3483 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt @@ -118,7 +118,7 @@ class ArtistListFragment : } override fun onOpenMenu(item: Artist, anchor: View) { - openMusicMenu(anchor, R.menu.menu_parent_actions, item) + openMusicMenu(anchor, R.menu.item_parent, item) } private fun updateArtists(artists: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index d751e3699..46c26f689 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -117,7 +117,7 @@ class GenreListFragment : } override fun onOpenMenu(item: Genre, anchor: View) { - openMusicMenu(anchor, R.menu.menu_parent_actions, item) + openMusicMenu(anchor, R.menu.item_parent, item) } private fun updateGenres(genres: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt index 405dbe312..cef433bde 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt @@ -115,7 +115,7 @@ class PlaylistListFragment : } override fun onOpenMenu(item: Playlist, anchor: View) { - openMusicMenu(anchor, R.menu.menu_playlist_actions, item) + openMusicMenu(anchor, R.menu.item_playlist, item) } private fun updatePlaylists(playlists: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt index d827adbf7..b22366658 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt @@ -143,7 +143,7 @@ class SongListFragment : } override fun onOpenMenu(item: Song, anchor: View) { - openMusicMenu(anchor, R.menu.menu_song_actions, item) + openMusicMenu(anchor, R.menu.item_song, item) } private fun updateSongs(songs: List) { 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 4efc4c704..ff41e9910 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -181,11 +181,11 @@ class SearchFragment : ListFragment() { override fun onOpenMenu(item: Music, anchor: View) { when (item) { - is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item) - is Album -> openMusicMenu(anchor, R.menu.menu_album_actions, item) - is Artist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item) - is Genre -> openMusicMenu(anchor, R.menu.menu_parent_actions, item) - is Playlist -> openMusicMenu(anchor, R.menu.menu_playlist_actions, item) + is Song -> openMusicMenu(anchor, R.menu.item_song, item) + is Album -> openMusicMenu(anchor, R.menu.item_album, item) + is Artist -> openMusicMenu(anchor, R.menu.item_parent, item) + is Genre -> openMusicMenu(anchor, R.menu.item_parent, item) + is Playlist -> openMusicMenu(anchor, R.menu.item_playlist, item) } } diff --git a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml index f2919eacc..263bf2a7d 100644 --- a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml @@ -11,7 +11,7 @@ android:layout_height="wrap_content" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:menu="@menu/menu_playback" + app:menu="@menu/toolbar_playback" app:navigationIcon="@drawable/ic_down_24" app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> diff --git a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml index 9fa7ff413..c673dd8ca 100644 --- a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml @@ -11,7 +11,7 @@ android:layout_height="wrap_content" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:menu="@menu/menu_playback" + app:menu="@menu/toolbar_playback" app:navigationIcon="@drawable/ic_down_24" app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> diff --git a/app/src/main/res/layout/fragment_detail.xml b/app/src/main/res/layout/fragment_detail.xml index a272ca07f..1dfcf82b9 100644 --- a/app/src/main/res/layout/fragment_detail.xml +++ b/app/src/main/res/layout/fragment_detail.xml @@ -33,7 +33,7 @@ android:clickable="true" android:focusable="true" app:navigationIcon="@drawable/ic_close_24" - app:menu="@menu/menu_selection_actions" /> + app:menu="@menu/toolbar_selection" /> + app:menu="@menu/toolbar_edit" /> diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 712509a65..049256481 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -22,7 +22,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_scrollFlags="scroll|enterAlways" - app:menu="@menu/menu_home" + app:menu="@menu/toolbar_home" app:title="@string/info_app_name" /> + app:menu="@menu/toolbar_selection" /> diff --git a/app/src/main/res/layout/fragment_playback_panel.xml b/app/src/main/res/layout/fragment_playback_panel.xml index 313c018f0..74f106903 100644 --- a/app/src/main/res/layout/fragment_playback_panel.xml +++ b/app/src/main/res/layout/fragment_playback_panel.xml @@ -11,7 +11,7 @@ android:layout_height="wrap_content" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:menu="@menu/menu_playback" + app:menu="@menu/toolbar_playback" app:navigationIcon="@drawable/ic_down_24" app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index 2aac496d0..d8eba32d7 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -21,7 +21,7 @@ android:id="@+id/search_normal_toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" - app:menu="@menu/menu_search" + app:menu="@menu/toolbar_search" app:navigationIcon="@drawable/ic_back_24"> + app:menu="@menu/toolbar_selection" /> diff --git a/app/src/main/res/menu/menu_album_detail.xml b/app/src/main/res/menu/item_album.xml similarity index 100% rename from app/src/main/res/menu/menu_album_detail.xml rename to app/src/main/res/menu/item_album.xml diff --git a/app/src/main/res/menu/menu_album_song_actions.xml b/app/src/main/res/menu/item_album_song.xml similarity index 100% rename from app/src/main/res/menu/menu_album_song_actions.xml rename to app/src/main/res/menu/item_album_song.xml diff --git a/app/src/main/res/menu/menu_artist_album_actions.xml b/app/src/main/res/menu/item_artist_album.xml similarity index 100% rename from app/src/main/res/menu/menu_artist_album_actions.xml rename to app/src/main/res/menu/item_artist_album.xml diff --git a/app/src/main/res/menu/menu_artist_song_actions.xml b/app/src/main/res/menu/item_artist_song.xml similarity index 100% rename from app/src/main/res/menu/menu_artist_song_actions.xml rename to app/src/main/res/menu/item_artist_song.xml diff --git a/app/src/main/res/menu/menu_parent_actions.xml b/app/src/main/res/menu/item_parent.xml similarity index 100% rename from app/src/main/res/menu/menu_parent_actions.xml rename to app/src/main/res/menu/item_parent.xml diff --git a/app/src/main/res/menu/menu_playlist_actions.xml b/app/src/main/res/menu/item_playlist.xml similarity index 100% rename from app/src/main/res/menu/menu_playlist_actions.xml rename to app/src/main/res/menu/item_playlist.xml diff --git a/app/src/main/res/menu/menu_playlist_song_actions.xml b/app/src/main/res/menu/item_playlist_song.xml similarity index 100% rename from app/src/main/res/menu/menu_playlist_song_actions.xml rename to app/src/main/res/menu/item_playlist_song.xml diff --git a/app/src/main/res/menu/menu_song_actions.xml b/app/src/main/res/menu/item_song.xml similarity index 100% rename from app/src/main/res/menu/menu_song_actions.xml rename to app/src/main/res/menu/item_song.xml diff --git a/app/src/main/res/menu/menu_album_sort.xml b/app/src/main/res/menu/sort_album.xml similarity index 100% rename from app/src/main/res/menu/menu_album_sort.xml rename to app/src/main/res/menu/sort_album.xml diff --git a/app/src/main/res/menu/menu_artist_sort.xml b/app/src/main/res/menu/sort_artist.xml similarity index 100% rename from app/src/main/res/menu/menu_artist_sort.xml rename to app/src/main/res/menu/sort_artist.xml diff --git a/app/src/main/res/menu/menu_genre_sort.xml b/app/src/main/res/menu/sort_genre.xml similarity index 100% rename from app/src/main/res/menu/menu_genre_sort.xml rename to app/src/main/res/menu/sort_genre.xml diff --git a/app/src/main/res/menu/menu_album_actions.xml b/app/src/main/res/menu/toolbar_album.xml similarity index 76% rename from app/src/main/res/menu/menu_album_actions.xml rename to app/src/main/res/menu/toolbar_album.xml index 6f9f28aff..7cc2b4b79 100644 --- a/app/src/main/res/menu/menu_album_actions.xml +++ b/app/src/main/res/menu/toolbar_album.xml @@ -1,11 +1,5 @@ - - diff --git a/app/src/main/res/menu/menu_edit_actions.xml b/app/src/main/res/menu/toolbar_edit.xml similarity index 100% rename from app/src/main/res/menu/menu_edit_actions.xml rename to app/src/main/res/menu/toolbar_edit.xml diff --git a/app/src/main/res/menu/menu_home.xml b/app/src/main/res/menu/toolbar_home.xml similarity index 100% rename from app/src/main/res/menu/menu_home.xml rename to app/src/main/res/menu/toolbar_home.xml diff --git a/app/src/main/res/menu/menu_parent_detail.xml b/app/src/main/res/menu/toolbar_parent.xml similarity index 100% rename from app/src/main/res/menu/menu_parent_detail.xml rename to app/src/main/res/menu/toolbar_parent.xml diff --git a/app/src/main/res/menu/menu_playback.xml b/app/src/main/res/menu/toolbar_playback.xml similarity index 100% rename from app/src/main/res/menu/menu_playback.xml rename to app/src/main/res/menu/toolbar_playback.xml diff --git a/app/src/main/res/menu/menu_playlist_detail.xml b/app/src/main/res/menu/toolbar_playlist.xml similarity index 100% rename from app/src/main/res/menu/menu_playlist_detail.xml rename to app/src/main/res/menu/toolbar_playlist.xml diff --git a/app/src/main/res/menu/menu_search.xml b/app/src/main/res/menu/toolbar_search.xml similarity index 100% rename from app/src/main/res/menu/menu_search.xml rename to app/src/main/res/menu/toolbar_search.xml diff --git a/app/src/main/res/menu/menu_selection_actions.xml b/app/src/main/res/menu/toolbar_selection.xml similarity index 100% rename from app/src/main/res/menu/menu_selection_actions.xml rename to app/src/main/res/menu/toolbar_selection.xml From 6dc2892eb90ce359cd87c36b3b388350ef348c6d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 3 Jul 2023 10:45:32 -0600 Subject: [PATCH 06/72] home: fix settings anim Fix mismatched navigation animations when going to settings and back. --- .../java/org/oxycblt/auxio/MainFragment.kt | 7 ---- .../org/oxycblt/auxio/home/HomeFragment.kt | 39 ++++++++++++------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 9b6b47a08..5ae68fc78 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -34,7 +34,6 @@ import androidx.navigation.findNavController import com.google.android.material.R as MR import com.google.android.material.bottomsheet.BackportBottomSheetBehavior import com.google.android.material.shape.MaterialShapeDrawable -import com.google.android.material.transition.MaterialFadeThrough import dagger.hilt.android.AndroidEntryPoint import kotlin.math.max import kotlin.math.min @@ -79,12 +78,6 @@ class MainFragment : private var elevationNormal = 0f private var initialNavDestinationChange = true - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough() - exitTransition = MaterialFadeThrough() - } - override fun onCreateBinding(inflater: LayoutInflater) = FragmentMainBinding.inflate(inflater) override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) { 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 7aa4dbe5f..4a878826f 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -38,6 +38,7 @@ import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.appbar.AppBarLayout import com.google.android.material.tabs.TabLayoutMediator +import com.google.android.material.transition.MaterialFadeThrough import com.google.android.material.transition.MaterialSharedAxis import dagger.hilt.android.AndroidEntryPoint import java.lang.reflect.Field @@ -100,9 +101,11 @@ class HomeFragment : // Orientation change will wipe whatever transition we were using prior, which will // result in no transition when the user navigates back. Make sure we re-initialize // our transitions. - val axis = savedInstanceState.getInt(KEY_LAST_TRANSITION_AXIS, -1) - if (axis > -1) { - setupAxisTransitions(axis) + val id = savedInstanceState.getInt(KEY_LAST_TRANSITION_ID, -2) + when (id) { + -2 -> {} + -1 -> applyFadeTransition() + else -> applyAxisTransition(id) } } } @@ -179,9 +182,9 @@ class HomeFragment : } override fun onSaveInstanceState(outState: Bundle) { - val enter = enterTransition - if (enter is MaterialSharedAxis) { - outState.putInt(KEY_LAST_TRANSITION_AXIS, enter.axis) + when (val transition = enterTransition) { + is MaterialFadeThrough -> outState.putInt(KEY_LAST_TRANSITION_ID, -1) + is MaterialSharedAxis -> outState.putInt(KEY_LAST_TRANSITION_ID, transition.axis) } super.onSaveInstanceState(outState) @@ -214,17 +217,19 @@ class HomeFragment : // Handle main actions (Search, Settings, About) R.id.action_search -> { logD("Navigating to search") - setupAxisTransitions(MaterialSharedAxis.Z) + applyAxisTransition(MaterialSharedAxis.Z) findNavController().navigateSafe(HomeFragmentDirections.search()) true } R.id.action_settings -> { logD("Navigating to preferences") + applyFadeTransition() findNavController().navigateSafe(HomeFragmentDirections.preferences()) true } R.id.action_about -> { logD("Navigating to about") + applyFadeTransition() findNavController().navigateSafe(HomeFragmentDirections.about()) true } @@ -537,7 +542,7 @@ class HomeFragment : // fragment should be launched otherwise. is Show.SongAlbumDetails -> { logD("Navigating to the album of ${show.song}") - setupAxisTransitions(MaterialSharedAxis.X) + applyAxisTransition(MaterialSharedAxis.X) findNavController() .navigateSafe(HomeFragmentDirections.showAlbum(show.song.album.uid)) } @@ -546,14 +551,14 @@ class HomeFragment : // detail fragment. is Show.AlbumDetails -> { logD("Navigating to ${show.album}") - setupAxisTransitions(MaterialSharedAxis.X) + applyAxisTransition(MaterialSharedAxis.X) findNavController().navigateSafe(HomeFragmentDirections.showAlbum(show.album.uid)) } // Always launch a new ArtistDetailFragment. is Show.ArtistDetails -> { logD("Navigating to ${show.artist}") - setupAxisTransitions(MaterialSharedAxis.X) + applyAxisTransition(MaterialSharedAxis.X) findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.artist.uid)) } is Show.SongArtistDetails -> { @@ -566,10 +571,12 @@ class HomeFragment : } is Show.GenreDetails -> { logD("Navigating to ${show.genre}") + applyAxisTransition(MaterialSharedAxis.X) findNavController().navigateSafe(HomeFragmentDirections.showGenre(show.genre.uid)) } is Show.PlaylistDetails -> { logD("Navigating to ${show.playlist}") + applyAxisTransition(MaterialSharedAxis.X) findNavController() .navigateSafe(HomeFragmentDirections.showPlaylist(show.playlist.uid)) } @@ -591,7 +598,7 @@ class HomeFragment : } } - private fun setupAxisTransitions(axis: Int) { + private fun applyAxisTransition(axis: Int) { // Sanity check to avoid in-correct axis transitions check(axis == MaterialSharedAxis.X || axis == MaterialSharedAxis.Z) { "Not expecting Y axis transition" @@ -603,6 +610,13 @@ class HomeFragment : reenterTransition = MaterialSharedAxis(axis, false) } + private fun applyFadeTransition() { + enterTransition = MaterialFadeThrough() + returnTransition = MaterialFadeThrough() + exitTransition = MaterialFadeThrough() + reenterTransition = MaterialFadeThrough() + } + /** * [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance. * @@ -630,7 +644,6 @@ class HomeFragment : private companion object { val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView") val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop") - const val KEY_LAST_TRANSITION_AXIS = - BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS" + const val KEY_LAST_TRANSITION_ID = BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS" } } From c1158b1a07077ae4ee557b17743aa46cbe7fe188 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 3 Jul 2023 14:24:26 -0600 Subject: [PATCH 07/72] list: add menu dialog framework Add a dialog that shows menu information as a bottom sheet instead of as a PopupMenu. This did not take as much finessing of BottomSheetBehavior as the main playback UI framework did, just some style redefinitions. --- .../oxycblt/auxio/detail/SongDetailDialog.kt | 6 +- .../auxio/detail/picker/ShowArtistDialog.kt | 6 +- .../auxio/home/tabs/TabCustomizeDialog.kt | 7 +- .../auxio/list/menu/MenuDialogFragment.kt | 55 +++++++++ .../auxio/list/menu/MenuOptionAdapter.kt | 73 ++++++++++++ .../auxio/list/recycler/DialogRecyclerView.kt | 9 ++ .../oxycblt/auxio/music/fs/MusicDirsDialog.kt | 4 +- .../auxio/music/metadata/SeparatorsDialog.kt | 8 +- .../auxio/music/picker/AddToPlaylistDialog.kt | 4 +- .../music/picker/DeletePlaylistDialog.kt | 6 +- .../auxio/music/picker/NewPlaylistDialog.kt | 4 +- .../music/picker/RenamePlaylistDialog.kt | 4 +- .../playback/picker/PlayFromArtistDialog.kt | 6 +- .../playback/picker/PlayFromGenreDialog.kt | 6 +- .../replaygain/PreAmpCustomizeDialog.kt | 7 +- .../ViewBindingBottomSheetDialogFragment.kt | 111 ++++++++++++++++++ ...t => ViewBindingMaterialDialogFragment.kt} | 7 +- .../auxio/ui/accent/AccentCustomizeDialog.kt | 6 +- app/src/main/res/layout/dialog_menu.xml | 24 ++++ app/src/main/res/layout/item_menu_option.xml | 12 ++ app/src/main/res/navigation/main.xml | 9 ++ app/src/main/res/values/styles_core.xml | 2 + app/src/main/res/values/styles_ui.xml | 22 ++++ 23 files changed, 359 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt rename app/src/main/java/org/oxycblt/auxio/ui/{ViewBindingDialogFragment.kt => ViewBindingMaterialDialogFragment.kt} (95%) create mode 100644 app/src/main/res/layout/dialog_menu.xml create mode 100644 app/src/main/res/layout/item_menu_option.xml diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt index f7b293a06..e63c630ac 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -38,18 +38,18 @@ import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.metadata.AudioProperties import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.formatDurationMs -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.concatLocalized import org.oxycblt.auxio.util.logD /** - * A [ViewBindingDialogFragment] that shows information about a Song. + * A [ViewBindingMaterialDialogFragment] that shows information about a Song. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class SongDetailDialog : ViewBindingDialogFragment() { +class SongDetailDialog : ViewBindingMaterialDialogFragment() { private val detailModel: DetailViewModel by activityViewModels() // Information about what song to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an song. diff --git a/app/src/main/java/org/oxycblt/auxio/detail/picker/ShowArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/picker/ShowArtistDialog.kt index a98dfb162..2e7ef1ba1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/picker/ShowArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/picker/ShowArtistDialog.kt @@ -33,17 +33,17 @@ import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately /** - * A picker [ViewBindingDialogFragment] intended for when the [Artist] to show is ambiguous. + * A picker [ViewBindingMaterialDialogFragment] intended for when the [Artist] to show is ambiguous. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class ShowArtistDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingMaterialDialogFragment(), ClickableListListener { private val detailModel: DetailViewModel by activityViewModels() private val pickerModel: NavigationPickerViewModel by viewModels() // Information about what artists to show choices for is initially within the navigation diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt index c7dadd8d2..fd6b36ae7 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt @@ -30,17 +30,18 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogTabsBinding import org.oxycblt.auxio.home.HomeSettings import org.oxycblt.auxio.list.EditClickListListener -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.logD /** - * A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration. + * A [ViewBindingMaterialDialogFragment] that allows the user to modify the home [Tab] + * configuration. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class TabCustomizeDialog : - ViewBindingDialogFragment(), EditClickListListener { + ViewBindingMaterialDialogFragment(), EditClickListListener { private val tabAdapter = TabAdapter(this) private var touchHelper: ItemTouchHelper? = null @Inject lateinit var homeSettings: HomeSettings diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt new file mode 100644 index 000000000..5129857dc --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 Auxio Project + * MenuDialogFragment.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.menu + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuInflater +import androidx.appcompat.view.menu.MenuBuilder +import androidx.core.view.children +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogMenuBinding +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.ui.ViewBindingBottomSheetDialogFragment + +/** + * A [ViewBindingBottomSheetDialogFragment] that displays basic music information and + * a series of options. + * @author Alexander Capehart (OxygenCobalt) + */ +class MenuDialogFragment : ViewBindingBottomSheetDialogFragment() { + private val menuAdapter = MenuOptionAdapter() + + override fun onCreateBinding(inflater: LayoutInflater) = DialogMenuBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogMenuBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + binding.menuRecycler.apply { + adapter = menuAdapter + itemAnimator = null + } + + // Avoid having to use a dummy view and rely on what AndroidX Toolbar uses. + @SuppressLint("RestrictedApi") val builder = MenuBuilder(requireContext()) + MenuInflater(requireContext()).inflate(R.menu.item_song, builder) + menuAdapter.update(builder.children.toList(), UpdateInstructions.Diff) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt new file mode 100644 index 000000000..a9e23f11e --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 Auxio Project + * MenuOptionAdapter.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.menu + +import android.view.MenuItem +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import org.oxycblt.auxio.databinding.ItemMenuOptionBinding +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.list.recycler.DialogRecyclerView +import org.oxycblt.auxio.util.inflater + +/** + * Displays a list of [MenuItem]s as custom list items. + * @author Alexander Capehart (OxygenCobalt) + */ +class MenuOptionAdapter : + FlexibleListAdapter(MenuOptionViewHolder.DIFF_CALLBACK) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + MenuOptionViewHolder.from(parent) + + override fun onBindViewHolder(holder: MenuOptionViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +/** + * A [DialogRecyclerView.ViewHolder] that displays a list of menu options based on [MenuItem]. + * @author Alexander Capehart (OxygenCobalt) + */ +class MenuOptionViewHolder private constructor(private val binding: ItemMenuOptionBinding) : + DialogRecyclerView.ViewHolder(binding.root) { + fun bind(item: MenuItem) { + binding.title.text = item.title + } + + companion object { + /** + * Create a new instance. + * + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ + fun from(parent: ViewGroup) = + MenuOptionViewHolder(ItemMenuOptionBinding.inflate(parent.context.inflater)) + + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: MenuItem, newItem: MenuItem) = + oldItem == newItem + + override fun areContentsTheSame(oldItem: MenuItem, newItem: MenuItem) = + oldItem.title == newItem.title + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt index 9fc255ce1..90a5786f6 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt @@ -22,6 +22,7 @@ import android.content.Context import android.util.AttributeSet import android.view.View import android.view.ViewGroup +import android.view.WindowInsets import androidx.annotation.AttrRes import androidx.core.view.isInvisible import androidx.core.view.updatePadding @@ -31,6 +32,7 @@ import com.google.android.material.divider.MaterialDivider import org.oxycblt.auxio.R import org.oxycblt.auxio.list.recycler.DialogRecyclerView.ViewHolder import org.oxycblt.auxio.util.getDimenPixels +import org.oxycblt.auxio.util.systemBarInsetsCompat /** * A [RecyclerView] intended for use in Dialogs, adding features such as: @@ -74,6 +76,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr invalidateDividers() } + override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + // Update the RecyclerView's padding such that the bottom insets are applied + // while still preserving bottom padding. + updatePadding(bottom = insets.systemBarInsetsCompat.bottom) + return insets + } + override fun onScrolled(dx: Int, dy: Int) { super.onScrolled(dx, dy) // Scroll event occurred, need to update the dividers. diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt index 4ecea1336..bca211f9a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt @@ -35,7 +35,7 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicDirsBinding import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast @@ -47,7 +47,7 @@ import org.oxycblt.auxio.util.showToast */ @AndroidEntryPoint class MusicDirsDialog : - ViewBindingDialogFragment(), DirectoryAdapter.Listener { + ViewBindingMaterialDialogFragment(), DirectoryAdapter.Listener { private val dirAdapter = DirectoryAdapter(this) private var openDocumentTreeLauncher: ActivityResultLauncher? = null private var storageManager: StorageManager? = null diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt index 3496ea059..d74b9ba53 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt @@ -29,19 +29,19 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogSeparatorsBinding import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.logW /** - * A [ViewBindingDialogFragment] that allows the user to configure the separator characters used to - * split tags with multiple values. + * A [ViewBindingMaterialDialogFragment] that allows the user to configure the separator characters + * used to split tags with multiple values. * * @author Alexander Capehart (OxygenCobalt) * * TODO: Replace with unsplit names dialog */ @AndroidEntryPoint -class SeparatorsDialog : ViewBindingDialogFragment() { +class SeparatorsDialog : ViewBindingMaterialDialogFragment() { @Inject lateinit var musicSettings: MusicSettings override fun onCreateBinding(inflater: LayoutInflater) = diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt index d22f9a5e9..00175dc2a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt @@ -33,7 +33,7 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast @@ -45,7 +45,7 @@ import org.oxycblt.auxio.util.showToast */ @AndroidEntryPoint class AddToPlaylistDialog : - ViewBindingDialogFragment(), + ViewBindingMaterialDialogFragment(), ClickableListListener, NewPlaylistFooterAdapter.Listener { private val musicModel: MusicViewModel by activityViewModels() diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt index 15d347199..fbe599750 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt @@ -30,19 +30,19 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A [ViewBindingDialogFragment] that asks the user to confirm the deletion of a [Playlist]. + * A [ViewBindingMaterialDialogFragment] that asks the user to confirm the deletion of a [Playlist]. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class DeletePlaylistDialog : ViewBindingDialogFragment() { +class DeletePlaylistDialog : ViewBindingMaterialDialogFragment() { private val pickerModel: PlaylistPickerViewModel by viewModels() private val musicModel: MusicViewModel by activityViewModels() // Information about what playlist to name for is initially within the navigation arguments diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt index d0c4a03cc..b21925781 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt @@ -30,7 +30,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.music.MusicViewModel -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast @@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class NewPlaylistDialog : ViewBindingDialogFragment() { +class NewPlaylistDialog : ViewBindingMaterialDialogFragment() { private val musicModel: MusicViewModel by activityViewModels() private val pickerModel: PlaylistPickerViewModel by viewModels() // Information about what playlist to name for is initially within the navigation arguments diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt index 20ed39bd5..024396525 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt @@ -31,7 +31,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast @@ -43,7 +43,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class RenamePlaylistDialog : ViewBindingDialogFragment() { +class RenamePlaylistDialog : ViewBindingMaterialDialogFragment() { private val musicModel: MusicViewModel by activityViewModels() private val pickerModel: PlaylistPickerViewModel by viewModels() // Information about what playlist to name for is initially within the navigation arguments diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt index e42e6ff61..1c7f025ef 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt @@ -33,19 +33,19 @@ import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A picker [ViewBindingDialogFragment] intended for when [Artist] playback is ambiguous. + * A picker [ViewBindingMaterialDialogFragment] intended for when [Artist] playback is ambiguous. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class PlayFromArtistDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingMaterialDialogFragment(), ClickableListListener { private val playbackModel: PlaybackViewModel by activityViewModels() private val pickerModel: PlaybackPickerViewModel by viewModels() // Information about what Song to show choices for is initially within the navigation arguments diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt index 6811e3510..e47f87968 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt @@ -33,19 +33,19 @@ import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous. + * A picker [ViewBindingMaterialDialogFragment] intended for when [Genre] playback is ambiguous. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class PlayFromGenreDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingMaterialDialogFragment(), ClickableListListener { private val playbackModel: PlaybackViewModel by activityViewModels() private val pickerModel: PlaybackPickerViewModel by viewModels() // Information about what Song to show choices for is initially within the navigation arguments diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt index dcd7db42e..6cc1cea54 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt @@ -28,16 +28,17 @@ import kotlin.math.abs import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogPreAmpBinding import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.logD /** - * aa [ViewBindingDialogFragment] that allows user configuration of the current [ReplayGainPreAmp]. + * aa [ViewBindingMaterialDialogFragment] that allows user configuration of the current + * [ReplayGainPreAmp]. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class PreAmpCustomizeDialog : ViewBindingDialogFragment() { +class PreAmpCustomizeDialog : ViewBindingMaterialDialogFragment() { @Inject lateinit var playbackSettings: PlaybackSettings override fun onCreateBinding(inflater: LayoutInflater) = DialogPreAmpBinding.inflate(inflater) diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt new file mode 100644 index 000000000..2072d62e7 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2022 Auxio Project + * ViewBindingBottomSheetDialogFragment.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.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.viewbinding.ViewBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.unlikelyToBeNull + +/** + * A lifecycle-aware [DialogFragment] that automatically manages the [ViewBinding] lifecycle as a + * [BottomSheetDialogFragment]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +abstract class ViewBindingBottomSheetDialogFragment : + BottomSheetDialogFragment() { + private var _binding: VB? = null + + /** + * Configure the [AlertDialog.Builder] during [onCreateDialog]. + * + * @param builder The [AlertDialog.Builder] to configure. + * @see onCreateDialog + */ + protected open fun onConfigDialog(builder: AlertDialog.Builder) {} + + /** + * Inflate the [ViewBinding] during [onCreateView]. + * + * @param inflater The [LayoutInflater] to inflate the [ViewBinding] with. + * @return A new [ViewBinding] instance. + * @see onCreateView + */ + protected abstract fun onCreateBinding(inflater: LayoutInflater): VB + + /** + * Configure the newly-inflated [ViewBinding] during [onViewCreated]. + * + * @param binding The [ViewBinding] to configure. + * @param savedInstanceState The previously saved state of the UI. + * @see onViewCreated + */ + protected open fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {} + + /** + * Free memory held by the [ViewBinding] during [onDestroyView] + * + * @param binding The [ViewBinding] to release. + * @see onDestroyView + */ + protected open fun onDestroyBinding(binding: VB) {} + + /** The [ViewBinding], or null if it has not been inflated yet. */ + protected val binding: VB? + get() = _binding + + /** + * Get the [ViewBinding] under the assumption that it has been inflated. + * + * @return The currently-inflated [ViewBinding]. + * @throws IllegalStateException if the [ViewBinding] is not inflated. + */ + protected fun requireBinding() = + requireNotNull(_binding) { + "ViewBinding was available. Fragment should be a valid state " + + "right now, but instead it was ${lifecycle.currentState}" + } + + final override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = onCreateBinding(inflater).also { _binding = it }.root + + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onBindingCreated(requireBinding(), savedInstanceState) + logD("Fragment created") + } + + final override fun onDestroyView() { + super.onDestroyView() + onDestroyBinding(unlikelyToBeNull(_binding)) + // Clear binding + _binding = null + logD("Fragment destroyed") + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingMaterialDialogFragment.kt similarity index 95% rename from app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt rename to app/src/main/java/org/oxycblt/auxio/ui/ViewBindingMaterialDialogFragment.kt index 1615cbfd3..f12fe1b2e 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingMaterialDialogFragment.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2022 Auxio Project - * ViewBindingDialogFragment.kt is part of Auxio. + * ViewBindingMaterialDialogFragment.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 @@ -32,11 +32,12 @@ import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A lifecycle-aware [DialogFragment] that automatically manages the [ViewBinding] lifecycle. + * A lifecycle-aware [DialogFragment] that automatically manages the [ViewBinding] lifecycle as a + * material dialog. * * @author Alexander Capehart (OxygenCobalt) */ -abstract class ViewBindingDialogFragment : DialogFragment() { +abstract class ViewBindingMaterialDialogFragment : DialogFragment() { private var _binding: VB? = null /** diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt index a09e0f0d5..c5ce7a5cc 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt @@ -29,18 +29,18 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogAccentBinding import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.ui.UISettings -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A [ViewBindingDialogFragment] that allows the user to configure the current [Accent]. + * A [ViewBindingMaterialDialogFragment] that allows the user to configure the current [Accent]. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class AccentCustomizeDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingMaterialDialogFragment(), ClickableListListener { private var accentAdapter = AccentAdapter(this) @Inject lateinit var uiSettings: UISettings diff --git a/app/src/main/res/layout/dialog_menu.xml b/app/src/main/res/layout/dialog_menu.xml new file mode 100644 index 000000000..72534ff15 --- /dev/null +++ b/app/src/main/res/layout/dialog_menu.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_menu_option.xml b/app/src/main/res/layout/item_menu_option.xml new file mode 100644 index 000000000..6064a7025 --- /dev/null +++ b/app/src/main/res/layout/item_menu_option.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/src/main/res/navigation/main.xml b/app/src/main/res/navigation/main.xml index b1694965c..9d0736663 100644 --- a/app/src/main/res/navigation/main.xml +++ b/app/src/main/res/navigation/main.xml @@ -30,6 +30,9 @@ + @@ -297,6 +300,12 @@ app:argType="org.oxycblt.auxio.music.Music$UID" /> + + @style/Theme.Auxio.Dialog @style/Widget.Auxio.Slider @style/Widget.Auxio.LinearProgressIndicator + @style/Widget.Auxio.BottomSheet + @style/Widget.Auxio.BottomSheet.Dialog @style/TextAppearance.Auxio.DisplayLarge @style/TextAppearance.Auxio.DisplayMedium diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml index 5ee16b401..105b4aab5 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -31,6 +31,28 @@ @dimen/size_corners_medium + + + + + + + + From 0d896c04e3c57da75a718d3d331809f501166c76 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 3 Jul 2023 16:07:54 -0600 Subject: [PATCH 09/72] list: clean up menu impl Clean up the some minor mistakes in the new menu dialog implementation. --- .../auxio/list/menu/MenuDialogFragment.kt | 5 +++ .../auxio/list/menu/MenuDialogFragmentImpl.kt | 41 ++++++++++++------- .../auxio/list/menu/MenuOptionAdapter.kt | 5 ++- app/src/main/res/values/dimens.xml | 1 - app/src/main/res/values/styles_ui.xml | 6 --- 5 files changed, 34 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt index 16ede2a8f..1f0317ae8 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt @@ -58,6 +58,8 @@ abstract class MenuDialogFragment : super.onBindingCreated(binding, savedInstanceState) // --- UI SETUP --- + binding.menuName.isSelected = true + binding.menuInfo.isSelected = true binding.menuOptionRecycler.apply { adapter = menuAdapter itemAnimator = null @@ -75,6 +77,8 @@ abstract class MenuDialogFragment : override fun onDestroyBinding(binding: DialogMenuBinding) { super.onDestroyBinding(binding) + binding.menuName.isSelected = false + binding.menuInfo.isSelected = false binding.menuOptionRecycler.adapter = null } @@ -87,6 +91,7 @@ abstract class MenuDialogFragment : } final override fun onClick(item: MenuItem, viewHolder: RecyclerView.ViewHolder) { + findNavController().navigateUp() @Suppress("UNCHECKED_CAST") onClick(menuModel.currentMusic.value as T, item.itemId) } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt index 86ee874fe..9c1f8f7cc 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt @@ -26,6 +26,7 @@ import org.oxycblt.auxio.databinding.DialogMenuBinding import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames @@ -36,13 +37,15 @@ class SongMenuDialogFragment : MenuDialogFragment() { override val menuModel: MenuViewModel by viewModels() private val args: SongMenuDialogFragmentArgs by navArgs() - override val menuRes = args.menuRes - override val uid = args.songUid + override val menuRes: Int + get() = args.menuRes + override val uid: Music.UID + get() = args.songUid override fun updateMusic(binding: DialogMenuBinding, music: Song) { val context = requireContext() binding.menuCover.bind(music) - binding.menuInfo.text = getString(R.string.lbl_song) + binding.menuType.text = getString(R.string.lbl_song) binding.menuName.text = music.name.resolve(context) binding.menuInfo.text = music.artists.resolveNames(context) } @@ -55,13 +58,15 @@ class AlbumMenuDialogFragment : MenuDialogFragment() { override val menuModel: MenuViewModel by viewModels() private val args: AlbumMenuDialogFragmentArgs by navArgs() - override val menuRes = args.menuRes - override val uid = args.albumUid + override val menuRes: Int + get() = args.menuRes + override val uid: Music.UID + get() = args.albumUid override fun updateMusic(binding: DialogMenuBinding, music: Album) { val context = requireContext() binding.menuCover.bind(music) - binding.menuInfo.text = getString(music.releaseType.stringRes) + binding.menuType.text = getString(music.releaseType.stringRes) binding.menuName.text = music.name.resolve(context) binding.menuInfo.text = music.artists.resolveNames(context) } @@ -74,13 +79,15 @@ class ArtistMenuDialogFragment : MenuDialogFragment() { override val menuModel: MenuViewModel by viewModels() private val args: ArtistMenuDialogFragmentArgs by navArgs() - override val menuRes = args.menuRes - override val uid = args.artistUid + override val menuRes: Int + get() = args.menuRes + override val uid: Music.UID + get() = args.artistUid override fun updateMusic(binding: DialogMenuBinding, music: Artist) { val context = requireContext() binding.menuCover.bind(music) - binding.menuInfo.text = getString(R.string.lbl_artist) + binding.menuType.text = getString(R.string.lbl_artist) binding.menuName.text = music.name.resolve(context) binding.menuInfo.text = getString( @@ -101,13 +108,15 @@ class GenreMenuDialogFragment : MenuDialogFragment() { override val menuModel: MenuViewModel by viewModels() private val args: GenreMenuDialogFragmentArgs by navArgs() - override val menuRes = args.menuRes - override val uid = args.genreUid + override val menuRes: Int + get() = args.menuRes + override val uid: Music.UID + get() = args.genreUid override fun updateMusic(binding: DialogMenuBinding, music: Genre) { val context = requireContext() binding.menuCover.bind(music) - binding.menuInfo.text = getString(R.string.lbl_genre) + binding.menuType.text = getString(R.string.lbl_genre) binding.menuName.text = music.name.resolve(context) binding.menuInfo.text = getString( @@ -124,13 +133,15 @@ class PlaylistMenuDialogFragment : MenuDialogFragment() { override val menuModel: MenuViewModel by viewModels() private val args: PlaylistMenuDialogFragmentArgs by navArgs() - override val menuRes = args.menuRes - override val uid = args.playlistUid + override val menuRes: Int + get() = args.menuRes + override val uid: Music.UID + get() = args.playlistUid override fun updateMusic(binding: DialogMenuBinding, music: Playlist) { val context = requireContext() binding.menuCover.bind(music) - binding.menuInfo.text = getString(R.string.lbl_genre) + binding.menuType.text = getString(R.string.lbl_playlist) binding.menuName.text = music.name.resolve(context) binding.menuInfo.text = context.getPlural(R.plurals.fmt_song_count, music.songs.size) } diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt index e5d7b2a85..e8d13279a 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt @@ -39,7 +39,7 @@ class MenuOptionAdapter(private val listener: ClickableListListener) : MenuOptionViewHolder.from(parent) override fun onBindViewHolder(holder: MenuOptionViewHolder, position: Int) { - holder.bind(getItem(position)) + holder.bind(getItem(position), listener) } } @@ -50,7 +50,8 @@ class MenuOptionAdapter(private val listener: ClickableListListener) : */ class MenuOptionViewHolder private constructor(private val binding: ItemMenuOptionBinding) : DialogRecyclerView.ViewHolder(binding.root) { - fun bind(item: MenuItem) { + fun bind(item: MenuItem, listener: ClickableListListener) { + listener.bind(item, this) binding.title.text = item.title } diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 558ed6e40..064135105 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -12,7 +12,6 @@ 48dp 56dp - 92dp 128dp 192dp 256dp diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml index 18ecf8a2f..0f5675812 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -65,12 +65,6 @@ medium - - + + + + From f5c7f25cdf5b235e1899d959f28a950fddd1ce02 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Aug 2023 17:42:05 -0600 Subject: [PATCH 60/72] home: add music loading error dialog Add a dialog that shows the stack trace of a music loading error. This is an MVP that is only available to music loading to resolve some immediate issues. Resolves #527. --- .../oxycblt/auxio/home/ErrorDetailsDialog.kt | 89 +++++++++++++++++++ .../org/oxycblt/auxio/home/HomeFragment.kt | 11 ++- .../java/org/oxycblt/auxio/music/Indexing.kt | 2 +- .../oxycblt/auxio/music/MusicRepository.kt | 1 + .../oxycblt/auxio/settings/AboutFragment.kt | 84 ++--------------- .../org/oxycblt/auxio/util/FrameworkUtil.kt | 67 ++++++++++++++ app/src/main/res/drawable/ic_copy_24.xml | 11 +++ .../fragment_playback_panel.xml | 4 +- .../main/res/layout/dialog_error_details.xml | 67 ++++++++++++++ app/src/main/res/layout/fragment_home.xml | 21 ++++- app/src/main/res/navigation/inner.xml | 13 +++ app/src/main/res/values/dimens.xml | 1 + app/src/main/res/values/strings.xml | 8 ++ 13 files changed, 294 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt create mode 100644 app/src/main/res/drawable/ic_copy_24.xml create mode 100644 app/src/main/res/layout/dialog_error_details.xml diff --git a/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt new file mode 100644 index 000000000..19ac97a34 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2023 Auxio Project + * ErrorDetailsDialog.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.home + +import android.content.ClipData +import android.content.ClipboardManager +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import androidx.navigation.fragment.navArgs +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment +import org.oxycblt.auxio.util.getSystemServiceCompat +import org.oxycblt.auxio.util.openInBrowser +import org.oxycblt.auxio.util.showToast + +/** + * A dialog that shows a stack trace for a music loading error. + * + * TODO: Extend to other errors + * + * @author Alexander Capehart (OxygenCobalt) + */ +class ErrorDetailsDialog : ViewBindingMaterialDialogFragment() { + private val args: ErrorDetailsDialogArgs by navArgs() + private var clipboardManager: ClipboardManager? = null + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder + .setTitle(R.string.lbl_error_info) + .setPositiveButton(R.string.lbl_report) { _, _ -> + requireContext().openInBrowser(LINK_ISSUES) + } + .setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogErrorDetailsBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogErrorDetailsBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + clipboardManager = requireContext().getSystemServiceCompat(ClipboardManager::class) + + // --- UI SETUP --- + binding.errorStackTrace.text = args.error.stackTraceToString().trimEnd('\n') + binding.errorCopy.setOnClickListener { copyStackTrace() } + } + + override fun onDestroyBinding(binding: DialogErrorDetailsBinding) { + super.onDestroyBinding(binding) + clipboardManager = null + } + + private fun copyStackTrace() { + requireNotNull(clipboardManager) { "Clipboard was unavailable" } + .setPrimaryClip( + ClipData.newPlainText("Exception Stack Trace", args.error.stackTraceToString())) + // A copy notice is shown by the system from Android 13 onwards + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + requireContext().showToast(R.string.lbl_copied) + } + } + + private companion object { + /** The URL to the bug report issue form */ + const val LINK_ISSUES = + "https://github.com/OxygenCobalt/Auxio/issues/new" + + "?assignees=OxygenCobalt&labels=bug&projects=&template=bug-crash-report.yml" + } +} 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 7228e10ec..c016c6602 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -330,11 +330,12 @@ class HomeFragment : } } - private fun setupCompleteState(binding: FragmentHomeBinding, error: Throwable?) { + private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) { if (error == null) { logD("Received ok response") binding.homeFab.show() binding.homeIndexingContainer.visibility = View.INVISIBLE + binding.homeIndexingError.visibility = View.INVISIBLE return } @@ -357,6 +358,7 @@ class HomeFragment : .launch(PERMISSION_READ_AUDIO) } } + binding.homeIndexingError.visibility = View.INVISIBLE } is NoMusicException -> { logD("Showing no music error") @@ -367,6 +369,7 @@ class HomeFragment : text = context.getString(R.string.lbl_retry) setOnClickListener { musicModel.refresh() } } + binding.homeIndexingError.visibility = View.INVISIBLE } else -> { logD("Showing generic error") @@ -377,6 +380,12 @@ class HomeFragment : text = context.getString(R.string.lbl_retry) setOnClickListener { musicModel.rescan() } } + binding.homeIndexingError.apply { + visibility = View.VISIBLE + setOnClickListener { + findNavController().navigateSafe(HomeFragmentDirections.reportError(error)) + } + } } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt b/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt index a185d5b2f..d4e582660 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt @@ -47,7 +47,7 @@ sealed interface IndexingState { * @param error If music loading has failed, the error that occurred will be here. Otherwise, it * will be null. */ - data class Completed(val error: Throwable?) : IndexingState + data class Completed(val error: Exception?) : IndexingState } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 6e45c5ae9..d5263da7b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -371,6 +371,7 @@ constructor( // parallel. logD("Starting MediaStore query") emitIndexingProgress(IndexingProgress.Indeterminate) + val mediaStoreQueryJob = worker.scope.async { val query = diff --git a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt index cd6217a9f..3c3258ab9 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt @@ -18,13 +18,8 @@ package org.oxycblt.auxio.settings -import android.content.ActivityNotFoundException -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build import android.os.Bundle import android.view.LayoutInflater -import androidx.core.net.toUri import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController @@ -37,8 +32,7 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.showToast +import org.oxycblt.auxio.util.openInBrowser import org.oxycblt.auxio.util.systemBarInsetsCompat /** @@ -69,10 +63,10 @@ class AboutFragment : ViewBindingFragment() { } binding.aboutVersion.text = BuildConfig.VERSION_NAME - binding.aboutCode.setOnClickListener { openLinkInBrowser(LINK_SOURCE) } - binding.aboutWiki.setOnClickListener { openLinkInBrowser(LINK_WIKI) } - binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) } - binding.aboutAuthor.setOnClickListener { openLinkInBrowser(LINK_AUTHOR) } + binding.aboutCode.setOnClickListener { requireContext().openInBrowser(LINK_SOURCE) } + binding.aboutWiki.setOnClickListener { requireContext().openInBrowser(LINK_WIKI) } + binding.aboutLicenses.setOnClickListener { requireContext().openInBrowser(LINK_LICENSES) } + binding.aboutAuthor.setOnClickListener { requireContext().openInBrowser(LINK_AUTHOR) } // VIEWMODEL SETUP collectImmediately(musicModel.statistics, ::updateStatistics) @@ -93,74 +87,6 @@ class AboutFragment : ViewBindingFragment() { (statistics?.durationMs ?: 0).formatDurationMs(false)) } - /** - * Open the given URI in a web browser. - * - * @param uri The URL to open. - */ - private fun openLinkInBrowser(uri: String) { - logD("Opening $uri") - val context = requireContext() - val browserIntent = - Intent(Intent.ACTION_VIEW, uri.toUri()).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // Android 11 seems to now handle the app chooser situations on its own now - // [along with adding a new permission that breaks the old manual code], so - // we just do a typical activity launch. - logD("Using API 30+ chooser") - try { - context.startActivity(browserIntent) - } catch (e: ActivityNotFoundException) { - // No app installed to open the link - context.showToast(R.string.err_no_app) - } - } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - // On older versions of android, opening links from an ACTION_VIEW intent might - // not work in all cases, especially when no default app was set. If that is the - // case, we will try to manually handle these cases before we try to launch the - // browser. - logD("Resolving browser activity for chooser") - val pkgName = - context.packageManager - .resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY) - ?.run { activityInfo.packageName } - - if (pkgName != null) { - if (pkgName == "android") { - // No default browser [Must open app chooser, may not be supported] - logD("No default browser found") - openAppChooser(browserIntent) - } else logD("Opening browser intent") - try { - browserIntent.setPackage(pkgName) - startActivity(browserIntent) - } catch (e: ActivityNotFoundException) { - // Not a browser but an app chooser - browserIntent.setPackage(null) - openAppChooser(browserIntent) - } - } else { - // No app installed to open the link - context.showToast(R.string.err_no_app) - } - } - } - - /** - * Open an app chooser for a given [Intent]. - * - * @param intent The [Intent] to show an app chooser for. - */ - private fun openAppChooser(intent: Intent) { - logD("Opening app chooser for ${intent.action}") - val chooserIntent = - Intent(Intent.ACTION_CHOOSER) - .putExtra(Intent.EXTRA_INTENT, intent) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(chooserIntent) - } - private companion object { /** The URL to the source code. */ const val LINK_SOURCE = "https://github.com/OxygenCobalt/Auxio" diff --git a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt index e58a34c93..1662c47c5 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt @@ -18,7 +18,10 @@ package org.oxycblt.auxio.util +import android.content.ActivityNotFoundException import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import android.graphics.PointF import android.graphics.drawable.Drawable import android.os.Build @@ -33,6 +36,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ShareCompat import androidx.core.graphics.Insets import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.net.toUri import androidx.core.view.children import androidx.navigation.NavController import androidx.navigation.NavDirections @@ -41,6 +45,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import com.google.android.material.appbar.MaterialToolbar import java.lang.IllegalArgumentException +import org.oxycblt.auxio.R import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song @@ -322,3 +327,65 @@ fun Context.share(songs: Collection) { builder.setType(mimeTypes.singleOrNull() ?: "audio/*").startChooser() } + +/** + * Open the given URI in a web browser. + * + * @param uri The URL to open. + */ +fun Context.openInBrowser(uri: String) { + fun openAppChooser(intent: Intent) { + logD("Opening app chooser for ${intent.action}") + val chooserIntent = + Intent(Intent.ACTION_CHOOSER) + .putExtra(Intent.EXTRA_INTENT, intent) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(chooserIntent) + } + + logD("Opening $uri") + val browserIntent = + Intent(Intent.ACTION_VIEW, uri.toUri()).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Android 11 seems to now handle the app chooser situations on its own now + // [along with adding a new permission that breaks the old manual code], so + // we just do a typical activity launch. + logD("Using API 30+ chooser") + try { + startActivity(browserIntent) + } catch (e: ActivityNotFoundException) { + // No app installed to open the link + showToast(R.string.err_no_app) + } + } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + // On older versions of android, opening links from an ACTION_VIEW intent might + // not work in all cases, especially when no default app was set. If that is the + // case, we will try to manually handle these cases before we try to launch the + // browser. + logD("Resolving browser activity for chooser") + val pkgName = + packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)?.run { + activityInfo.packageName + } + + if (pkgName != null) { + if (pkgName == "android") { + // No default browser [Must open app chooser, may not be supported] + logD("No default browser found") + openAppChooser(browserIntent) + } else logD("Opening browser intent") + try { + browserIntent.setPackage(pkgName) + startActivity(browserIntent) + } catch (e: ActivityNotFoundException) { + // Not a browser but an app chooser + browserIntent.setPackage(null) + openAppChooser(browserIntent) + } + } else { + // No app installed to open the link + showToast(R.string.err_no_app) + } + } +} diff --git a/app/src/main/res/drawable/ic_copy_24.xml b/app/src/main/res/drawable/ic_copy_24.xml new file mode 100644 index 000000000..65bb96df5 --- /dev/null +++ b/app/src/main/res/drawable/ic_copy_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml index c673dd8ca..b46562fa2 100644 --- a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml @@ -69,8 +69,8 @@ android:id="@+id/playback_seek_bar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/spacing_small" - android:layout_marginEnd="@dimen/spacing_small" + android:layout_marginStart="@dimen/spacing_tiny" + android:layout_marginEnd="@dimen/spacing_tiny" app:layout_constraintBottom_toTopOf="@+id/playback_controls_container" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.0" diff --git a/app/src/main/res/layout/dialog_error_details.xml b/app/src/main/res/layout/dialog_error_details.xml new file mode 100644 index 000000000..729c17d0b --- /dev/null +++ b/app/src/main/res/layout/dialog_error_details.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 049256481..8eeb42c13 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -108,16 +108,33 @@ + + diff --git a/app/src/main/res/navigation/inner.xml b/app/src/main/res/navigation/inner.xml index d665a90d3..b1f834f95 100644 --- a/app/src/main/res/navigation/inner.xml +++ b/app/src/main/res/navigation/inner.xml @@ -81,8 +81,21 @@ + + + + + 48dp 56dp 64dp + 64dp 72dp 24dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd9f2c2e7..852e9eeae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,8 @@ Monitoring music library Retry + + More Grant @@ -168,6 +170,12 @@ Selection + Error information + + Copied + + Report + From e912120f9fecb29f3018141719b4c23be465d8e7 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Aug 2023 19:54:31 -0600 Subject: [PATCH 61/72] all: general cleanup --- CHANGELOG.md | 1 + .../main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt | 4 ++-- .../org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt | 6 ++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e66ee13..81d1a81e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Item and sort menus have been refreshed with a cleaner look - Added ability to sort playlists - Added option to play song by itself in library/item details +- Added error details when music loading fails #### What's Improved - Made "Add to Playlist" action more prominent in selection toolbar diff --git a/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt index 19ac97a34..e88ed1175 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt @@ -35,9 +35,9 @@ import org.oxycblt.auxio.util.showToast /** * A dialog that shows a stack trace for a music loading error. * - * TODO: Extend to other errors - * * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Extend to other errors */ class ErrorDetailsDialog : ViewBindingMaterialDialogFragment() { private val args: ErrorDetailsDialogArgs by navArgs() diff --git a/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt b/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt index 1c3e9f340..58fe95c14 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt @@ -24,7 +24,10 @@ import androidx.navigation.NavDestination /** * A [NavController.OnDestinationChangedListener] that will call [callback] when moving between - * fragments only (not between dialogs or anything similar) + * fragments only (not between dialogs or anything similar). + * + * Note: This only works because of special naming used in Auxio's navigation graphs. Keep this in + * mind when porting to other projects. * * @author Alexander Capehart (OxygenCobalt) */ @@ -74,6 +77,5 @@ class DialogAwareNavigationListener(private val callback: () -> Unit) : } } - /** This relies on special label naming used in-app. */ private fun NavDestination.isDialog() = label?.endsWith("dialog") == true } From e32fc6b60985b3c40d7d443f6b0d28daf708251b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 15 Aug 2023 20:48:17 -0600 Subject: [PATCH 62/72] home: fix misaligned grant/retry button Caused by naive visibility logic when I added the "More" button prior. Resolves #544. --- .../org/oxycblt/auxio/home/HomeFragment.kt | 17 +++--- .../oxycblt/auxio/music/MusicRepository.kt | 1 + app/src/main/res/layout/fragment_home.xml | 59 ++++++++++--------- 3 files changed, 40 insertions(+), 37 deletions(-) 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 c016c6602..01558611d 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -335,7 +335,6 @@ class HomeFragment : logD("Received ok response") binding.homeFab.show() binding.homeIndexingContainer.visibility = View.INVISIBLE - binding.homeIndexingError.visibility = View.INVISIBLE return } @@ -343,13 +342,13 @@ class HomeFragment : val context = requireContext() binding.homeIndexingContainer.visibility = View.VISIBLE binding.homeIndexingProgress.visibility = View.INVISIBLE + binding.homeIndexingActions.visibility = View.VISIBLE when (error) { is NoAudioPermissionException -> { logD("Showing permission prompt") binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms) // Configure the action to act as a permission launcher. - binding.homeIndexingAction.apply { - visibility = View.VISIBLE + binding.homeIndexingTry.apply { text = context.getString(R.string.lbl_grant) setOnClickListener { requireNotNull(storagePermissionLauncher) { @@ -358,29 +357,29 @@ class HomeFragment : .launch(PERMISSION_READ_AUDIO) } } - binding.homeIndexingError.visibility = View.INVISIBLE + binding.homeIndexingMore.visibility = View.GONE } is NoMusicException -> { logD("Showing no music error") binding.homeIndexingStatus.text = context.getString(R.string.err_no_music) // Configure the action to act as a reload trigger. - binding.homeIndexingAction.apply { + binding.homeIndexingTry.apply { visibility = View.VISIBLE text = context.getString(R.string.lbl_retry) setOnClickListener { musicModel.refresh() } } - binding.homeIndexingError.visibility = View.INVISIBLE + binding.homeIndexingMore.visibility = View.GONE } else -> { logD("Showing generic error") binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed) // Configure the action to act as a reload trigger. - binding.homeIndexingAction.apply { + binding.homeIndexingTry.apply { visibility = View.VISIBLE text = context.getString(R.string.lbl_retry) setOnClickListener { musicModel.rescan() } } - binding.homeIndexingError.apply { + binding.homeIndexingMore.apply { visibility = View.VISIBLE setOnClickListener { findNavController().navigateSafe(HomeFragmentDirections.reportError(error)) @@ -394,7 +393,7 @@ class HomeFragment : // Remove all content except for the progress indicator. binding.homeIndexingContainer.visibility = View.VISIBLE binding.homeIndexingProgress.visibility = View.VISIBLE - binding.homeIndexingAction.visibility = View.INVISIBLE + binding.homeIndexingActions.visibility = View.INVISIBLE when (progress) { is IndexingProgress.Indeterminate -> { diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index d5263da7b..e3f2a0abb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.yield diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 8eeb42c13..f1b5c8c80 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -70,8 +70,8 @@ android:layout_height="wrap_content" android:layout_gravity="center" android:layout_margin="@dimen/spacing_medium" - android:fitsSystemWindows="true" - android:visibility="invisible"> + android:visibility="invisible" + android:fitsSystemWindows="true"> @@ -103,37 +103,40 @@ android:layout_marginEnd="@dimen/spacing_medium" android:indeterminate="true" app:indeterminateAnimationType="disjoint" - app:layout_constraintBottom_toBottomOf="@+id/home_indexing_action" - app:layout_constraintTop_toTopOf="@+id/home_indexing_action" /> + app:layout_constraintBottom_toBottomOf="@+id/home_indexing_actions" + app:layout_constraintTop_toTopOf="@+id/home_indexing_actions" /> - - - + tools:layout_editor_absoluteX="16dp"> + + + + + + From b43e4695c080be09e71c9a70c36a21b057e9df5c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 15:09:45 -0600 Subject: [PATCH 63/72] build: update deps fragment: 1.6.0 -> 1.6.1 preferences: 1.2.0 -> 1.2.1 room: 2.6.0-alpha02 -> 2.6.0-alpha03 material: 1.10.0-alpha05 -> 1.10.0-alpha06 media: 1.1.0 -> 1.1.1 --- app/build.gradle | 12 +++++------- build.gradle | 2 +- media | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f088245c7..efac4f42e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,7 +21,7 @@ android { defaultConfig { applicationId namespace - versionName "3.1.4" + versionName "3.1.0" versionCode 34 minSdk 24 @@ -88,7 +88,7 @@ dependencies { implementation "androidx.core:core-ktx:1.10.1" implementation "androidx.appcompat:appcompat:1.6.1" implementation "androidx.activity:activity-ktx:1.7.2" - implementation "androidx.fragment:fragment-ktx:1.6.0" + implementation "androidx.fragment:fragment-ktx:1.6.1" // Components // Deliberately kept on 1.2.1 to prevent a bug where the queue sheet will not collapse on @@ -114,13 +114,11 @@ dependencies { implementation "androidx.media:media:1.6.0" // Preferences - implementation "androidx.preference:preference-ktx:1.2.0" + implementation "androidx.preference:preference-ktx:1.2.1" // Database - def room_version = '2.6.0-alpha02' + def room_version = '2.6.0-alpha03' implementation "androidx.room:room-runtime:$room_version" - // I have no clue why, but using KSP breaks the playlist database definition. - //noinspection KaptUsageInsteadOfKsp ksp "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" @@ -136,7 +134,7 @@ dependencies { // Material // TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just // PR a fix. - implementation "com.google.android.material:material:1.10.0-alpha05" + implementation "com.google.android.material:material:1.10.0-alpha06" // Dependency Injection implementation "com.google.dagger:dagger:$hilt_version" diff --git a/build.gradle b/build.gradle index 75fa2ca64..e5bc1dc66 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { kotlin_version = '1.9.0' - navigation_version = "2.6.0" + navigation_version = "2.7.0" hilt_version = '2.47' } diff --git a/media b/media index 316763308..40c3e5c68 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 316763308d3143c75270103c85cf2d984bfa34a0 +Subproject commit 40c3e5c68cbdf8758037aa40b4071cca8a53ee89 From 70a5bab9214c9592979504fe006d225982872ce2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 17:12:56 -0600 Subject: [PATCH 64/72] ui: vendor bottom sheet dialog w/fixes Vendor BottomSheetDialog(Fragment) with the inset fix that prior used reflection. Apparently said reflection breaks down and crashes the release build somehow. So now I just have to hastily patch BackportBottomSheetBehavior and vendor another 1000 lines of MDC code. Really considering making a PHP sadness-like blog solely for android at this point. --- .../BackportBottomSheetBehavior.java | 32 +- .../BackportBottomSheetDialog.java | 551 ++++++++++++++++++ .../BackportBottomSheetDialogFragment.java | 120 ++++ .../ViewBindingBottomSheetDialogFragment.kt | 66 +-- .../res/layout/design_bottom_sheet_dialog.xml | 51 ++ app/src/main/res/values/styles_ui.xml | 8 + 6 files changed, 753 insertions(+), 75 deletions(-) create mode 100644 app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java create mode 100644 app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialogFragment.java create mode 100644 app/src/main/res/layout/design_bottom_sheet_dialog.xml diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java index 214f6ac62..f9e8edb42 100644 --- a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java @@ -1737,16 +1737,10 @@ private void setWindowInsetsListener(@NonNull View child) { final boolean shouldHandleGestureInsets = VERSION.SDK_INT >= VERSION_CODES.Q && !isGestureInsetBottomIgnored() && !peekHeightAuto; - // If were not handling insets at all, don't apply the listener. - if (!paddingBottomSystemWindowInsets - && !paddingLeftSystemWindowInsets - && !paddingRightSystemWindowInsets - && !marginLeftSystemWindowInsets - && !marginRightSystemWindowInsets - && !marginTopSystemWindowInsets - && !shouldHandleGestureInsets) { - return; - } + // MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves + // don't need peek height adjustments (Despite the fact that they still likely padding + // the view, just without clipping anything) + ViewUtils.doOnApplyWindowInsets( child, new ViewUtils.OnApplyWindowInsetsListener() { @@ -1759,6 +1753,12 @@ public WindowInsetsCompat onApplyWindowInsets( insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()); insetTop = systemBarInsets.top; + // MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves + // don't need peek height adjustments (Despite the fact that they still likely padding + // the view, just without clipping anything) + // Intentionally uses getSystemWindowInsetBottom to apply padding properly when + // adjustResize is used as the windowSoftInputMode. + insetBottom = insets.getSystemWindowInsetBottom(); boolean isRtl = ViewUtils.isLayoutRtl(view); @@ -1767,9 +1767,6 @@ public WindowInsetsCompat onApplyWindowInsets( int rightPadding = view.getPaddingRight(); if (paddingBottomSystemWindowInsets) { - // Intentionally uses getSystemWindowInsetBottom to apply padding properly when - // adjustResize is used as the windowSoftInputMode. - insetBottom = insets.getSystemWindowInsetBottom(); bottomPadding = initialPadding.bottom + insetBottom; } @@ -1810,11 +1807,10 @@ public WindowInsetsCompat onApplyWindowInsets( gestureInsetBottom = mandatoryGestureInsets.bottom; } - // Don't update the peek height to be above the navigation bar or gestures if these - // flags are off. It means the client is already handling it. - if (paddingBottomSystemWindowInsets || shouldHandleGestureInsets) { - updatePeekHeight(/* animate= */ false); - } + // MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves + // don't need peek height adjustments (Despite the fact that they still likely padding + // the view, just without clipping anything) + updatePeekHeight(/* animate= */ false); return insets; } }); diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java new file mode 100644 index 000000000..af5cc64bf --- /dev/null +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java @@ -0,0 +1,551 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.material.bottomsheet; + +import com.google.android.material.R; + +import static com.google.android.material.color.MaterialColors.isColorLight; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.os.Build; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import androidx.appcompat.app.AppCompatDialog; +import android.util.TypedValue; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager.LayoutParams; +import android.widget.FrameLayout; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleRes; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.view.AccessibilityDelegateCompat; +import androidx.core.view.OnApplyWindowInsetsListener; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.view.WindowInsetsControllerCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.material.internal.EdgeToEdgeUtils; +import com.google.android.material.motion.MaterialBackOrchestrator; +import com.google.android.material.shape.MaterialShapeDrawable; + +import org.checkerframework.common.subtyping.qual.Bottom; + +/** + * Base class for {@link android.app.Dialog}s styled as a bottom sheet. + * + *

Edge to edge window flags are automatically applied if the {@link + * android.R.attr#navigationBarColor} is transparent or translucent and {@code enableEdgeToEdge} is + * true. These can be set in the theme that is passed to the constructor, or will be taken from the + * theme of the context (ie. your application or activity theme). + * + *

In edge to edge mode, padding will be added automatically to the top when sliding under the + * status bar. Padding can be applied automatically to the left, right, or bottom if any of + * `paddingBottomSystemWindowInsets`, `paddingLeftSystemWindowInsets`, or + * `paddingRightSystemWindowInsets` are set to true in the style. + * + * MODIFICATION: Replace all usages of BottomSheetBehavior with BackportBottomSheetBehavior + */ +public class BackportBottomSheetDialog extends AppCompatDialog { + + private BackportBottomSheetBehavior behavior; + + private FrameLayout container; + private CoordinatorLayout coordinator; + private FrameLayout bottomSheet; + + boolean dismissWithAnimation; + + boolean cancelable = true; + private boolean canceledOnTouchOutside = true; + private boolean canceledOnTouchOutsideSet; + private EdgeToEdgeCallback edgeToEdgeCallback; + private boolean edgeToEdgeEnabled; + @Nullable private MaterialBackOrchestrator backOrchestrator; + + public BackportBottomSheetDialog(@NonNull Context context) { + this(context, 0); + + edgeToEdgeEnabled = + getContext() + .getTheme() + .obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge}) + .getBoolean(0, false); + } + + public BackportBottomSheetDialog(@NonNull Context context, @StyleRes int theme) { + super(context, getThemeResId(context, theme)); + // We hide the title bar for any style configuration. Otherwise, there will be a gap + // above the bottom sheet when it is expanded. + supportRequestWindowFeature(Window.FEATURE_NO_TITLE); + + edgeToEdgeEnabled = + getContext() + .getTheme() + .obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge}) + .getBoolean(0, false); + } + + protected BackportBottomSheetDialog( + @NonNull Context context, boolean cancelable, OnCancelListener cancelListener) { + super(context, cancelable, cancelListener); + supportRequestWindowFeature(Window.FEATURE_NO_TITLE); + this.cancelable = cancelable; + + edgeToEdgeEnabled = + getContext() + .getTheme() + .obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge}) + .getBoolean(0, false); + } + + @Override + public void setContentView(@LayoutRes int layoutResId) { + super.setContentView(wrapInBottomSheet(layoutResId, null, null)); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Window window = getWindow(); + if (window != null) { + if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + // The status bar should always be transparent because of the window animation. + window.setStatusBarColor(0); + + window.addFlags(LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + if (VERSION.SDK_INT < VERSION_CODES.M) { + // It can be transparent for API 23 and above because we will handle switching the status + // bar icons to light or dark as appropriate. For API 21 and API 22 we just set the + // translucent status bar. + window.addFlags(LayoutParams.FLAG_TRANSLUCENT_STATUS); + } + } + window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + } + } + + @Override + public void setContentView(View view) { + super.setContentView(wrapInBottomSheet(0, view, null)); + } + + @Override + public void setContentView(View view, ViewGroup.LayoutParams params) { + super.setContentView(wrapInBottomSheet(0, view, params)); + } + + @Override + public void setCancelable(boolean cancelable) { + super.setCancelable(cancelable); + if (this.cancelable != cancelable) { + this.cancelable = cancelable; + if (behavior != null) { + behavior.setHideable(cancelable); + } + if (getWindow() != null) { + updateListeningForBackCallbacks(); + } + } + } + + @Override + protected void onStart() { + super.onStart(); + if (behavior != null && behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) { + behavior.setState(BackportBottomSheetBehavior.STATE_COLLAPSED); + } + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + Window window = getWindow(); + if (window != null) { + if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + // If the navigation bar is transparent at all the BottomSheet should be edge to edge. + boolean drawEdgeToEdge = + edgeToEdgeEnabled && Color.alpha(window.getNavigationBarColor()) < 255; + if (container != null) { + container.setFitsSystemWindows(!drawEdgeToEdge); + } + if (coordinator != null) { + coordinator.setFitsSystemWindows(!drawEdgeToEdge); + } + WindowCompat.setDecorFitsSystemWindows(window, !drawEdgeToEdge); + } + if (edgeToEdgeCallback != null) { + edgeToEdgeCallback.setWindow(window); + } + } + + updateListeningForBackCallbacks(); + } + + @Override + public void onDetachedFromWindow() { + if (edgeToEdgeCallback != null) { + edgeToEdgeCallback.setWindow(null); + } + + if (backOrchestrator != null) { + backOrchestrator.stopListeningForBackCallbacks(); + } + } + + /** + * This function can be called from a few different use cases, including Swiping the dialog down + * or calling `dismiss()` from a `BackportBottomSheetDialogFragment`, tapping outside a dialog, etc... + * + *

The default animation to dismiss this dialog is a fade-out transition through a + * windowAnimation. Call {@link #setDismissWithAnimation(true)} if you want to utilize the + * BottomSheet animation instead. + * + *

If this function is called from a swipe down interaction, or dismissWithAnimation is false, + * then keep the default behavior. + * + *

Else, since this is a terminal event which will finish this dialog, we override the attached + * {@link BackportBottomSheetBehavior.BottomSheetCallback} to call this function, after {@link + * BackportBottomSheetBehavior#STATE_HIDDEN} is set. This will enforce the swipe down animation before + * canceling this dialog. + */ + @Override + public void cancel() { + BackportBottomSheetBehavior behavior = getBehavior(); + + if (!dismissWithAnimation || behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) { + super.cancel(); + } else { + behavior.setState(BackportBottomSheetBehavior.STATE_HIDDEN); + } + } + + @Override + public void setCanceledOnTouchOutside(boolean cancel) { + super.setCanceledOnTouchOutside(cancel); + if (cancel && !cancelable) { + cancelable = true; + } + canceledOnTouchOutside = cancel; + canceledOnTouchOutsideSet = true; + } + + @NonNull + public BackportBottomSheetBehavior getBehavior() { + if (behavior == null) { + // The content hasn't been set, so the behavior doesn't exist yet. Let's create it. + ensureContainerAndBehavior(); + } + return behavior; + } + + /** + * Set to perform the swipe down animation when dismissing instead of the window animation for the + * dialog. + * + * @param dismissWithAnimation True if swipe down animation should be used when dismissing. + */ + public void setDismissWithAnimation(boolean dismissWithAnimation) { + this.dismissWithAnimation = dismissWithAnimation; + } + + /** + * Returns if dismissing will perform the swipe down animation on the bottom sheet, rather than + * the window animation for the dialog. + */ + public boolean getDismissWithAnimation() { + return dismissWithAnimation; + } + + /** Returns if edge to edge behavior is enabled for this dialog. */ + public boolean getEdgeToEdgeEnabled() { + return edgeToEdgeEnabled; + } + + /** Creates the container layout which must exist to find the behavior */ + private FrameLayout ensureContainerAndBehavior() { + if (container == null) { + container = + (FrameLayout) View.inflate(getContext(), R.layout.design_bottom_sheet_dialog, null); + + coordinator = (CoordinatorLayout) container.findViewById(R.id.coordinator); + bottomSheet = (FrameLayout) container.findViewById(R.id.design_bottom_sheet); + + // MODIFICATION: Override layout-specified BottomSheetBehavior w/BackportBottomSheetBehavior + behavior = BackportBottomSheetBehavior.from(bottomSheet); + behavior.addBottomSheetCallback(bottomSheetCallback); + behavior.setHideable(cancelable); + + backOrchestrator = new MaterialBackOrchestrator(behavior, bottomSheet); + } + return container; + } + + private View wrapInBottomSheet( + int layoutResId, @Nullable View view, @Nullable ViewGroup.LayoutParams params) { + ensureContainerAndBehavior(); + CoordinatorLayout coordinator = (CoordinatorLayout) container.findViewById(R.id.coordinator); + if (layoutResId != 0 && view == null) { + view = getLayoutInflater().inflate(layoutResId, coordinator, false); + } + + if (edgeToEdgeEnabled) { + ViewCompat.setOnApplyWindowInsetsListener( + bottomSheet, + new OnApplyWindowInsetsListener() { + @Override + public WindowInsetsCompat onApplyWindowInsets(View view, WindowInsetsCompat insets) { + if (edgeToEdgeCallback != null) { + behavior.removeBottomSheetCallback(edgeToEdgeCallback); + } + + if (insets != null) { + edgeToEdgeCallback = new EdgeToEdgeCallback(bottomSheet, insets); + edgeToEdgeCallback.setWindow(getWindow()); + behavior.addBottomSheetCallback(edgeToEdgeCallback); + } + + return insets; + } + }); + } + + bottomSheet.removeAllViews(); + if (params == null) { + bottomSheet.addView(view); + } else { + bottomSheet.addView(view, params); + } + // We treat the CoordinatorLayout as outside the dialog though it is technically inside + coordinator + .findViewById(R.id.touch_outside) + .setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View view) { + if (cancelable && isShowing() && shouldWindowCloseOnTouchOutside()) { + cancel(); + } + } + }); + // Handle accessibility events + ViewCompat.setAccessibilityDelegate( + bottomSheet, + new AccessibilityDelegateCompat() { + @Override + public void onInitializeAccessibilityNodeInfo( + View host, @NonNull AccessibilityNodeInfoCompat info) { + super.onInitializeAccessibilityNodeInfo(host, info); + if (cancelable) { + info.addAction(AccessibilityNodeInfoCompat.ACTION_DISMISS); + info.setDismissable(true); + } else { + info.setDismissable(false); + } + } + + @Override + public boolean performAccessibilityAction(View host, int action, Bundle args) { + if (action == AccessibilityNodeInfoCompat.ACTION_DISMISS && cancelable) { + cancel(); + return true; + } + return super.performAccessibilityAction(host, action, args); + } + }); + bottomSheet.setOnTouchListener( + new View.OnTouchListener() { + @Override + public boolean onTouch(View view, MotionEvent event) { + // Consume the event and prevent it from falling through + return true; + } + }); + return container; + } + + private void updateListeningForBackCallbacks() { + if (backOrchestrator == null) { + return; + } + if (cancelable) { + backOrchestrator.startListeningForBackCallbacks(); + } else { + backOrchestrator.stopListeningForBackCallbacks(); + } + } + + boolean shouldWindowCloseOnTouchOutside() { + if (!canceledOnTouchOutsideSet) { + TypedArray a = + getContext().obtainStyledAttributes(new int[] {android.R.attr.windowCloseOnTouchOutside}); + canceledOnTouchOutside = a.getBoolean(0, true); + a.recycle(); + canceledOnTouchOutsideSet = true; + } + return canceledOnTouchOutside; + } + + private static int getThemeResId(@NonNull Context context, int themeId) { + if (themeId == 0) { + // If the provided theme is 0, then retrieve the dialogTheme from our theme + TypedValue outValue = new TypedValue(); + if (context.getTheme().resolveAttribute(R.attr.bottomSheetDialogTheme, outValue, true)) { + themeId = outValue.resourceId; + } else { + // bottomSheetDialogTheme is not provided; we default to our light theme + themeId = R.style.Theme_Design_Light_BottomSheetDialog; + } + } + return themeId; + } + + void removeDefaultCallback() { + behavior.removeBottomSheetCallback(bottomSheetCallback); + } + + @NonNull + private BackportBottomSheetBehavior.BottomSheetCallback bottomSheetCallback = + new BackportBottomSheetBehavior.BottomSheetCallback() { + @Override + public void onStateChanged( + @NonNull View bottomSheet, @BackportBottomSheetBehavior.State int newState) { + if (newState == BackportBottomSheetBehavior.STATE_HIDDEN) { + cancel(); + } + } + + @Override + public void onSlide(@NonNull View bottomSheet, float slideOffset) {} + }; + + private static class EdgeToEdgeCallback extends BackportBottomSheetBehavior.BottomSheetCallback { + + @Nullable private final Boolean lightBottomSheet; + @NonNull private final WindowInsetsCompat insetsCompat; + + @Nullable private Window window; + private boolean lightStatusBar; + + private EdgeToEdgeCallback( + @NonNull final View bottomSheet, @NonNull WindowInsetsCompat insetsCompat) { + this.insetsCompat = insetsCompat; + + // Try to find the background color to automatically change the status bar icons so they will + // still be visible when the bottomsheet slides underneath the status bar. + ColorStateList backgroundTint; + MaterialShapeDrawable msd = BackportBottomSheetBehavior.from(bottomSheet).getMaterialShapeDrawable(); + if (msd != null) { + backgroundTint = msd.getFillColor(); + } else { + backgroundTint = ViewCompat.getBackgroundTintList(bottomSheet); + } + + if (backgroundTint != null) { + // First check for a tint + lightBottomSheet = isColorLight(backgroundTint.getDefaultColor()); + } else if (bottomSheet.getBackground() instanceof ColorDrawable) { + // Then check for the background color + lightBottomSheet = isColorLight(((ColorDrawable) bottomSheet.getBackground()).getColor()); + } else { + // Otherwise don't change the status bar color + lightBottomSheet = null; + } + } + + @Override + public void onStateChanged(@NonNull View bottomSheet, int newState) { + setPaddingForPosition(bottomSheet); + } + + @Override + public void onSlide(@NonNull View bottomSheet, float slideOffset) { + setPaddingForPosition(bottomSheet); + } + + @Override + void onLayout(@NonNull View bottomSheet) { + setPaddingForPosition(bottomSheet); + } + + void setWindow(@Nullable Window window) { + if (this.window == window) { + return; + } + this.window = window; + if (window != null) { + WindowInsetsControllerCompat insetsController = + WindowCompat.getInsetsController(window, window.getDecorView()); + lightStatusBar = insetsController.isAppearanceLightStatusBars(); + } + } + + private void setPaddingForPosition(View bottomSheet) { + if (bottomSheet.getTop() < insetsCompat.getSystemWindowInsetTop()) { + // If the bottomsheet is light, we should set light status bar so the icons are visible + // since the bottomsheet is now under the status bar. + if (window != null) { + EdgeToEdgeUtils.setLightStatusBar( + window, lightBottomSheet == null ? lightStatusBar : lightBottomSheet); + } + // Smooth transition into status bar when drawing edge to edge. + bottomSheet.setPadding( + bottomSheet.getPaddingLeft(), + (insetsCompat.getSystemWindowInsetTop() - bottomSheet.getTop()), + bottomSheet.getPaddingRight(), + bottomSheet.getPaddingBottom()); + } else if (bottomSheet.getTop() != 0) { + // Reset the status bar icons to the original color because the bottomsheet is not under the + // status bar. + if (window != null) { + EdgeToEdgeUtils.setLightStatusBar(window, lightStatusBar); + } + bottomSheet.setPadding( + bottomSheet.getPaddingLeft(), + 0, + bottomSheet.getPaddingRight(), + bottomSheet.getPaddingBottom()); + } + } + } + + /** + * @deprecated use {@link EdgeToEdgeUtils#setLightStatusBar(Window, boolean)} instead + */ + @Deprecated + public static void setLightStatusBar(@NonNull View view, boolean isLight) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + int flags = view.getSystemUiVisibility(); + if (isLight) { + flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } else { + flags &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } + view.setSystemUiVisibility(flags); + } + } +} diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialogFragment.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialogFragment.java new file mode 100644 index 000000000..eead66daa --- /dev/null +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialogFragment.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.material.bottomsheet; + +import android.annotation.SuppressLint; +import android.app.Dialog; +import android.os.Bundle; +import androidx.appcompat.app.AppCompatDialogFragment; +import android.view.View; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Modal bottom sheet. This is a version of {@link androidx.fragment.app.DialogFragment} that shows + * a bottom sheet using {@link BackportBottomSheetDialog} instead of a floating dialog. + */ +public class BackportBottomSheetDialogFragment extends AppCompatDialogFragment { + + /** + * Tracks if we are waiting for a dismissAllowingStateLoss or a regular dismiss once the + * BottomSheet is hidden and onStateChanged() is called. + */ + private boolean waitingForDismissAllowingStateLoss; + + public BackportBottomSheetDialogFragment() {} + + @SuppressLint("ValidFragment") + public BackportBottomSheetDialogFragment(@LayoutRes int contentLayoutId) { + super(contentLayoutId); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + return new BackportBottomSheetDialog(getContext(), getTheme()); + } + + @Override + public void dismiss() { + if (!tryDismissWithAnimation(false)) { + super.dismiss(); + } + } + + @Override + public void dismissAllowingStateLoss() { + if (!tryDismissWithAnimation(true)) { + super.dismissAllowingStateLoss(); + } + } + + /** + * Tries to dismiss the dialog fragment with the bottom sheet animation. Returns true if possible, + * false otherwise. + */ + private boolean tryDismissWithAnimation(boolean allowingStateLoss) { + Dialog baseDialog = getDialog(); + if (baseDialog instanceof BackportBottomSheetDialog) { + BackportBottomSheetDialog dialog = (BackportBottomSheetDialog) baseDialog; + BackportBottomSheetBehavior behavior = dialog.getBehavior(); + if (behavior.isHideable() && dialog.getDismissWithAnimation()) { + dismissWithAnimation(behavior, allowingStateLoss); + return true; + } + } + + return false; + } + + private void dismissWithAnimation( + @NonNull BackportBottomSheetBehavior behavior, boolean allowingStateLoss) { + waitingForDismissAllowingStateLoss = allowingStateLoss; + + if (behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) { + dismissAfterAnimation(); + } else { + if (getDialog() instanceof BackportBottomSheetDialog) { + ((BackportBottomSheetDialog) getDialog()).removeDefaultCallback(); + } + behavior.addBottomSheetCallback(new BottomSheetDismissCallback()); + behavior.setState(BackportBottomSheetBehavior.STATE_HIDDEN); + } + } + + private void dismissAfterAnimation() { + if (waitingForDismissAllowingStateLoss) { + super.dismissAllowingStateLoss(); + } else { + super.dismiss(); + } + } + + private class BottomSheetDismissCallback extends BackportBottomSheetBehavior.BottomSheetCallback { + + @Override + public void onStateChanged(@NonNull View bottomSheet, int newState) { + if (newState == BackportBottomSheetBehavior.STATE_HIDDEN) { + dismissAfterAnimation(); + } + } + + @Override + public void onSlide(@NonNull View bottomSheet, float slideOffset) {} + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt index e3087d42f..8abffb38f 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt @@ -20,25 +20,18 @@ package org.oxycblt.auxio.ui import android.content.Context import android.os.Bundle -import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout import androidx.annotation.StyleRes import androidx.fragment.app.DialogFragment import androidx.viewbinding.ViewBinding -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BackportBottomSheetBehavior +import com.google.android.material.bottomsheet.BackportBottomSheetDialog +import com.google.android.material.bottomsheet.BackportBottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import java.lang.reflect.Field -import java.lang.reflect.Method -import org.oxycblt.auxio.util.coordinatorLayoutBehavior import org.oxycblt.auxio.util.getDimenPixels -import org.oxycblt.auxio.util.lazyReflectedField -import org.oxycblt.auxio.util.lazyReflectedMethod import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -48,10 +41,10 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ abstract class ViewBindingBottomSheetDialogFragment : - BottomSheetDialogFragment() { + BackportBottomSheetDialogFragment() { private var _binding: VB? = null - override fun onCreateDialog(savedInstanceState: Bundle?): BottomSheetDialog = + override fun onCreateDialog(savedInstanceState: Bundle?): BackportBottomSheetDialog = TweakedBottomSheetDialog(requireContext(), theme) /** @@ -100,10 +93,7 @@ abstract class ViewBindingBottomSheetDialogFragment : inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - val root = onCreateBinding(inflater).also { _binding = it }.root - return EdgeToEdgeFixWrapperLayout(root.context).apply { addView(root) } - } + ) = onCreateBinding(inflater).also { _binding = it }.root final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -121,7 +111,8 @@ abstract class ViewBindingBottomSheetDialogFragment : private inner class TweakedBottomSheetDialog @JvmOverloads - constructor(context: Context, @StyleRes theme: Int = 0) : BottomSheetDialog(context, theme) { + constructor(context: Context, @StyleRes theme: Int = 0) : + BackportBottomSheetDialog(context, theme) { private var avoidUnusableCollapsedState = false override fun onCreate(savedInstanceState: Bundle?) { @@ -141,47 +132,8 @@ abstract class ViewBindingBottomSheetDialogFragment : super.onStart() if (avoidUnusableCollapsedState) { // skipCollapsed isn't enough, also need to immediately snap to expanded state. - behavior.state = BottomSheetBehavior.STATE_EXPANDED - } - } - } - - private class EdgeToEdgeFixWrapperLayout - @JvmOverloads - constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : - FrameLayout(context, attrs, defStyleAttr) { - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - super.onLayout(changed, left, top, right, bottom) - // BottomSheetBehavior's normal window inset behavior is awful. It doesn't - // follow true edge-to-edge and instead just blindly pads the bottom part of - // the view, causing visual clipping. We can turn it off, but that throws - // of the peek height calculation and results in a collapsed state that only - // expands a few pixels (specifically the size of the bottom inset) into an - // expanded state. So, ideally we would just vendor and eliminate the padding - // changes entirely, but due to layout dependencies that requires vendoring - // both BottomSheetDialog and BottomSheetDialogFragment, which I generally - // don't want to do. Instead, we deliberately clobber the window insets listener - // of our bottom sheet and only re-implement the update of the cached inset - // variables and the peek height update. This way, the peek height calculation - // remains consistent and the top inset animation continues to work correctly - // without the other absurd edge-to-edge behaviors. - // TODO: Do a fix for this upstream - (parent as View).setOnApplyWindowInsetsListener { v, insets -> - val bsb = v.coordinatorLayoutBehavior as BottomSheetBehavior - BSB_INSET_TOP_FIELD.set(bsb, insets.systemBarInsetsCompat.top) - BSB_INSET_BOTTOM_FIELD.set(bsb, insets.systemBarInsetsCompat.bottom) - BSB_UPDATE_PEEK_HEIGHT_METHOD.invoke(bsb, false) - insets + behavior.state = BackportBottomSheetBehavior.STATE_EXPANDED } } - - private companion object { - val BSB_INSET_TOP_FIELD: Field by - lazyReflectedField(BottomSheetBehavior::class, "insetTop") - val BSB_INSET_BOTTOM_FIELD: Field by - lazyReflectedField(BottomSheetBehavior::class, "insetBottom") - val BSB_UPDATE_PEEK_HEIGHT_METHOD: Method by - lazyReflectedMethod(BottomSheetBehavior::class, "updatePeekHeight", Boolean::class) - } } } diff --git a/app/src/main/res/layout/design_bottom_sheet_dialog.xml b/app/src/main/res/layout/design_bottom_sheet_dialog.xml new file mode 100644 index 000000000..bb70eccbb --- /dev/null +++ b/app/src/main/res/layout/design_bottom_sheet_dialog.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml index 18e87da49..5106e12b9 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -32,11 +32,19 @@ From 449ec7cecd813b5b4de5835c8c8c14b2f6fd0987 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 17:32:26 -0600 Subject: [PATCH 65/72] ui: fix gap in landscape bottom sheet dialog Apparently a second-order effect of the prior fix since the insetTop value would now shift the dialog downwards unneccessarily. --- .../material/bottomsheet/BackportBottomSheetBehavior.java | 5 ++++- .../material/bottomsheet/BackportBottomSheetDialog.java | 2 -- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java index f9e8edb42..577036ec4 100644 --- a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java @@ -1752,7 +1752,10 @@ public WindowInsetsCompat onApplyWindowInsets( Insets mandatoryGestureInsets = insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()); - insetTop = systemBarInsets.top; + // MODIFICATION: Fix second order change of edge-to-edge fix where dialogs will not + // use the nice-looking inset animation and instead blindly shift themselves downwards. + // insetTop = systemBarInsets.top; + // MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves // don't need peek height adjustments (Despite the fact that they still likely padding // the view, just without clipping anything) diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java index af5cc64bf..060fe04d2 100644 --- a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java @@ -53,8 +53,6 @@ import com.google.android.material.motion.MaterialBackOrchestrator; import com.google.android.material.shape.MaterialShapeDrawable; -import org.checkerframework.common.subtyping.qual.Bottom; - /** * Base class for {@link android.app.Dialog}s styled as a bottom sheet. * From f400aa513c42999c240c015bcdd7bce4b84536a1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 19:20:21 -0600 Subject: [PATCH 66/72] ui: mitigate navigation desync bug This thing reared it's ugly head again during 3.2.0 testing. I think I've found a terrible but probably functional workaround for it. Start using it. --- app/src/main/java/org/oxycblt/auxio/MainFragment.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index c86037eb1..6747acb65 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -148,6 +148,14 @@ class MainFragment : } } + // Workaround for a bug where fast navigation ends up desynchronizing the current + // destination in the main navigation graph. + findNavController().apply { + findDestination(R.id.main_fragment)?.let { + currentBackStackEntry?.destination = it + } + } + // --- VIEWMODEL SETUP --- collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) collectImmediately(homeModel.showOuter.flow, ::handleShowOuter) From 80268498560aded7cc8a3d053d00e32ee2f58029 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 19:41:19 -0600 Subject: [PATCH 67/72] all: cleanup --- app/src/main/java/org/oxycblt/auxio/MainFragment.kt | 4 +--- app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt | 1 - app/src/main/res/values-ar-rIQ/strings.xml | 2 -- app/src/main/res/values-ar/strings.xml | 2 -- app/src/main/res/values-be/strings.xml | 2 -- app/src/main/res/values-cs/strings.xml | 2 -- app/src/main/res/values-de/strings.xml | 2 -- app/src/main/res/values-el/strings.xml | 2 -- app/src/main/res/values-es/strings.xml | 2 -- app/src/main/res/values-fi/strings.xml | 2 -- app/src/main/res/values-fr/strings.xml | 2 -- app/src/main/res/values-gl/strings.xml | 2 -- app/src/main/res/values-hi/strings.xml | 2 -- app/src/main/res/values-hr/strings.xml | 2 -- app/src/main/res/values-hu/strings.xml | 2 -- app/src/main/res/values-in/strings.xml | 2 -- app/src/main/res/values-it/strings.xml | 2 -- app/src/main/res/values-iw/strings.xml | 2 -- app/src/main/res/values-ja/strings.xml | 2 -- app/src/main/res/values-ko/strings.xml | 2 -- app/src/main/res/values-lt/strings.xml | 2 -- app/src/main/res/values-ml/strings.xml | 1 - app/src/main/res/values-nb-rNO/strings.xml | 2 -- app/src/main/res/values-nl/strings.xml | 2 -- app/src/main/res/values-pa/strings.xml | 2 -- app/src/main/res/values-pl/strings.xml | 2 -- app/src/main/res/values-pt-rBR/strings.xml | 2 -- app/src/main/res/values-pt-rPT/strings.xml | 2 -- app/src/main/res/values-ro/strings.xml | 2 -- app/src/main/res/values-ru/strings.xml | 2 -- app/src/main/res/values-sv/strings.xml | 2 -- app/src/main/res/values-tr/strings.xml | 2 -- app/src/main/res/values-uk/strings.xml | 2 -- app/src/main/res/values-zh-rCN/strings.xml | 2 -- app/src/main/res/values/strings.xml | 2 -- 35 files changed, 1 insertion(+), 69 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 6747acb65..2163721de 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -151,9 +151,7 @@ class MainFragment : // Workaround for a bug where fast navigation ends up desynchronizing the current // destination in the main navigation graph. findNavController().apply { - findDestination(R.id.main_fragment)?.let { - currentBackStackEntry?.destination = it - } + findDestination(R.id.main_fragment)?.let { currentBackStackEntry?.destination = it } } // --- VIEWMODEL SETUP --- diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index e3f2a0abb..d5263da7b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -29,7 +29,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.yield diff --git a/app/src/main/res/values-ar-rIQ/strings.xml b/app/src/main/res/values-ar-rIQ/strings.xml index 9dc9abb35..97794174f 100644 --- a/app/src/main/res/values-ar-rIQ/strings.xml +++ b/app/src/main/res/values-ar-rIQ/strings.xml @@ -145,8 +145,6 @@ الحجم المسار إحصائيات المكتبة - تشغي الاغاني المحددة بترتيب عشوائي - تشغيل الموسيقى المحددة معدل البت اسم الملف تجميع مباشر diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index fcbf1ee96..ca6f6fafd 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -14,7 +14,6 @@ حذف قائمة التشغيل؟ بحث تصفية - تشغيل المختارة تشغيل التالي إضافة للطابور إضافة لقائمة التشغيل @@ -28,7 +27,6 @@ قائمة تشغيل جديدة إعادة تسمية قائمة التشغيل تعديل - خلط المختارة طابور خلط اذهب للفنان diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 196cd5357..106d70d59 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -72,7 +72,6 @@ Зараз іграе Гуляць Ператасаваць - Выбрана перамешванне Памер Ператасаваць Адмяніць @@ -81,7 +80,6 @@ Гуляць далей Дадаць у чаргу Эквалайзер - Гуляць выбрана Чарга Перайсці да альбома Перайсці да выканаўцы diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 35c984fed..9e55b50e3 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -260,9 +260,7 @@ Nepodařilo se vymazat stav Znovu najít hudbu Vymazat mezipaměť značek a znovu úplně znovu načíst hudební knihovnu (pomalejší, ale úplnější) - Přehrát vybrané Vybráno %d - Náhodně přehrát vybrané Přehrát z žánru Wiki %1$s, %2$s diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index eec9aa65e..d30ef1144 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -251,8 +251,6 @@ Zustand konnte nicht gespeichert werden Music neu scannen Tag-Cache leeren und die Musik-Bibliothek vollständig neu laden (langsamer, aber vollständiger) - Ausgewählte abspielen - Ausgewählte zufällig abspielen %d ausgewählt Vom Genre abspielen Wiki diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 0e7da1415..b5730db8e 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -135,8 +135,6 @@ Σύνθεση ζωντανών κομματιών Σύνθεση ρεμίξ Ισοσταθμιστής - Αναπαραγωγή επιλεγμένου - Τυχαία αναπαραγωγή επιλεγμένων Ενιαία κυκλοφορία Σινγκλ \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 70b8b49fc..0712780dd 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -255,9 +255,7 @@ No se puede borrar el estado Borrar la caché de las etiquetas y recargar completamente la biblioteca musical (más lento, pero más completo) Volver a escanear la música - Nodo aleatorio seleccionado %d seleccionado - Reproducir los seleccionados Reproducir desde el género Wiki %1$s, %2$s diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 1f222e0a7..55677cbf1 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -37,7 +37,6 @@ Nyt toistetaan Taajuuskorjain Toista - Toisto valittu Sekoita Jono Lisää jonoon @@ -219,7 +218,6 @@ ReplayGain Suosi albumia ReplayGain-strategia - Sekoitus valittu Automaattinen uudelleenlataus Automaattitoisto kuulokkeilla Aloita aina toisto, kun kuulokkeet yhdistetään (ei välttämättä toimi kaikilla laitteilla) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 822484268..b063a1730 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -134,8 +134,6 @@ Genre inconnu Dynamique Cyan - Lecture aléatoire sélectionnée - Réinitialiser Aucun dossier Supprimer le dossier Artiste inconnu diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 5c3288e6a..faf684102 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -49,7 +49,6 @@ Reproducir Mezcla Reproducir seguinte - Reproducir a selección Cola Engadir á cola Excluir o que non é música @@ -126,7 +125,6 @@ Ascendente Descendente Ecualizador - Aleatorio seleccionado Frecuencia de mostraxe Acerca de Monitorizando cambios na túa biblioteca… diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index f4e65894c..97ffbd5ca 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -100,8 +100,6 @@ %s हटाएँ\? इसे पूर्ववत नहीं किया जा सकता। लोड किए गए गाने: %d अवरोही - चयनित चलाएँ - फेरबदल का चयन किया गया स्थिति साफ की गई स्थिति सहेजी गई लायब्रेरी टैब की दृश्यता और क्रम बदलें diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 2cd6aac4f..fecd2b6f2 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -247,8 +247,6 @@ Ponovo pretraži glazbu Izbriši predmemoriju oznaka i ponovo potpuno učitaj glazbenu biblioteku (sporije, ali potpunije) Odabrano: %d - Promiješaj odabrane - Reproduciraj odabrane Reproduciraj iz žanra Wiki %1$s, %2$s diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 179bb1a44..cec9b922a 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -75,7 +75,6 @@ Név Dátum Csökkenő - Kiválasztott lejátszása Új lejátszólista Ismeretlen műfaj Ugrás a következő dalra @@ -142,7 +141,6 @@ Helyezze át ezt a dalt %s előadó fotója Teljes időtartam: %s - Kiválasztottak keverése UI vezérlők és viselkedés testreszabása A könyvtárfülek láthatóságának és sorrendjének módosítása A tétel részleteiből történő lejátszáskor diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 525ed9115..21f166a7e 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -183,8 +183,6 @@ Muat ulang otomatis Selalu muat ulang pustaka musik saat terjadi perubahan (membutuhkan notifikasi tetap) Perilaku - Putar yang dipilih - Acak yang dipilih Mode bundar Aktifkan sudut yang bundar pada elemen UI tambahan (mewajibkan sampul album bersudut bundar) Koma (,) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9e565a356..1a4bf8938 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -255,8 +255,6 @@ Impossibile salvare Svuota la cache dei tag e ricarica completamente la libreria musicale (più lento, ma più completo) Impossibile svuotare - Mescola selezionati - Riproduci selezionati %d selezionati Riproduci dal genere Wiki diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index ab1393f98..e85f5a07e 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -40,9 +40,7 @@ מושמע כעת איקוולייזר ניגון - ניגון נבחרים ערבוב - ערבוב נבחרים ניגון הבא הוספה לתור מעבר לאלבום diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 21849864b..7c011f04e 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -63,7 +63,6 @@ 降順 再生 シャフル - 選択曲をシャフル 次に再生 再生待ちに追加 オーディオ形式 @@ -178,7 +177,6 @@ リミックスEP リミックス ジャンル - 選択曲を再生 プロパティを見る 再生待ち ライブラリ統計 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 7d54c121c..70aa17ba9 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -251,8 +251,6 @@ %d 아티스트 태그 정보를 지우고 음악 라이브러리를 재생성함(느림, 더 완전함) - 선택한 재생 - 선택한 셔플 %d 선택됨 재설정 위키 diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index e225f4250..cfcbccdcf 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -249,8 +249,6 @@ Perskenuoti muziką Išvalyti žymių talpyklą ir pilnai perkrauti muzikos biblioteką (lėčiau, bet labiau išbaigta) %d pasirinkta - Pasirinktas grojimas - Pasirinktas maišymas Groti iš žanro Viki %1$s, %2$s diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index 82f0ffe92..f1ae5be53 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -1,6 +1,5 @@ - തിരഞ്ഞെടുത്തു കളിക്കുക രക്ഷിക്കുക പെരുമാറ്റം ഉള്ളടക്കം diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 5913008c0..c276bd0bd 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -43,7 +43,6 @@ Sporantall Spill neste - Omstokking valgt Bibliotek Kunne ikke lagre tilstand @@ -275,7 +274,6 @@ Tonekontroll Endre gjentagelsesmodus Spill - Spill valgte Lagre Laster inn musikkbiblioteket ditt … Ved avspilling fra bibliotek diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 4d29de5df..c0ecc5905 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -209,7 +209,6 @@ Toon alleen artiesten die rechtstreeks op een album worden genoemd (werkt het beste op goed getagde bibliotheken) Sorteer namen die beginnen met cijfers of woorden zoals \"de\" correct (werkt het beste met Engelstalige muziek) Stop met afspelen - Geselecteerd afspelen Uw muziekbibliotheek wordt geladen… Gedrag Remix compilatie @@ -279,7 +278,6 @@ %d artiest %d artiesten - Shuffle geselecteerd Intelligent sorteren Verschijnt op Afspeellijsten diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 8e43996db..175c77cdb 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -49,7 +49,6 @@ ਇਕੋਲਾਈਜ਼ਰ ਚਲਾਓ ਸ਼ਫਲ - ਸ਼ਫਲ ਚੁਣਿਆ ਗਿਆ ਕਤਾਰ ਅਗਲਾ ਚਲਾਓ ਕਤਾਰ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ @@ -76,7 +75,6 @@ ਖੋਜੋ ਗੀਤ ਦੀ ਗਿਣਤੀ ਘਟਦੇ ਹੋਏ - ਚੁਣਿਆ ਹੋਇਆ ਚਲਾਓ ਕਲਾਕਾਰ \'ਤੇ ਜਾਓ ਫਾਈਲ ਦਾ ਨਾਮ ਬਿੱਟ ਰੇਟ diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index e813bd244..737838fe6 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -255,8 +255,6 @@ Stan odtwarzania Obrazy Zarządzaj dźwiękiem i odtwarzaniem muzyki - Odtwórz wybrane - Wybrane losowo Wybrano %d Wyrównanie głośności (ReplayGain) Resetuj diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ec43dcdc8..ef6191ce3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -253,8 +253,6 @@ Não foi possível salvar a lista Ocultar artistas colaboradores Mostrar apenas artistas que foram creditados diretamente no álbum (funciona melhor em músicas com metadados completos) - Tocar selecionada(s) - Aleatorizar selecionadas %d Selecionadas Wiki Redefinir diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 8140c3ad1..3a297776d 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -217,8 +217,6 @@ A carregar a sua biblioteca de músicas… (%1$d/%2$d) Retroceder antes de voltar Parar reprodução - Reproduzir selecionada(s) - Aleatorizar selecionadas Caminho principal Ativar cantos arredondados em elementos adicionais da interface do utilizador (requer que as capas dos álbuns sejam arredondadas) %d Selecionadas diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 793b4ec7d..4351fd565 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -134,11 +134,9 @@ Afişa Utilizați o temă întunecată pur-negru Coperți rotunjite ale albumelor - Redare selecție Listă de redare Liste de redare Descrescător - Selecție aleatorie aleasă Treceți la următoarea Redă de la artist Redă din genul diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 03703707b..6e93fb54f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -258,8 +258,6 @@ Не удалось очистить состояние Не удалось сохранить состояние Предупреждение: Использование этой настройки может привести к тому, что некоторые теги будут неправильно интерпретироваться как имеющие несколько значений. Вы можете решить эту проблему, добавив к нежелательным символам-разделителям обратную косую черту (\\). - Воспроизвести выбранное - Перемешать выбранное %d выбрано Вики Сбросить diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index e9547d169..60f9e5c97 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -43,7 +43,6 @@ Nu spelar Utjämnare Spela - Spela utvalda Blanda Spela nästa @@ -99,7 +98,6 @@ Alla Disk Sortera - Blanda utvalda Lägg till kö Filnamn Lägg till diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 86915531e..559ef2336 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -196,8 +196,6 @@ Tekliler Tekli Karışık kaset - Seçileni çal - Karışık seçildi Canlı derleme Remiks derlemeler Ekolayzır diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index fa3310d1c..ddeb997f0 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -54,7 +54,6 @@ %d альбомів %d альбомів - Перемішати вибране Ім\'я файлу Формат Добре @@ -83,7 +82,6 @@ Шлях до каталогу Екран Рік - Відтворити вибране Обкладинки альбомів Приховати співавторів Вимкнено diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5edb4fc5e..c5e17d0c4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -249,8 +249,6 @@ 无法清除状态 重新扫描音乐 清除标签缓存并完全重新加载音乐库(更慢,但更完整) - 随机播放所选 - 播放所选 选中了 %d 首 按流派播放 Wiki diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 852e9eeae..31700900b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -114,9 +114,7 @@ Now playing Equalizer Play - Play selected Shuffle - Shuffle selected Queue Play next From 12bc46e210f7456cbc72bb666d50beb488590ece Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 19:42:48 -0600 Subject: [PATCH 68/72] build: bump to 3.2.0 Bump Auxio to version 3.2.0 (35). --- CHANGELOG.md | 5 +---- README.md | 4 ++-- app/build.gradle | 4 ++-- .../metadata/android/en-US/changelogs/35.txt | 2 ++ .../en-US/images/phoneScreenshots/shot5.png | Bin 83876 -> 95043 bytes 5 files changed, 7 insertions(+), 8 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/35.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d1a81e7..8fd76d375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## dev +## 3.2.0 #### What's New - Item and sort menus have been refreshed with a cleaner look @@ -16,9 +16,6 @@ aspect ratio setting #### What's Fixed - Playlist detail view now respects playback settings -#### Dev/Meta -- Unified navigation graph - ## 3.1.4 #### What's Fixed diff --git a/README.md b/README.md index a324ffd3c..8b16f36ed 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases diff --git a/app/build.gradle b/app/build.gradle index efac4f42e..6ab20ef92 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { defaultConfig { applicationId namespace - versionName "3.1.0" - versionCode 34 + versionName "3.2.0" + versionCode 35 minSdk 24 targetSdk 34 diff --git a/fastlane/metadata/android/en-US/changelogs/35.txt b/fastlane/metadata/android/en-US/changelogs/35.txt new file mode 100644 index 000000000..e3514f6dc --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/35.txt @@ -0,0 +1,2 @@ +Auxio 3.2.0 refreshes the item management experience, with a new menu UI and playback options. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.2.0. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png index 8fcac190a493a94397413a59818e23838fe26f39..1ed0f49ec4322ba3748ae74728c17821e380add1 100644 GIT binary patch literal 95043 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 of6pS=TT?yc|I0=dg`r8lS-m2vGp13EL>j{pDw literal 83876 zcma&N1yo$$vObuE;K6C!g41Y%I|L8f!9BP;G!DTXf(H*xg1bwA;O_43?hgO+yZ6n^ zo%QCed8>P!)ob_Jc52sGRbSN!Q&NyZLm@(W_39OxjI{WdSFhfXyn6Lk0qG6Uf{EgF z^y;;gm5jKkirf5Q#*H#)VE$yLE)ayRM|#{SG?Po0{QZCRT&A-6*>o~H7TNxo-haLr^SGd?)CZW?$>{h6V%0O@c;iYUXepnJAb@4h8WF&8;ciwFu#K;XJipR&$b-$0x#N z*<~5aOif7z9L5bNB;Ccl3ve;ELwnDU>FlIH1-H_)>=K6i=aYgKUqt?kc!4>bX)bkFJcXA-t983D;Nt^=8EV(v=dS~nbF0dD8pcVcxYk_af>?8Sqi zGSfw$bLe(9?lkv6v9u>eizTE0H@yEgKl|xE=5v}GV>}4dIzFN1?0Na4mRRYjOy!Q_!wcP36^62>AUSc-R(u4=o zX|O>zx=kLrCt{y&`|eitsbYL*iLo2ybVY%cagFR{=@XXJ=bLN%*io5Mb-Da@KCNJ6 z)&r_HZ!fVfwT&RwmQg|4gBe*Jc~aOvv{85}-bhGDOf7aw5zO^@M%%7i;(<=SHJ>)~ zK-x|g-dva3K*p*$SZ#{UPRt;>2B4s*NEPXaFk98JS+?83xt6AwP8!iqkc!#rij!e6 zpHI&D%RQ~%ezq56 z_l7_!E*AYWStwTqhw#Y7HHTp9hgCJ6UV%7?nP+2Y3$>+pYpg9QOg|a1CF4=n#>Qgw zp1SXFCWMwWGMFu`m)xldJ{#4x&GFN-6~VUOv%dV958fIq`=0+j zAM=aZgWpfRwYU7#2Eddz^W*GoOz&ieMs&4dFmr2rPQgSMwbKhN$<+1FQD1%M!|o;E zjc7pdI&unk&oiw-wC6G7+t){k80~7GS-|ttYR#_Xj;Rhs)nB>>HH;C~4L=ikB6n-v zJ)6PtFYs2yu}>=3u}#!gj8|5QROvPtx&$621YH5=IG9k&8kn!)S_4@nNIX>6*M9 z(0r=-W?ftkD~Fw5F6jqgWCrVpiZU0Z`Y#L6N#(LW_&JPL(CVg(qV_mR!)P6CS$ z=QCe-o;2Om+gPL2KA3O1Ye1JHT=Oe)5FL1WP(N#~@CQB?@p%SBONDOlRULV#Dd?Vj zotbO~&W4sGt)tccO;Aw5a8(kPjLzfZc*HwRT=t-QDRFL;VW%>E3 zX|wLO6*rEE3m6C$8BYr|66o53vm#Yd!-o$w{SsTU%XyS6WGmk260gnHOO2CMwHmEW z)n~km^MBYap6*`REvkGTJ;QOjY`JoFwk}3HI{N0j|+%yen3q8AlFftmCH%%3a}WSPgH zQ}vIwBPl!a+`cN+E_2pcy3cuEtubAwEbe=fDP@jZ-CU37uL8AssllCQxS*AvuT05I z3?qXUHhY7NlM~Cu-h|bCRBOmzy@I6rnj6@hC}YK9X8rt@sE(U0Y|b>YaCBAygBcl3 z`M!KGd+T{3;JnhTeXbc_@^FnN?9r`HeSJC8T3_)I*yhPj8J#2Z*VmA{+2Yx=((3h( zEl3|2vxPYh#4xgKms?0<#VA>Snuw7yC@}%r94<;W;)l~djDy@JWrTr2ahaD zfZUxkJKOiiL;$~V>-_pjYk5A?dq&*D%dJ~FyO-5Sw~-7)26%}xi@MLL^JIVAS{oZ- z&8X*94oxRl3%tG8$E$|F!DrF zlkoPswhnz`oornM51t0;z_Puc&6-_CF5!mz)vH~J`FVZv@;Z*;(1e_lCK3@@eU}b* z==%0qH|GSsvy>Ae3icL}r9a*`>UrKo38u(I^c#>;B(O8EW)(GivMvWpN>pVCJr2yy zRs1Q5(60lIV1EQkDrI8~(?1GC#)d1=Hv0{2bAHqET15c*5OJt1+KW#QzBYszwoZ$~ zYcw?^ak<)W>lqUut}%DuuuX(_pWqu7q^5A7N800~Afe&(;n4c5KI3q;pLU#FSYNSG1Xb6e<&y@LCd>ZCIC7%@_pQPy(I@!>lufxUcjp9VJ$zb_^|OuIv=B* zZ_n?Ko^Lpqh89e{nN06=+!K3O-=lEdbvOYgA*%Sfp8}G-#>xWLr+N= zUiIGbZ76SrI|XX8ymr8w=72YK2neaRi{<(GZ0>j?n|*RiE7G_O2=*(@X+_I`g{&9H zOXFJhJ%dg++&L{gKT&pxkaPkXVJ>s_5mLv-Tv&9R1p+;mo&}-#hRIDn_~eFpScIX} zF?RKi8R%0jIoGvxcp1am74f=j3;cE&L_IyVl2v4nJMLVH(wdnli*wZp z#sUF2sA)nIYQKFhk;1uEFeo=^_G?=ufyVfkw*}5sk(o?|vqaE}O-NVZkbG}{#_G@~ z#plyS!wXvRSx+VvucH&MpBugcTu>p1<|nn&qWy>9`tR%x>Pf8NM**5_BQLFqaj%}W z4xP0Sa>0qJ8m!jcpiXhy?ojcd+dnXW^|YtnoJSqAz_Mp=u>N-3x@ygxLki3vkCrMu z7tV?0GHz^U4n*?q^^clWU(zP~*K7Rfa*kOq4yi#r(1*sya8xd(Q#oC6>J`rMLua!|e&0YSi}I{ytRX%9$2h z02)|#iTCl9yGWh45C_vBp2ky6V6idUrIKl>blpHS{Ug2Jjg3)yF5h zk*C5UlJV#fzUC#9{P|PQ?{RtWde+gYRKB>+m#X|;O-==HsH+BK&&*afZ-)*SkP_1S zlX6qwz6D&DwDWuW$_<>cJwDTf_JakK9C=~q8JSS13A5Qrzc&BfK@(4PF_XaouN85; z^M&^IAzoT`$jXSVjl0o7i%Dr1Cp(&~Q@8q*zWK$*cxAZ!=e6zxzV4Ias#@F+TVvdD z#P`8O_rV9Ah!`1sRDJtp0}Oj3rZX%nEM?er&zN*NNRI6#sH3`e?6;>3>byI4iuM4a3F~ z9j~h^IX<=`Pb=NqA1Jb5voB9mR7pyj$~{^*5!^}AeR4UreB;Xy(X}oA1YB{$@O1 zd(MfJFwgz6SroZ9hBhXVI~19G?^7YDii(^O9E=WK#QUDIw3;DE$J%ow;P`gk;o9<( z?#b##-N{Xvv-}d^%^6PIlkxPDO5W`bop4`76zk}6Ff)%PuWTM@&yNqgV`t#Ft{pR# z-#H*_X2*Y-9c?cu^p9}j73}Em>Ka`hrlUtfN4a5L{MoWJ*!%J0Van2SuXT#`!8tn0 zIj_A4q+S;*6SEGQG8fG2?Q3UF?}f!9gK!O{W6?|fvE=Y3WA(b_EoscE-JuTmDvNs;d9YvGRrVk%KUTMG?B<6ETl-NNHnC8tX9 zV&oUR2Au-cU$SuY*2M)kiaYHQGKjkkTD=E^JtA~JdfpCdKBGr&{-I<+Kitd6(szko z@tcyA8_pH>c+#E?`T-x@nv|4i-_^V`8j$yBxZ-&R-4DTkG=iKyG+50v5aPRwJ^A0~ zVki)J-pu5vw>q#4An0esN(rCFV=Vi=p|iL=i}4D{v9OOMwpaeZ9$ZCPc}>(6%qR44 zWS3Cr{fN%YK&z&q(jw6EcLcc%IjQ`Y#<0?f+~ZUqc_!;4?^>AI$XZ*k&us=a4-VmP zkdtj!mLTQU((g&| zJ@u=<(S+Ri^fM!+Bjx#RF7SBceww)H2q1sj_Y8sI*p0^-uH!_YOa!&2crT3FK4EK^ znY7RzPyQ>)oCl)JxchmV*Pg8pan}XKTE)7$t56a3S5^2UGB9s2V5-xe9xK6Cl4OEn z?I%G!aO{5`P<_z_AD^M_SCaZGycvcxI613TJ4A?*fm!64GNJm%g1*{Ib4~ozfiKNW z35dT9Be07+HwjA3G*oeH~@UnxC89%>}4Ge)igXZEW=57l8KE z5!%6LOh0%OZfT4BN^Ad~S$jOl{oD$!^=8G>OOSD0Is0&+e(;`3*8pQ6TRn!d4 zz86%*r&O_jgMZ1dfM~)A0+GdTZ%BF!pDX9uV&Yo@lRDjJk6NyX=+%Ohw0Tbt5&`#p zxyQr_2lY2GQXK2XGH|k zW!u`;+D|xNUrTNyDrQ`xAZeJ{(C88OPTc(UD@PrRohjYO>&X=4J1F5jN~t2CRful- z^oi1tg^bs8dU+szNk7-n(wgf*sk)0r9CM(i%9Ivz5*V7lTT1m0<5WO#tDund4>9%2f0bX8@E;QD zJIcQo{olI%_ksR*yBiQwkQw_st|K8EkckFRMl&p6{!i#qX1{^24L>G@j2EFhue%8i zuLL~H=GV=X^S*4^yE>4nDxNxp1U$7A?Ii`kr?qfEAk#{+rOajCY{=OCs34U@eECX~ z;|nzR?HCySRZMbzhh>NX1wr z^aq11c`X3;GCvLFv%v173a(&8BySzRCJO&;BI3dJGv zAoQWJ*$a>|Rj3-Tpq965@&52`UuTgL5u;g8xJ)@QJn%`k1LlpxbIB&YE_|mxUtvNOAqJDTm z#y)%i6CMSalC;cs+qCJkvEYXtsdw8gvrIkQy{I& zVuA?W8=MNlFatK{9Zk?UO>1+0qx+Jz;`Iz?l*9bLkJb}B8#15qf?D!-Q__+Y(cibX zU6?sXqOIMCXjMm{r7#cz4j3T#FA@@BiipbyofNeE9o{zznCd^^TB%D4BwaFS?QX3G zomOmp+JhwEho$78e3ai{*<^3Y6hql-D_~(B{uH6CnkoX<_v%ZeMtR&nNq$Q5Xnt`q zbM8=6Y{{p{-GsG+rAAY5)MYQ6Jm>9^9~qmfQl>!v@SmZqN-D;nO{2>3_wRWHZSbA! zy0(qqJjTt;yhh_ma}<-*gBn2ktV=>Zg+_$`sf^1`3<}E0fE9-4wv;}Qy{yOTbz%sR zw&5pw)3xu4V!svjdJluy7=LG-j4^7z!zSd_qyPj8-XnTb#h1$AT(g4%$_nrU4uEXW z{ZljvM_nBV3xqhrY=RR&lk?OPa((cFpyBA8k!(+l)~5}z)#yfRT9x8_>#=yKWnnW& z_B)_up*754;6I9YTgn|t#3XlX(R#n4}8M9}SB#glLu z3koR9Kl5a&@o{}yJeBo=u<$Fwz2=AVrjp_?W|4eM^DUzR0IVj2<#kT2?JJcKMHP6KRC<-dDmFyEd99V%<$IaV&VF<&riCaHf(KUtN& z<*szj1+?`)f$M*dlK+i%#Pz z%SPGy+;TH&VCCs)Y4DFt@Zy;F@L0le&HRT?_IUFAdOBue`a@Z7X#0BeD2vf7VQz{f2j@Z zav8cLH1(&DR{7`i&!4f05EaPu_>`!zsyv$Qs)VSfjErhf5Nd*wO2X9S?7OKM3mGA? z@<0_?dR~1MdytN-l)k>Qwzh((&sh)#ikz2@f~>l_gswcS1{(#1s9pAz3p4XsQTgxY zq|7@#mHjjb2uVdn4aV&UoHc4NTQt@Vt4g)AbF|`(ys-HpX=HQeFEis`xdpL$N;*r+ zXs7y4#Kbb<@OTq_T~}J&CG*3}EC#{MVn#tVQSa##%O<8WRt{Ncrs8CW!%?iCxL%eT zK%C40M&7Pu)iH9L>Qi6o|r#0RSJcO z0gy7Ri3oq{j=B;IKr*AU=fbbWP-`(eCnG)wggFdQ=;7h1N!y7Dmj&dRPJ*`RI&W$h zJo8#_A1}tgFz0QCLeC_}@9K+geB7xV#~xUhbrn8CZbyaxPAz$RrQHW-wLB^R3X~Rl z+88(>)FhgpCr?tEwmGOx@*B%L5szp4?QtRH?$q?VgxS!tSbK(#gM};v{>$5!U_AtX zis^6pKQuI|V-$S8s`@@)Jka^5)k520GAv;5z9nH0jThs~VO^_1%iUd!;GKh+2t})# ztZdC-9MwxCEUu~bPs&8Ju-DT2ObT#SI*Bkryb?jxe<)j`4q6}Ri>fL2 zpbG9zJpuS9U%9gep@aA!(B;-hr=G|8HO8=IU5s4&3lFg;LsPNieGh(mLm-BMwQWnf z$^a{`+v&NwxY!WxGzZ=c=Z%8!Z6+PI{b!z_QEskdl8p6dmz$j#K>Rx_U0pQ4tI?w! z3Y6*~>ew1FD8liTjt$rfLY_X%f)lPcdacb5w#KFoH$l{EW+NSpd@k=1)@m}Eb>fCA zECRn>ByS{6sqC5jTfkc$3t=a|+lP)WQZWrtXjV8N)dY+X^45!sqMHuRh_LX?Y{KHq{S5&j$`b3<{qgJLPdgi>y@M=Uaf!+N zWQ11$vHSJ0Pcf{n(qwDP;tF3B(@IaJeMG`Xn1460h26}ZH!U+x`|n)6gv8cYa{}%_ z_z1g$&A}10>oX5YykzZ#)B51+Mw&b=pT4;U6e@OhpHCt3X}p}D3ZMuGA1(w_Q%|+( zALRS`@HLxEEUdWiZxLChDkjV@^GknE3GnoXecs%1YxyK{r_)?Zne7)AmYRzIgkeaA zLlG$VS92z+=|Lsd2mZftWOf^;9)MI7H5OxZmoe<^ zbsP(~l1Ttf4U>De+S_%GmUjTT&>!-=S%X`8y5md+b&7a3*c;eTCEF;tb9Rb|%2fQy zPDBlCO-x8U*s6mA!(gRcD{AilnB3{Y{J;B&+;t;b%`b~AEcl8@s65x3bTZ-qjupUu z2cZ5*5$y5%df(mshwLe9;pR}+5X_J$z+5IxomdzwDRx4k4hiWPXyM;nITbAOhbl#D&Ai|&(!dr;F2Ij%lf{-2|+1p=9BQOJJ-AtkHNQ#@Z}iZ0eR*IA95{12WH5OAW>HFjDAtjb^tq8nUq$7b zM4%IG^h#l6Ce!M@TIU_=;xAd{m%(?!e@ao+v5y}wKYL|xFtU;6#MhHPe&d5NJvMsNbm8DsV zV+Dg1SV|W!2LqB@wTkONWowuI*vo@TO}&GdD8;{vj1G34c?2k#%koX^BU?!;P3%>S zmiYc9$H)CLr{psGr-QK`;qs;?UgOQpb-P>#EIiJk3S{p3UYm{$Ej~WVu2t4cBKOO| zzq7`D1;4bszph zv412?j_U`Vv_-h)CaL`s$`rdp9Oe((d0&^CxU0H&1WPW4=N@@pM+ra4oO^@Q76p%m zeFr3>d3^osa12r#f*OA2mxc)bHW45rJ|&ho6!;j>;qHCy~U8|^Nz=7PikFYdDhyv72*{mXimW!K~R zgD=rNLddaj>>|g4-697MvA((QORCp339ksoGU~U{b}&5yGkc=5Y)g55FC&=RcyU{D z?fdmw(Mm9xK+5ei{&TDeRZOOhv(ru-g|EW<(}CWOlY=HiBF~WT;jC_XMl6o(GNWUN ztSe_l>w62r1KNDOChw69Z9-9IS8L&u6gH`K3C@zTKj15tJvU*{a1XIX3pGJ+R=lZO zLX-GYxpVSWE;_>EfB*jX!+@?+)6}h=rLF8Ry>|>?IezzUot0yhovYM*50h6sWh7^& zm2P<$qUQM`ffp(zX@FcENNxk1C0#f;4?;jNoG@7445tv<0?KF?x`3mK7prt{!GuJO ze3Z9XiI|b9jjkDM*B%kkJ^_K3d8+Q*?VQP!wQbDGqXRp?A~iRo=R@@)rSrnrK(Mzs z9cl2@+JJuAgH&{;0O!hF=)V-E$TEKhdR2i%)_i}YWd8Q0^>ZOOq$5VGdsXhIHU0Yo znMk4M_f`4M%{lS6{?LE;xqVAV3v7|3qkxCp5dK9i zL#%I-gOilYmJgE%N_*{i+i>CY@J6W))hio2u~k9cHNHveG%Oyl&Kc2RXnaI(V!wSG z(#IIwOKNGlA$fh9*5teC^p&1oir)u$P?;ZeN{|!BjiiZKn#TtO5{xbcLg^Rn@kr>To@6pqkPg zgse#Wo?nlYp7HmLNK}|LlyUvvOTAO5S00&P7Fze3ELlH~yH6VLo^DU=E-E18hr6~q z*z@Kd!T+rWXH~Ze5J&pO`KPg}-;W3D~#`q-K55Ei#^I46d#9E0{~)P5aT0B5WN5 zvpgVX^Zo3VR+OdqMDe|(ahi`2QOb3N)ONv3zQiVBYD#>azMv4Mc7`NmB)FOadv|bo zrCu*uiKe*l*M9AwQuXa7F77Wk7k8R2xHUS3&TfmrD{ldR4PHoWZ2ZFRTH+6N9d4dw zzPoP*>pt#MzLQsJ2&SeHw7{n8Gc#6%p7Qg5%Q}IoO#w2XsLs&=)oGl=!>sw#DrWy+ zdTY9dBU*u2Q{!{DoW9aD_ z9mcLhvdqbvh_hm!2t5h54{RW{#f1o}@1Uw0988Jb~hr%OrS=N$9O(hho2+5f5?YZa=Lo5;3dNCOh zg`@-#iX&QRQZMUE3cdX&^%wQOO6aQoNCsAqgK-!c)l^VY=MCOOGMD_)3+^onGdc2p z{oa7lVWe|Pkz|CdQ)i5ZcFNimK@_OlsWRa4x{ubm8U^_ntn z2qn7Or^&Vk3#{}EAO^&G50@RM`LlP;gc<(bQ1JjTe}he|;p@vtdB=m)0se=jx)TZ< z880q~!@{;UH;>=7Hv`SYs8noToUuBuWyN z4})15zdM-Y`50v>;P!~CT|o_|C@YoFwOUFadwg@V;qrhBYp>mR@wj(}g3so}B{X%J zn~RCMfeM-6dYVc{dw(oHKcrU7flSayG<=bjMPL8GMzVaSwhgznIO;Y=PL&gAm!q$@ z4_H>p!Yp5XJ<*R;hmIa4CRBdUCiygjxJ}$00%b`#T`{VKsI>2xcYq;ckyoK)(b2lS z0@A=LoE(GJ)BFt$Io~Y@^FntNgm7#k%sFboKif)$q2rXKVbkfqGshryG=+4QZT2oym-!DY*jrXh^28I7EVxI8Wk8$AmomQ9it-SKsaN~%WE5IhCI2jP`r;lk z(aP-@p_FCDqMufUTHK<*pI6&paT%Q!!>X@xO~$J>x!8wS<756&NKkg9SHrlTmi9vp z%<5ZIK-@}c@y+?lkgmyf>4d+clo}Lm9dFdJ(khk9!>we3JG40^dclCKjpqVs(W^Eh z0_7KXNp)P!D!qY+Oya<_&12K|&1}}w)lOov9)r2A(b~P=lS>Qjctz~|TR2{C=~(srD(X{H*jG^K z;SKbWm&l)c5v)W}Qh)ee#p7?BZl6jCXc$E$zpsMfMGs%4w@xsP3E~kBd4ELJJvmI3G**ocoP$T6ga3Whyn9X+=9h9u2Ld@4VPptIJ$xz{ zmo2hqrGFoe#^`kE{AE7ft+RsQZ>)zt5zb5X?%I{?=DbbB71q~a^@Xe4G5Qe_GBYRn@d;__x6RjH{6yF`%dGzFMCYG%E6qD|37f&K;Ke`Q4S5WG zVp%6`LsVdlH!(q~P>k%sQ%`XZi`W_LSXH?%M4M5pU?;wz=Ld}U-1Lo$haO!^2dfRD%9A9pSuVo{CNh43H`$ygS zMn^|wR8gvfv!=qYSy=^QDk+^y<{!9l$0p+Tl%S&eX{3j3IQfW5N(8*SUAPQ8P2drfP4etKlo%=iS3pHrmxvIrw$PwF#-z~8$q`*UGo`v<|q zW~om^Qn{G6Ez3~Tq0$^>`4##|+0_brH#_z*^pL;i?L;31#d$TMAWZ%|{}l=AckOfRtMivQ#MrH9rPP zFcW9AoD&p!6Z^HEwc(0+6l^LYsh}pW%+7Meb@M&9t*cWsRJKqq$HnakIWnbZ1@XY2urRp zMc>|l`d#8X_h5S18Bc?)%!3(%z+?kPhmOqVbPDmlo6}nv)GjX$I+^sp`se!M%Bmgs~^A+YZJl`R!w;+sQb+EhM z9~1c{J7P%y+T#e>v?vl zLwM4fYuDS_PK-lDXmCOG@Q;13OgN_^D~YPZNktJGkl|3Z`G@VRtglv=6_C(0&6cK>F`M-MXR+v^^I zD2}U$I1(CBe$xA4ZAn$cDwzVzhY-9wItk?WFC^dqkw3Z=JPbx zXR~*$EcofY=O-F&(RFh&7IG%)lz=ZdHEbm(8=D9am7?&)Mb-MU#ID27R%&D(C|q~6 z9MQhWL~mpg*)iGASC$R37%O|Ehxi?QLoY_!{n>_y?G87FDrtw#+OWW-|NOhnFEoCT z*$kfoq_;G9uEmvlD{;2%q}vKFF7fNy>l$IT6u>8Oyw_rP*nvLArzz4bM^WjYY=6nt z)63G-{KDQ^OZt9J9ymfqYj*1%Zn$8w8TkA?@W|K#=17$ns;^?(vL@E2!XX0DE1x|+)aE(V^ zsgk)1f!ar-O^>r{tn#F!SUdy0taR+8yf^SB52bd*C2Va<=bFEu=>J5pvhAh?91qEn|{|{*=IJu61Ru2+)-MzN@;$&Bbr6+`W@v372WVY5#bO6_+P5p-=jFW za{m>UNvN5*G-Y5})najIyLE^(1=zM9UkTR7ua|#4Vmq8#It0jm$;uCzWeYf0#yCqF zcE1YUl|2s+x8~RjM_H%9e6n>2aJfHM{m~l1J&{ZcwjLg+V~|bD2e`S~<9XdF{zy!o z1VWQ3H)AsvT{J$tVK<2^6%|;@ro)~lAU~Q2l>L(GW<+e0CIg&hR%i4d*|oQCjVTNA zNiabmX!h6L22{*HfWx)>(|sPB<#fB%nY?Z*x%^5teHCsHjz97es%N{ z;u#9fHr`rd#{g4w;6*r)3SA^U#wyEyGEL@X9g5y$8w+F<%Hd$~ zVP^x=2fLjbcB+RlSsW18PFmH{5lsg(gOK1!+`5Ys3xl4B%4`M2raJ>Cjtz!y1(JsoyCCH;J+!C{)C*-YpAGDWX}44drSqZ==cvR9|h!e5G}+^`B- zhZ($OCT0-iLrtv(L1ytT5w50qjQX2eNi!(wS{{(;EqMC%9gU zopW2CX{6@=B#L!<8hz{0Pi~ty`wbQ>DJd(YIAHBV(u$aDI6M>+va|HaQ@)) zG&EdknMf&Q0nYyx<0jSK@;eR)DeS0QUZIqHuyMg=G1BYvf(NIIvxJ@?1J^5m@hvKQ z5ZQ!3LKM`rWf_k(?howE+iu$p+WJ{HPuT^J`msz!Fc{_Y=ib-qa=qYD&X1<7elsUZ zoqO9AO@v!D>_vWC#xwkOr;9BUgY%{qJ=NuS;=j__lF_J?=_5y$MXpVZjy~S6M&(RA zS51@*YcCqm?uXDly}7O$1LE)6qval?j=%MDhmAG6ow(4CgxCHHlH6uT?{T14r&ouc zppmi6+LJD(XN*-x&OYQ029PyJg??58ON}3x7?hA*XeagWA&v2_eW&doNs%c=TF~=o zi?I#z3G9l|56qM>f2kDJp&w&FggrJwoPoG?@wf-EZ@a>5G7;3S)JOwm};{NB~KN)mEUX7WsFVFK4w2;Dxu}ZA64e zBy)dGR9~^=;4l4#V87dGk${#VyqF;!pH%%CGtgNA-mbkRC%XpDs6AIW7NQnK>sACS z^>IUauk>bw^cnT0<~I%a@8WdQ(vAA$qC3OYbWmqPf}yFR&~hojNX$m8tIG5)qDF|on79ZIVs&j#=oUTfX* z&cZ1j6ST)688HUmD%7HC+jbKrJ=%%jR@&= zZcIuSs1g0H?HwL->8PoE-!Hn-2pe3O59$Ae{pQTGM4h3Fh<^Vf(g)oZ>5Kb(EFwd2 z5QLF{iv@y`lY2s6y(6c7?VVbMSQ5Y*-M3d+4&zZt`kNp-1p3YXXUcp*oin43=AvsHrx=;^kG*x3^Qe z{1?Z9D8rVl)s^CLGrcU68nD#An81fEhR%NtNUR=uOru+}yp)#sNwQ*1Hg+FLPb5-L zRGiL?w8V$QN5`iaEsjKgwuJ3H5PlVWI*<1Gft@iIHTrjadCdd*TIY2wf!kWF_TMq3 zLfSA|YK_h8;{#^*wjL9wSa`m1tofD=sI5VXrVLc$;nMWiKO zr*r%oCao3v`HLQMF~Dv{-@!Eb`LfOWw<2FYp{@m=$24oz3-JPU(Wl{bvtgvQ=oa#( zfajj$fVK!S?~&3iiQDHnny%Ae#m;Wko2c?EEpxI~)DgFXQo(xCMSf4|nRLydtQd*c z>*H0oCj21c7!u%ZiOoocv=LgxvcvsuylWZxMJaAy&%Zv;{aN?|Z4EncDv*_Y$l<`Zax0TjHN{vw5ZNHw2_-9nlm^W356Olibbrq zpZ?zZ)uV8achx2CTDn`WtTkK3L|q`n!aZxe5;}r}FUuj--$$ z9G9m%acas)v#XW@-p7Wvr4Cp9pAk1)HgEyF(fH!e2ng3{x~FPYR062J&7UEfge(y= z!@BiWvYt}EG4krSWs3@OibnXo9f!@8M7efsJg-HY#nDURehJ-icV(LHws`m8WgX>m z9VEIV60zv01X84@<$o<<%k0HlIRbFin%mlc@MkSn$ifz%nHs?E-RGL_9|$1@a!@MS zK%FQbVp(X(c{QuED9@5cE;Bb%jSz29p!4zjNc@cK}^SW6k?j0ZJH zshXn-vvTeD#kncU7Z>Z+-K|Hb>(red7%T@c;9I|XdsZNBQu2y83VnX4{iD{zrz@bY zB&^?Y&gE2kpz|rmEGHjJ$r~ucfmP~6OX-6xjy^n`da?QM19bp^8UG2(ZcBHPLoELC zuY3OgO91~}U-zGtCBR!JbFs?b)m2nuH^n^9!%@P);7Y@+7#e^MkB7ku3*4{!qZm^f z+VZHjlFa6TKv6-r5j#1vE7O>Tiq3QOw(mCbFe+2KHFM`z?r)Gi$Ds#QRLeru(Z@o3 z&_qo6g4f8Bm>@7c>T^G%0%L((Z~uzRYWXclxWGKhb@n05zO))#W(&*vjZOY=PC{z~SN=6lSrt+UpY+6E)XO-QatrRvH|Q zUgPqwG_1xvuL=~StvWWJiF|UoX2JbvBF51&4REE5tPN_$IxbSn5m3_j*OLE~(KDB5 zgIq!Z;5}MDGBP)4xL7qX$1u!PlwSAk2?KKQL!sfYaFj?PkN&~r3OaIW7VxRvCJ5A( z_Zklba+iSTWsoPMa02oeu{lr^tom;fnR&D0%H|KoJa)H^^)qo9Xb7ccA54KEaRFjOZq? zoGLDs)7)(PdDENrrVacY1xa@+Qjb^KpsljxBh{!+S27I2_ z#KI&zCQg>*AKJKy->o;Q{mQtNh(d-+$HEHe{{k#e)fqBgE%b}_MDiyo%b%vNF1uIr-BV^1DnnRVrmlMu^D0n3i8`(AsQ zSpJ|0oni#v)8=+D>!NkUmo5us`tZ|Wf!GhOEV>d01=RKkDO(q42}&gL3b93d{&8c6Q-2o` zZXh%>Fw!45Bhfqv+Tg1P};*(xov(h{l{QiAzid&RJIE@)r} z;LoZ5#KpQ4RhF8pdp84okL}sj#tK#-i3Di)iA|9tc4OQwN7(Pp?crQqJEt6GCm3MM zgjpA*{g8QlnUQI=u_2Hzs`4!KK}&sh<_qqt-zySpqT-$89Ek>{01l4n*$d`Rtt!}o znV5ly-mwhqtv)U`Y9P_|I;}+ir2^*vG1=uh>kqEo(Qe=|RLzBo@qMq2ulZY#p5q1A z%}Guww)ainNXH#+t^$4+9TOw-K^J=$+b2Dq`etvp&zdBLGAN&19jY%czw+IXp;uJ9 zem5quP&i|;Ik6#XhU7L@Ou92Y3`D{>sLA)?`k83vO=@%0RQJWCDI#rV( zedNVqwBjaT`StikAQ#9O=u#QA*_R&7(z)FYv*V;Dn zPIWBrdp?awBX%B>pzET9LZ1sj^y4oI#8Q!@3QADityoI&T+g7c)Y42i8q9Mod}l=;;r% zrqCioBqawJ!Ct>kV2(hj)0b_(do_YLg5%x}Wh^QRW5g_G=Wxu(d`>51@nC)MkEua1 zv-%E7Mj2L?bY~97!txGtAvKjS8MNIn&HQEa*9P_qND|G3XwNPppCP?N-eEyBLE|S6 zs0Oy|_+MMjtyO@!7+u^p19Zw9CUQt?Ap24HITC;*JOPvq`T6GNJl4=4ZOr&| zC{-RC5aneU8XOIHb?Dc_@~=2JKIIW0;F?};h{KN+7vFU#Z)H3oaj7>DNnUJFQuJ0s0+ z*BIfy=o1Sh9TzZpv07?rxucczE=KR*I})3(rm5XTmouhmLK>j_F~Z5wd09a6V$&Ss z*2(u4xwB0fPjl$^ufp)mxB4ncY8^lp7#Vn_(Sn_?olSe2t5)YH@OJpYMzbfmMzQp5b?_?sGl+)zl~_xJha zsUk5x%@^J9U`xO^v@wJM(Y#39Rb~D9@K3R;035UqWzQHA9L={T7AN1{;13JuePMe^ z?{B~H*J8Fg=z{xH5>BntqcSOSu}ccou-1bxc(H=BcVKImoo3sita#Ea^ji`Ae9o(H~mMs?b1ytvJp&aIV#Mp>< z7yiZy@G4|vpx@nY?>gwriJF|))~k#E{o0b$~263<49>X{-1q(wk+KHt!mT$x(EaF zI6nmR_asO@gEGQU^EyPbd}G^E>tLH0ImK0hM%Gy&C_}y1%3RDA5In#a-%gB8MUn4_ zTa$!S!$V@@gPJ#{YhfzE+j#Yqwun%t*LGC9vkvt7JsDEPFrB_Zfb-R;jvF(Riq)b$ zCgpb(wJiAu!(a&n2ogwe*TH3Qm*5V;-5D%6 z3=V@6WFQ20cS~@0*Kd<^*Zr@1)_J%OcYP1^8g}pAySl5p>Q`0Wwe7S>6Fdh!LHR5l zteI*U^B5~aF9_U7Ul8C4z|$ISG1Mm_cA3Q~K>*zSBMF2zw+M+(sw&HCgciKsfuxG` z0KC&w(B0ejsJxh8QLJj=E_$nxEP46>A@Ys+c(2t}?smOSZx?NwOGZaOz@SB!u8J5# zX~tdXX7JYq4C^d$V2^KM2j1n0w8t}Nv(sfj%EdS(XFua5cH&35n)}3kl1QA1w8($< z{#5HUdm=zkQ1%=-s1n19pmVmh;wKEF`FBW|?a3a;Q`FzGnWM+Vb7#`R&f`e^&wb{_|%mP;G8mVXB1^C^|4EqI z*VkW-bjB0{QL=n&&m80?e2Hwrl;jmoh4t##-o2+^DSHtkV_}+vu(HxL<%RD_$!j%$ zdIVwsyWDRSM;F5+e?})!at-}9lGqmkCV75sGHI>vMi9~WRET7ca6n@1*;`GG41oB&6Q5%-F@a)~5%`|YP7pM@nXk-s+3M0xPx^E<%hr~aaD zjC~F8q`i3gIlcXdxMmL#umjU7IJCx3$-`|jdr1k{$h}DR^TAE_Z)tDC__E}wrd}_0 z245d3634{_*#Ch8VnZAkpP--6K?$ zn1CPw4w7I{3$JIC*)Zl8{KEYLq$VJBpobUy^k)^2v|~M%4DB^H@4HHUnvM(>UiBd2 zD0QrN?Ef7)ptER^4e(xVB3sad<_kq6;k!q7uW*VsoQ1J(JQc%R1|-xY`{QtGWo_-ZJ80 zjbY>WEzaEw) zJI@}-T+6)6#LHsN67dxEdF_!!uNtb(}BZIa$PZ4Z%WR7ly#*Ds)UW#G0D@B0k%8i`nxP3l3OHJKQ zhXh=F$!vAT!=kbMfR1)6riqqA1JCcleKWdo_ECdi_N@ld>|7!}=uo!$<3|$9ZdMAF ze&?_5LdBZeI$@Q|El9WbBkK19VG?W}?dabV0?z|gf@&|;fdg-cX>6TA4AyUoE?8^0}6*luB;Qr^9abz}eue5GAdCvT5@35*& zBIfSGI^*H4|sU@ z11nyY7VR6(>SPx=9N98uUjenL%=sY~%~fAn=1-1HS#?R#%N2cl(V1$J zZ8=`Dn4T!2TZ}@bxaD`apQrB?UTc0*a=t6BK6D6P@~i2t#rbBK551yyLp90uMwtb~ zCW&4>8K#D_)5^cavtm;!om``1SRXursZlsSMTn8h3(Yn+6GZ{;$Sn0L>cKPWco>w+ zn`moKQ8Njz1ub~SjN;;PGh@#F6)(y1jOq8fk^69n_eYGEB!r`yHFn0Pg-Aq1Cz3lL zpaieN=}Q>&{z?~M>n0POl1K@~us+O%KWQA;CT>DnNO_tofYJZ?wj*(AY| z5i7?23)Ng|(8J1}B(YL3M}t+s)qU^wYdk!A95%UjBIHt4jr(hXD)?A7t9}FvlF$w` zbc!Xq7Z;HiM><2RAK^R62;I82so{9AD39zP>(vx$?PFMPO!nZ;V2@UNRjsqm+g|(? zY^V%1+m#aRueHGpj(FMhwxeR>H(?#sjZB^EH(Q4VuA6l9wic(xc2}1amJa65i@~sj> zF703^c!TXROqFDbH-A7Iq3-hhrIm~FKgO5d#J)OMp`dC(kUX4Vc1>s4howDT2z5nC z^zCQ#HECmi-T4FA8ta)VYOCp(9sKMr^G|fkuTgnj1Ez14Z$1(Xk_HFaospc}T*&D9}ib(~AXzDI>p%&_0~(Y#zW-m-xxuEd$rJUKx@g6MrAin>QDH;-a2S1wX(2&Rg3;Lq^)Z}SLe_nu1OF=r0>`%R9S{`XUT60LGM zzPHq(s=NKq7ohgRy*pd=Cj74`-*g9l^AT50?G+E0qFvXpy!7xn2|;&vw1XIbCBCT+fsJ8Jg`@|X#^8&m?%1BBs zUcxZ<#^aT*UbCl#imKGnw}YBqeeoBG8#DbsMA{I@ehcYYVg<7V8O6@Eaq5d&cXd1( z4;DZFT4YGpyG2$k94hD>t|xdA;>`d2m2?(ai7kvg!7MTW(x6;DG!s zyv~y3_W5VlbmUIq-xds{dt$5issh}*XD0USM^5l&_RwF085z@;H;Oe-zFrcMipEaiaR)JHUIQaV00)J2e5Nq-4>Ft%*wHb6# z=yuJ||HF8I-&F6%8J=)^B@%!Yqg9r_yr%MgxWfhH-F)w_OmuHN; z@*9T_AfAKsUvrRGS^9P_VulR!{~FCvkDoS>K6 z2}$J4*I%m}Fz@2r$Xp-@vj^lvz_0cE3+7rI;IwLi;Fr_-q}w?}=O_Jn)2XUr@x@AgZAt!cFgwdmh-#%^o3f4Gg`*+Ye@R)|(Z9;K z!&1V9_j8v0yrD|#m#0!P4)pZrg%^?oGQ~GrG6yMYkfnIcx9j#PJI^b)$9+H;~D9%{JN2FWb$_2&UpTr7UhJO$a(WFC|G8(Zk8WCOLeETsDA4XCh-E zpOO-!+2(tK%7eOi&qg~x^(-0)QVV}_T0s|lMfM73t9+{EVM4B(d{#)R#Z-39esE66 z=%y%uN=h``_#<9F~hTUqQCn9?jaYb5HPk4ha!o36v@1wq2*ydS_#&jQt1qayq?{OdWFX>8u#bX`gIb*KN(v?{9`)dGfL`s#Vw{8p=w9%C|eAvmj%6{gnKUCGGk7VQhb+wPi6+Bm+;|1$#WWliFy8Rxi z+1~(%bS6j8_IwEE`<`Mc5!9l)gy3XTN46q(Ie|fT#%4Byrw{1q7ZQ=*w106D7;l~Q zNd$)Ax5{FR;_^zGiG5DHI|~R4J(JBy$iFz+=ssX&%oi=n6U(GQuzCZ*;;=o<9~p~6 z@7U$f_XFl)?!a>n=TnmG3$42z)gN`K4Re^;$@eV>l57~MTDNqKNu&i8d=`eJ3`Gkv zpJU;NOMaDN$c z&xh--o!r@DWMP=4$1B_IeVHWm4UO}30quux=79QrmHQV0F?OyKjC+-JFcFnnu-RZE zTLw2Ih=oRCzO`BqtdYsb^+wru{7@EhVJs?3QktavyRnXquI5`O5r6BX%(J10{->Q< zMa|{_ILl2jcNf5kb5j3P;Zt#QNMhdD0I(S z-~}45vR_7GsNJcldv8~`=Lpv}n*M}m4%u~{wH!v60Jc|tl=I!O!{OdZ9-XrRdZj7n zS`GkNB@Zl79HHLT(C7flNfOgKQ$(=@*w6^qVRD41e8{&sC=9y9`6G?rxLKz<;ND=$ zGiyOoM#2AD2c}t=>|-r<}255X}k)~`S^@30De#3 z!x%tIJv~JlDKRyalbtMXQtW1fd4~!rw+FX|8YrSAAtDgdV$+IZWq@A8c--pLy&m1y z#U#~=)e~~r^tWI>yl3|8Ckk z!=`zkbhh1`{L*bcN(FS5z904! zjM~INa<0^p$`Fdu_p~a1iS(=c=!4vzt zLi9RYO}zrtNKfed3#Rgq6!;MZ{eyF3Zts_?;Aa}Z#_fO&)qBKTkUF;LPI$;ro!eXH z=;Y+YhiYxcR`ezXz?LdXFb1riOD0~Dk#qj7?Y~hPCbxiP`Mh}FA<}`$aBtk z`O*fr3Exo}uzzIecty3vbzhiW(wIs;+Ufll3Mx=%wg>Jjz+Ac>+TD5JQ0pq_Q@!_e z7v#xUNEOr+3=?eBZ;qmbjqR!;3JysK)Rm2BkhGNFeFh8}QFDs0m@mb+{{S>obPaf|M9a;OVHb%`)r~)v z$m}Ho3cRMXS;|!20JftEP(zWX;KOq}_t=lreh2GhAp)-_+38Z#gJNZ^KV!br+^wsW zx1MsV0-M@fd209O%$nw9l@;d53}c97H{_xGn3nZ(EzW{Tzze@P;o)V&igV_7MZd-- zq+9dC-ss@NU=kKJXJ_M(1ymdLpbP8J7-QR@a=0TOTr) zW|7<@XXfMs_e|u@361k!eV_5KKrMy8F))@lNahGPMUmlvLdpS`k53%&G@- z6DFH~NH^p%>>6C#2e)rH7(Y5x%b;RTn|s~nu0$Ac!Qt~&LQC!93c=Q>euRt(yLHu> zHFPAF&JcdFM%{tuPoDh2kuq9tnu&bfid$I1TkYJM=U7K26nsTIb)O9`A-|EA7D`T| z6iUjg+Q^#nKm^Gpw_9_#{^iSyqh&MPzn%!q_TsZma$3D)`gu@J5vsp+;EKJGCz$=4 zUPI%j(TWHwfn-*{beNW5ccgtjK;5e7hk55cO7~xubgvLU@$kzHch=8er~`zINd~;E z=v!vegkrz_oNckRSo^g3G(ixb`EweVRKWH%F~z+AXV)B5cCA=@7LW{E%!~t)n!)cI-R8-nL?=N<_a0ojS|`tb7~jSTJ+h(2z#DP zszBH6AoFb4T8?{~wELeQz+%Wy1ko-uYFsbEG;XS^5Z0(jGTM8>mM|-4w)Y?Sjco?rE~^g@hpa3~fT+VMa5_gdtUM8IPAa>)J`7>&bz{0b8l0p8 z?I<)W8~xdQeO`-_uU{hWtVcX*>+*)=*(FmeECD3+x|#6FlS2j=o59taT4b}m*3V$S zx~cJ`I@^dQ5k`lrK`Wbt-qP~#WJuQxp(%|{cyqY5hI3H|)X=a+78KHmnU}nS8O8(S zFgaGvHDCfvxS-!#xH~<*#p(BDZS zW}~y@y!Xbod)-_~C?O{;(&wHySO01=U14f4bW~LKF48J#*5&jUuS5WI!a(`mJVWi* z5_@>MWp9acUpy=umfyHF{q?4tE_5H?O(uv(Z*ljW8S@Pi(4&~#QCMB!84h`^dlNTU z{eqn17E`eT<9KJfsxiCz0DsWLFMZ<12_$87AIK5PH`gD<`@mWFo@IQ5jL)k-km?3| zPF>r5a{x5F-qYq|AiDgIN7A4ZE;a*%I1(Mn-nkYx{7-or4b7V!>;sE@Alwk?mbYQx zvy2j7y52Q2h|Otxdr_s3+dR+&+JtU2FhI3({OA z!vZD4?&CDgqk+eTN`=S#ne@C2_a^`8ofwukjqlc2AMGECf3n78{;#8=UV~7RR-i1I z%_w4LcY@IpDOpb=!AmBmykIe3EeMgec2mQM8bu0BLkiR@&zlwv&>qKxH=k?#s>L9ONRU(U|vS}==cb~si(3|Jt}4z zAMn}=%tOSwdm^9S-E`Ln%A}f((6qVfpZ>ptJylsjZv6B}YkXdP>s;rB;VB;`MEN(b zjDsJ{Om3>I>t@i5Sr#0XECIFf$3V*HsCJ<4H&WrXPL;RnNsawvS<~6a@41y*mlQ>J zQ45t>4L9Loe(4^9J%}lm5zVkh>p1Tuu~u{ht%l7lXa+g~9Ustba>CJI_{IzzPKK7Hla6%94m(CHJ%+);u!bE8|hy{x_}l zPo4e$rzHO$A%jDHSax3NRm+oRMqov@!USvfOyb09)7iG8Fw>}-fXP5E1Oji=rtn-p zUU-Bf(dhqE`3O&PpZy=@A7WsY|6kt?wEqtFB2XL|mESQbnk?4j#XMV58?U26sYAG| z76(-6Xo3lFW>tBq{HE};bIvlXEauYZb0%k24Ls&HU9NxBz9h}ZPsMzsqNx}y5c86W zX0z&*T#a>_my*J4%7a6-DU@`Pwp+VYN=kAV{4jd2zf&Du8&ua)7H!iRWg15`4`MIb zYi1_DE$$?f6EzJRHp%96d*KxUprGhpUP+_NFL^|mEjOC5?5z6(Kh3%JYfnA*+B6^Q zKIqStH5K*0R#Er2v)+x13j!DR)+f7*swa7#FAIl{3eDA=fvNLj3SJ#0k1#h}U9_UA^BO@WAY+pV?9B&O! zXP~*zwKuP8_-wh)9uHAr8OOLm17$R=YEnlUC-0v9Z{b#JygYHZF>J<+M!C(Hq!` z``Ba2Fy(Dk3xd0jt}I9MZ#&`TlUo$t=g#-$eML?@8likvb!BF0__y#M1RBkq-appi zk)jz_+};Uq#$^{z$4@yIAy)H;tRzetbjX#B_wyI$su;%2m5Ftqng9{|rc;1ft|r8d z{=gQ^0%zyK`6g^B^6hPJeLhNE4d2zoB!`tCAKpxJlY{Uc!5IoLHSX(jvkUJt7KkGt;|9}1^NrkWy+Id-rQQ(XNp^f^VcE3F2P|ZziZH< zqS2kITqEa@=e_O5Y@)2{tpAcxAd>LBDlyxiG^_(vsQ^J(3%^~qk_C=e6xR3JYo&5j z#GQR`{w#e};zs(_=}9BkY`egH9};S>keHx#j-}NQsbxOQhn(|qjvrGFiC1xch|-|r zyB}J|lS#w8Zg`lfZgN!o^5ZR}hT=}30IadbcVqv3r!FU`dTdf39=VV9A}cuJCuZj#CCT+bOu0hoZyHsYG(hRd7Y69qf z&B-6Hp+cn7L1vgznAgU?Uv$)auWv|q-LlCgMp`{v&@k~YlpzWDuW_L4`f@PjY$HE# zIdHL5c(#?*pU9h(a@seSICFg=#Nn*~<8v#ptA(#iTcr`62L#FRdZyJ0G)+m;%+@&u z2ccWBlIqo6U&M0bT6ydn^@&pVaz*sYZJ5m=1VDp#Qu&PVYKN^Yk9|F%pzDeG!b?4* z$8~f(R8=Lh^UgW%SOyDB!;v)luH!1Lw(6hk5MxfKP3_UEo&tJQbRy zVIk;yJeyo1B8^<^wWeFSv+2Cu;%l=#Q|XOm=LFVM5}=1Z!@C-3mYVTG9aj=^*j zN#4$l_ffnF16NZOX}j-=v8kb)0P@l#Uxe=z*fbu9b&}L+#D-q+8$&0|0Aojdt&QE; zu9(z9b8q$#J#jb?H5`W(dq) z(5n59jc5vkb=Cv5qU%lDjiaIh@pzrg?>Nq*dvmG*9f^Ylgv=!ZSjZ4LLR3Il7kaoj z*-*!LFt8{d%ISTzC?0E3Eu2@{S;z?}lw3``T9PK6qGiZ*OkGm#TjxBJGc8cEx|tG_ zEsaBZKp2lSO29hI6jp72!DAk~&`64%##>BJTH4YhhW#+nDp)E;&>@rJowMUtlJ9+1 zY2Xe4v?MU1YcQ||4CP_18L{0Is^j#aev9zPjazStIxtToo8_T7BzI<-(;&Gy?!4mE zTa+%n9#(k0+yn*du=}hmG07?H-R|TbCj?!{cAeBG?r|Nz^5+hxxO-q~nLA>Y)1n7m zj~?UV&4EtLegxkDJ-x}Z4+1}mkd0i*jO*q#!arlabJp59Z%JU#g^7puux)=wm*X*Yz?&s1htQ7s4Q~W480Lj|& zVoPu8dXmnWL5M+S_L4P5a!jZ}=OPodhJ}XdSi?fQBujF3Kx5yW%pX$-uYr&%pRJ?O zOe|f>m!xYaZU5{*qrtPm+cf>qRr1F7cKI)2>}Xh48HI9Zt04L8VaS*8LFbc{RlDv) zS;|zJ`&6WKLnNoy^y7=$OVBd91g`SmK?o(yINX1G$31!)6tA?!jn4gtaFC!iUp>-s zUjP&1zQA;6I|-AV({?+)>~jNSezAr5DdDj9@u}IV>fJ;ha<9ad86&ecDIjW|KKV|2 z`Vtc>jeiS=WRsX^cXf~3=v+ELfzxtfd22&zOXt3sxb{Bxuy=fhoq-M}@DeN6s_^;0 zmQMna`x2tbZrj?_%6R8T4|K)1aMPj{aDg^a^Z}!0rBFWvWm+xl4KQl}r^H*Vbe^pD z&uuS41m<3Fnst6Oni<~dVb80;gg9qhGS~>x0E!C$XSag6%WF$^xpt&6|IiA2M~U0x zdR(YhZmn!%e5NY?8tg@x*nI;gL(Jy0NfpYi;a-Cu5u7~X9f!wq^;GzM%2CP)WGru3 zW%lhI@*}Rm&`p28TfDTCV zqEYU9hHmo9VFCdjGAph{K|GFSX{IL|2)% zX20<$Z08g?OvPs5gUw8@HNw5lhBK$xCuZc*j9vAv+ViTDSx78u!qSZG&v^|4~<5XkfzR-!N6g?`)O@yGizsx8H@{oH=mq&u;7z z>=F`f5x&_Xyq=$3xi1Njf~I|i=MQS@u3UYetaK$I<5#cLt7@3+Q{p7%==}W`t16-A z)fWES=A~P~uCj&r3nNp@O1%x8*+kXp6K%Twqn&3@6n((_vEYWJ6dwMUKZ(g!UcdRE zf7_0IhFy}?Y&nJ>>ks{LkzGUFDVI@iHsmF?^n|YEkKPI`eI+K*hvS4HgT$^Z&2v&B zF30O$<~i%nht3!-Ja<8PCP>82`}$kUYh<^U7d`B8CPT?Jragof?rh6GRf#Ffc$jB@ zS7^1WooSb*mo9QnX6?6`ytDxpWN3gA@>d5S$mslVY1f%s=%KJHUKJ-rrUa;wQ+`l& z^7y2ndZ`qgP% zmVWw6e|a6~tAa14$(-I%lEPm*airZ_oK!>S#DR&O!-p>hQI`^(`MK&zI(-8Ttxex2 z{Gn;>^H-=VvhL)#;>bgHyuQjV5UN?5(PTW9BexV_F`3cq)%N@EV2A}LI{~voQsA*t zAjh1O7nl3Kgw$N|;66J+?faf6g?!l!o5e-%^X%HiJ~}$5gM}%oPs`O%QcaC6>jhE| zvN@M|uI=%w399OR`d-WNw{DHw`J}1`F49RKlIze)+4rk@D<*veRE}@7Ycv zI!T24K1yOQu8Tr>%dFqTDhSuT8{VP|MyQuiU;_%@XOoQr3*GXIQ&)7Aya7l->fdgd z1sGx@;E~#Hu(s_vGcv*PF1+Yc#vQBtwM5;brzs-_YT{h8PaQe3dV|Y=dTn-qwLPXI zl7Yc&nBDSp;TXg2JDT#C-e9!2%{>JSDQFKF=l-HtSGmKT@#F_^WW#!&+!v|Dib(h2 zDjg&g5GCC?^1s1f`gXEHw{|CKbh79NhQQ~WA_|xDrB7Z)@R{qu=DkD|>o1tj2Untv ze$>8AN!lbN!XoBfoV`nT*S+-LV#ia=5_BTtT_{zx5i-D$NvliQ5Sto4jEAO)iLb8C z*w4hfGOEOYYj*{*W9`>{MWnCj2CLNuftq>W-#{|4(UB=7^+&M-pc`^I(6y+jYU+I2 zov-^T^g{G4vF74Y?)MxE_##M=+wQ37xO{r*dI<2ESiSZ$Mws%A{M0#;2dd z3?g?F*)Q02?rl}XER#uLUdodP9Bbp&isrnea2SI*f5h^+yCR>uO4-wlq%gvC=@KJAm zH%0D|pY|JLDqN;hn%HC$ZUgPJ|Esexx%#89;)R8vMY){MepK;U8q>`0J${w{Wu=0a z7ZlpBBY5JA*%^SOQC>ljq)Rfu7KS+Ef%-jSmmQyn&4aw65>hrdCOpUGl!(AdM!(oP zG9FRIE4?#~wZKLpf-?E_&-sIe4);Q!M=K5o(6!4juo5}Kp;QyTs4e5pF#XeAK)GG> zoZOm@JVrz@YkIBAGYEPBA!IWyaX+kL=d7zfa{zpZRGo6wEeB$XSJvlEP6M=%tUrF) z~&R=?ZT`nslQLS+8VgYLUQ8WjF#h=iX zX~co^2Gf?BcIIf_?WRw*+z=U-`t{2?0Pjc0)`85K$nZ4)T zhuPz|&?(UnOu5O~{|oz3Nx;N1nq|FO!5v)Wl`xu?nds&36~{;5bgW58LBgv!rt9Ze@*2y!v_)9iVB?NXDp}d5B^;Bl|7ZS5kLo5Lz$PVJJ_j50nydYLA)jye2~ZlfI$FK(?$1=%4p8} zQ8{IN8f%7O+`@Lalu8jdt22N?wG_LH=RO&n<5r~9%*v>!J+R>PAD9PLb6w`QQ1_3T zDQO~KhrE#JiS5Ot*a0o2nvOMUxw=_ZTQ~2QQE?)x<9M9JX2S2DdYZT=X@<3SyY+n= zlf^!I7jimrab0WYv)JI4OqT6Y+cbrf2>A6O%(b zl+La@{f*Sqb4|f8KKWS;*MWj|dc@1|)WOegML%V_er0CN4BIN@V-tNC{dEb|!7(=d zA{F4UH}OMO(kOq{t)W^8`sSY>=D3G!6c&x%oC!=rWMD4**Tu$|ULSk>U6RgA-)H}@ zd7Zwg{wP3G??NzudI0(JU*&%Rn*UxtJ_+#3{kQV**?<#qt*VEraPZDRXyXx5{z6Se z-+?|LA8G@DF^b|bYW4rfUH0gY0yI5P{By!R()fSlodc)_?)%sHU!>|0k27PZS{V@oS7>|;NHqhBN$pCXr zvM9Ri99Aus#6etu!A~yB6|*p0i|mcq)C~-BdLie?jRj3s&GaTN+JjTj5^>9B0%&{P zOqd;K$GSr#F8mLGo*vz8HVN&lEO4dYyv*=1wUZVbGlsR>N3uLf3STOSi_pAt5t1I} z%QNd?v-EbVS4O@-O#_mQn`vfn&@AO zstc$=iSXDm@%Z;>v3c#P2+G7>`F|at5&?nF6h)X~6iS(1OTeDxMH+)lLxi)npe=(> zceA_uiSl*i6BO3ypus;jOzdla7L+KG1nMgoDoMPS64s}HM|R=$)G3@}QUZ*_3*arnO8T#fTE9WSDW-VaLeNV#*ofqK`tquwa8)>cKA0 zbS#Z6g+&e3>`c|{y2n<_qjSp(;!SZbg#dOdit)exH|H3&6I35nX z=UPy=Bm{kEc*k=q{(eevNo%ffzq{}e({*_=}}l%D0@|< z;@IfuDEsKBV$O>GF}fYz-Iq|^f3~pPJ3JM-l9$`tJ3xYig(?t6UvY17(Mm_QOGtH7 zR#w8~2e!ZRSfPUEs~CuPuJTEOiiHIQx**S(rZAX~s=6wU+NFo>>Q%=OT*G7@{+jbJ zAhw4r+p~E&CNeflrShIj1TiF$L4Zy@v~1+gB1s+;#%Mv!5*2_9vnM;zs9pXmyXJ@$4 z(O-&+iY*vH#YG@Sv@5|Zi5icrsny&-6m&bH*dn_=Ze`x(Qa{NRY&zaW*2r_l$ZCm} zAv{TAmryGzk+cD$A5AcnjrN>UVPp=<KBjJJ|OV-zqvY7maA1?q*j}^mZqI`78#{$nPsHT>nmW}RZ zyPR*DO$q(IylwJfE$SJ2b~+vuKTe!pngYAR6Lgk}!ml)84-hStH#8lcR!k|&Pya%j z)SjUkWbb*}5i+t)lC|k>Bk>tx_?o&FJI>l628Me=m>g!af?~hrN>Fiv zga)C0b>c6Wo=d57sK^K}2x zCaosPCmzK9<=xq8{*jC=+p#o_W3uoLcj)*{(0a&1wlLoZffm_sqpD0EDeoN5Et)nb z0nEAUYp~M%gALo;%qv-(DIQD{N_XRA+S^jpEzm*T zm6VWFvGc>zJkOiLmh~8jTVP;(mVMp+fd=qx0VTVPr%2E&TvAU2{t4hKNjl$|Du#Hw zWmQUwK{7b03#6gS8a%V{iwM<&bE0kXP2wIeVUWp4jYhYkRf>a+xDfj07^zz&Rbtb z&`YQ>x1?Q@VMV?ngG!|(JUG;FuEd=%H9KM?ktMI5T-@GGBUy`e`KUuMJ$PyOS4e22 zac2ygupk)(tirsEVZ=o)(d0{%m>T^V{k5H)k;Fzvjag+AZv+K9jGLuW*xpPiW?2*; zufoH$@Fh7rCy4#Q)ouUyjCigF%xW`R!A{%wC$F&%FztDveEW?IBgt2`^&P#!CU{Kh zjb$CT+A=a3!d+rUs}ac`)r}?+7H;$_G0f)b!xDjS(^Z>uz})$<*tVySkUXhoqQtM5 z^zG3zXkm>4=$S}k2f78_^;QQ@d1M*I63>Z`(BpOhZGbS zmlYSo9-0monHd-g;l-sT^ttkR*`diTQi~m}8+P2WyFsH(H>2Z>`quTiAEoi_1yc-L zhb|z*_SPn$2&-B-{5o*E^b92q`-$5J_8J#gS7hYpy;i>f<$NA=h>Y9Z&F5#>DhDgf zhjrXXyxt)e%InqVk^l)ANp#WpYFxei(4U14hEeJUQF^~C41dd&Zr+po+pZ-z>E4(b z_i|-$0qqKgwTQi_3_|J;oc`1}Q||^cui*F?& zmZT~+>)CoBjbg4DrI8M#P*ba5SEoKsO&#Wu{}A)&$PT75>)j$CQty{}GL-;Aerm4bs6BG&4=vF_9>&U|796eoK>mwot zij<3QZ*O(5R^J@!eDVzuZ1+e{(xfgMih-rZ9ZC}COra4Uvolh9p4@t#AfbPvc?Q!J z@!ENb1NoqHb*PwCG_zeBmY&sm6C2v}SG0}DKX8M=|YPsvA+rq z{{A~{UBL37S0Q{$Sbu{ozt(KGvW|TbVJMKDIo-Z^cSHP^_%*4tbVG8A^+Hv~a#2Bl zQxChbAn3{MQ4nsG6-2r&RQ`){)hG6X52uzZaa7r#rmo$v61yx+P1UMRW0I4T8yk6* z7g;ru>08Pp`wC0omVd@WL9y+?7by?VD*Jy3>U@BMk~Ncc7*{6_uN<|km94B)jGe5l zb)1~e@9%Dm2UBMd?%3WBw1wk`jB%f0YMM0aDg9@-xz9+jM5tmzUDW6GB-SzYuErXX zoAC+j_Mt)U@qEC?I`|NrV$>e4yt&buRDZyXvn~9oV9GIjK^(;uyHL)9*&M~K7Fm!e zDK8c&huijry=XY8$!Pt_4*2U#U{m*Gg;XNfg-3V>8wY28w*Xt7{l$LofNA{OS80b# z96Rcg#(+@5E561eB8f>EpvJP493LOqUo4(Vl87^lGHw%d*fh=7;_u14`2JMe?NlDk zx#41aq4DASx}frX@H2xqjpw^p=FX8=S5f?_cXVL8mF3VE~7K%R~FR{}!Z z)=D~z0^K^6K{$mDo0}ivOa``jO`~ZoAefGEEZUo&=wvhWX6wlD0^FW|$EL({5*0sh z$XJqR?nQr->vB|#PV~tT&stl1p}}$Ix|<+WwSXm2lA1(ETSdf6QQg{3##X`;DL+pw zb&nxrdi3r4AV{JTqi*0;Y=pUmg_NXZB9o4hrR8j)65M*>dbFcBJw2y=uXLWRmoAHhKXZRcXi1~7>x!R{lC=R>R6=G3jk&%%k z)}9)!UWP_`irPjLb<8o{xzv6lBFdj{X;|K0$#@-IaV?v z+~BuydJh6y4?C+B6OxP%fXH~y#EI=IhM+~+B+fe#dv#RzA@}%={)ru;V;{o?lxqjec)RtriN8<@Y+p{u+WRo8tZR@3$wnQIY|qa{ZD4X;WdO zD56i%=RyP(0ZDZcO(7&LE&UemaA3W$7Avo=_N$nnZzJ2#{j$DVLFTKKkr!UZ57ro5 zRTY&9uxfYWH`I;q7zsmU0)tyTY!!|{cNvbu*IMp_spQ>Up=FMf&#O1=H->6er_`#P zB8nR|g@vg___)tR#MmqoI`#dxp4YAu5niKQ5eQcbV?pYe4VOP%l z;k76;Q}D5Y6H;lsFt!@LS#o4oBJ zR{YP=Z_4H!-X%8josxfYNXo0lPoAi@5t!rMcUV2GsLJV6E2PFtSi(RjX0yXJ%K2kC z^>?M3*VkdRCZEAd`~e?THX{o+G=hKr+ovb@Q@L!ySFUuS$&`5ZUq>Oem3ag==X=B- z^!;OE6ihT49MTcyS7X0-Kgt>*)TqrY8z>;ZHYRLcp_JD3!iu0*@rjA5$~_y;i_6gG z>TO11VSf424NIlo?PXLtRf%OcL4TL-OI$AxCGs?N_G!@MV;<$s#S@x~V$mM{@B-Kj>uQN6+&zXiZ+yH+% z6Avy6C;LKq8PCblz{M0u#i)FPkw3^*s*5+?>rZ_BaVArd4s@=4v-hsQW+z@2nwenT zr`P%`OQ;9)>By0#+WWH`ZU+~KWI3qahV{ZNWp+mXhTI-ZChzXlolxLIME-1P@Md8h3{TLU4C? zcXtTxPUAGeY200splK|)y9IZ*Ipn@m-<^7EeoWQy>r``OZCPtCd7iyd2)>@?{V`sv z!$X3`*-qQemh>CVRg%~cWUKMp3~O(Gb~|VKkRCTTREKMc`C_hp8G1B3syA4t;|Q&a zoi8abH&Nowyan_t8jr0|sYq7j>pQI~!{6rx$g#Se7%C8wf%0{B_U@jZH*emA2zjaM z>gb4VhJ8uuAJ)~@QUan}AM@#!K%t?f8mq@1i?d!vb!Pj8pf^;DlO2c5c87+iFW2im zD<~Q%NJ;fS={<;W?j6|5%v-egP5J&f3;VQ%BY~0et(N+5qer^cM#7AY&ghL{{3nWg zXGMeb40Rj#TZHQNO`zQ@b0zM>xjp+FtpP*`sI_p7f63;4%=C9L#g!}u)2Ws2+n#qz z)bSP4b!au>#D*>=pGx0Bpfs_N)_(5&670#*{h2lQLfU<^!oZCpoAcYkj7G;=Y?C|; zzy(9a=e)lQj;xqlF5L5R>-;3Cyuq?F7OZIK6j5f|`lnf)m%IJYcW@eo_VO6*G&U-F zh>@Z)P6oRRDXHKzndBD`P|(Dd9?|8^&1i|uD!lNDAyN|$Zop@8T^Qi=^1OT(C6nLX z0f#_~(R#IuogQ~d&rxcYEm$n`!re|$fqySWw^nHiIGlqmuWf_P#^KGRMnSk%qfHvK z!9B;k6=E}VBy!9LnN8q~O*`_;u8<-v8XpRU0!LRqJ?z!4iPEPuel($rAs5tpIDcG~r5zDZi{>s#zIYgjya za~0!5bjX!4P>R!5CCX5Fsoi@(FWaF`VJ_IYA(o7!MOQ6zPtBy0QzD}Q%1TxLNCmCp z2eIin%q}^fKvX{M44SL|GtKxD709(`zUFfgi2r)>mtBi)o6y z(AJ$I&7P{_bXq;OZA$_ehz`wn&Gp(hlj8J*FWkpN((&`Cr1Ssy2cZ+D5DuJc;#TJs zNWj4qKbj6d`R5WM;l9enq@{A7{@4@J+e?ukN~d-=z$f%Ka4gP^idJfqK`3ZZERBkO zo{xCws5ZyRVD8ZR09+tk<1GqeiBe7y>;7GkfCPPU{5d~2^$J3+H}iy}3(Cmu!-EM&bw0eXyx8)3ouw2@(&F3_=(MDTY>elm`XlEpUzsx(r;mScVK)#p5+9^H^4Z7N&ztWXgcUJ9KgZYfYIC8=*^6^S47l{R`oi6wtc3@NOcM`%3Qk3K= z-e;GJ7E1~v#3kPS+KOB^=%9an+TJHuV>F+>JSSX12322Z0;;FSUOP$#xS}I~b^f)@ z5r!esb!w>1=43jY@$_(iV?pV0(zEk&vIloJ&zdLpfPh%16nRGbwCXOt-tS?pvRtbk zU&z;6K>|^Zq5?A_)s?wAhzacQ#gKrcPm~}T6>r(?;_-%h1R^ah353X{6&19JOjtun z$;mZn=J6xEz2Qy{#Q7&Sb)k{o;;Gj?<_5MqmBrst#>dA81_p$3fQ9+4e-;_h zgHq5So)3*kRa|t5i&!|PD}RS=$Hr-9o@$2CxnyXYUNg6*)gZ4=p5dMX)DhW6o-YbS z6f!yR)y@Z=HIeOoI-&^gg5N?OKVxAJJsK!Dn+kgL6e|_E4(4lxFbY0md1yP?@7i&G zSuTbn;2q5D{3an{={Q>A2fJoQugws+dCUB_$Vsh)a~8!%{LVo!sv4k( z7|+VSpCV$>m$6X>yt`c1k1mRM+|KImj;6|{x+ETzAvMol=?8vqcVkW2FP2lqh?roh zLYnAH@k0Z?RUpYA_`uEpi(G^Ex*355o>;hBpcwO|7fcXLjyQH@l|a? zEh)@|mhiKMhK7NGfvPGFv}bwQ?`OapEbl4?%?)fZF);xFotAvP7H3+IU=K?vfwKu5 zO>xlF)YN=~&E9n7au_-}1_nmbcFO&=qQRkEHZKSJ$LK5)QI>r7xlE4{wo^!fF@shk zr2R+HTw=v|T5s{7ch5{dZEdF?1R~|KIaa?n^Ydr=oaXrUpph3}a^>>zR^FW3Jr;Ao_YdiS-%Ft7(QZD{AjK)0#o=LN=_f`Y11emou%m>>K%z(pj^}dw ztyr?mWxXs>H1dx2@G4RyLT^Wj<#56R2%Rs9ufz%VSL(JzB9^yAzROK+#b_D6Wb4fl zI}+R+56Eq!6t?Ycf zoww`cIGCq{e&-e?WhmZOSj8&*qo+6!f(J)wEyZN3O8(@h$bu-EWdrG%ben7V^D3 zEY!LJUGMmv=}TR$id!6fjv53qxQe`@M^0;-why+?zSlI!4tCRwfeLS=RB7XTDX4&~ z6AGaJK6jT-k5`?1ZacT^JoR8!M}V{CFVi^b?GP_~niL3u-0eR2X;Q5n2XJQZ~qptuu0H&hT0cG>XCynQ@5 zo5A7Z`THuf;yYAR8FCA;>;k+#C4v57+FygX3F$5m4UsU4zNCX~5wQpIb*$L{qr}+E z_G?bA%{1p7;d;)46iYuaV5~oIEgmVR$zBnIESQ|@Ws8!GDX8$dJI zxJEQ4LVCHg5}sQ23YVnQ!f6X6X+C%XUI@W6HS5G6MEwy|pM)oH%}WP5wd~En6Xw5IoNJ}>Cs4gtgP3%KV0VI^_kq2yU{J|H+6;S;`1tswq@>2= zqP?LN6~6d*KC7qT+%+=Rur*YIO%`haeEetm(Vez!l{o+@8cv>TMrzRaFujEuH}&-|y(Zdq2R zwS=F&tS8jDeKYjaEgq}o#4nXnVC1A?6g*yi;LD-FuBey*i-s%XCl)J{-jtbTcL3!M zgF@pMOGV1Yl9BRc<#Cc$nY%GBR?yOk+Gx?CR-$TZo9)`OoO+&E>q^ ze7)87A#g?{1Of!C>gvj>Dz(>NYsAFO?fPh}xQ_g_rk9-^V84e8-Gh_X*VYu()jtpr zWPrgvF&AW$;AC+5{mC{50jH_yW|fZ+P(}9c+CBrf{{nI#KF1x;MLPh&E)mT5cc(`8 z@tWSDUR|3keqy5bs+3(`937m7qnQbohY;xKVQOov$ER(sZEtUTQ0vd@HnJ57_$+F- z*$!n2@X*qx9iSW7WzsX!YSdd|Yiuz2-3(*{4Qa5Mf85i10?MtP_tz{?G9jVt$7JB7 z5J4a3gC)vZ#+g|4^R6hiP$|oj8Lbmq-rs|R?+_6`X1#`oS5;N5fr|mEnM~@MvDv&n zKNB|5hy~gVJzO?Rzz&EGb*d~NO&;mz3VZfz8y%wB)h;U8tg-q&x^i4B#;gAu@JxwQ zl$n&>;JLUV{e*G_4g2COz?1~L#-Ke*^2mv!UP+0hEC75J6BSVt69o+mVHpQu3sXlM z1yHsZHUC;uWS@$e8k(BgK-Zm}dZy>b-s<{6z1Nw4YM6RMa7hH{5hj`NGMKzbF8gq~ zv$LV$a{1}eO>qEM&->(fRis9?-ut7>Ytg&?>*lU>l*NgZD4umoB_%z|Y)~f!7gq{c z($$r72UshTA_`=XkdXM>kD{{$trn6jy3`)IT^~>9ry8VjQIs^x*Fp$_6#$IPk3O-H zKP!KB5(R(ycjWw(U4kf?jVOOG>6&5yu-Zw7n@{FlQ`uO$Is8e9V}sVU1{GcX=> z;j&Y6tE8K*w7d71M#sI|OU7{bm+X zm`=6qEi!UxrJ1RHjyWxzRgH?1eIi~+oR|U(tdLPr?>rTh zmA@Fg4`#x{+cA*KmQCZZnyU9Q{k+)+$1X_4#7*5*G7u+{)*F&rOS_!iRy=$7X=94l5M~#L5eLmQYjm& zFf)c8g)mWapgf{Y;YuM^Jij0Wmpv1)Qp4}RkL5t8@s zKLr?sB%a(b+A@MCIi%M=u<4nM2Z4=K0`3Du6U}1wImdWN`RRp9xsN1#h{mZ$EW>e3 z6V_{3FCW1zEiFMoK^#`o(z|2K9I|aG=y{U^gT7>=-Bg z$vqDZPRrR8by%2Vf64TfEIJVu_65rRnRMZ<>!Ta>RcfhfbD!KB4IO&e2zI2XAtE`o zWADLyqr^wymz^Eo|4c10a+B56q;$rpnF2lpatyc#pXs8|Pj4cr2&d0~~THJC8wR{#>X9%osV|+x4 z-#|RlRQA8vh@$!IcyPExE!{PDo1raf;?B4$JkH+NhOoJROBy@kkl_GQ$5fUD%P^LJR z@??58$N`>kCy-^*^LwaoX}SFghtRwLZ`#2k7>kmc+Al%>dnJNoQcA#zQjlYcNO9I02&Z!UaM%CG%$XhlrSz-SW1|lzbGKAFT45mI6M}?m9m4Dmc@*fs{Kh zYUiGYhDKacQnSsyIzB$WqQbV-^?*t)U0GLGHV4tGDDn&0W*BAcZZ{)1dK!d30_Gy7 zPqCqiF9#(vFvKy^#kmYx+&d-g6I2FFx>j=x5+$BKZA9L`WB>pqD~YiHTG3(7>Z{+7 z`_SJ?3ng>9gQvMsJtjL}@7Y&WV4O*Cfhu;lGR|w&#l>;E zw!}SH`@d}ZB_%-a_~;fMj1T+ugXCLyUan%+d~x>shN_PIvGYIJbrG8iOhoH5Luz)@ zgHGFad4c&QtDrCn1JlXK$Xr}pJe?N_?fm`wMNF*LiGmGtWgtJtyIMwL{q(+h+=I zoDgMa58^ylq?vdh8EhD@PWm1f*YUyjj1@zjPG0}V`pGHlcr8S%SGT!aXq$|GRnvFH zbG0I`dfrvch7V)KI6EtTj&$X0Bhe8th^ffOxK#x}9RS5$GIH{fOrG`KU3_e8(Vvng zCX_vNDrCQPDy$uJ*SC41Z^IYDAv^1~RA?>u(?hINf4?S-MyE33{|sL z$}1#ES3ct?uaguN=oQnXNx`QS7&+pDMw-p9*IMwaJLT5v*=n4M@{3$2l~tKfg4!N! zX&5TShiFWw7q6Xpe+khSZ2| z0&sTjC9!#xLs>(?)FHbg|ke9X(=`Wze_NyK`K_SLd2g-s_T zH_?1ZZ+hx4B4iWO=Xe7~a9J-CC9oOu4QFwfr|41(pMiyG%2+8k`$i-g_(@+39fYx# z56P~&RaOfIYLM@~_avyuh7&Lbg(=sae=5#ZM>U{YX=8iDypdTEP?b?Rg+Uv`UJaDRivsQr8440c$iPaUgGe1%6L>5idOvteNx9ooObQbn$fem zIN6%!Eo$uSB-RXU_wdZVx(Q=!K-}xeJj*S)fA!hXG(5Wfc%^(SJ61{#L1HA-iHX0( z?Iz%<$7vpQe~I1Y)?dSE%rH0Yqrv_Im<+v#5Gy_jhMRzN7sME>*2M3tuX}-}@e7YK zdKrtWf!6$=p>PHyUS3E1?Z)QZz{FySI&%61+A z;ztmZIK*hdk>{O^n^xRf+?_^ig~SjK2*#uwpd`xxKqLG?p9tzZrC$NWlSAHI5-YkX(tGr}SST+4lWgl-bRU`vg{75xCF+ z6EQPd(^oiB-M@Ze_-f)GJ&ODsK0~}?G~Mq^a)Rx|_iTzyGZ8H|cr5O|@tPVEYPQn( z^xaNEBWFR<+cgRIZUV;<@eSSSZ?aM#`@?fOuVTJxkKdLkDaK!!rb>gSHgzqdV6>+_ zo2@7cx6b2RfZ`qhiQXexS~u%)Pe9USrD_!b*0&-E034B|vxQNvl_EH?cTv$o=Rys$ z#Vk0eZu}x-c*5k1uez=>^#0CVfO!s3WOxq+d>}mh9-Ogp{o_+y;$9K&HagMQsdekz zE|Gi=_emBs9`!#+@_V0HD*c8930@&Z@;a|dwoXf5pgj|5K7%4|eDv5US$5x_Y`PWv zk&6F9fdhg}%o~%MK6)6qC~CS$OD`&%m1K`>61Aoot)kml__z-g1}hcXWU{W$C78UK zP2+iUV~e)XMJBS#lUeA#(euk@#qo7Ox8~skD?}x{zfIk{RXSB=q;&g4iLOa(Y}cfP1u1^xjYEEgeDj==|{CoUXcMjmYH8>V~2RVs0-9^vXXk@4ffgDRo;mrSYiz zghFEym(sPCClZ%orDo<$4%513ohtxq1#z0%Aj_+t!~mM}xOjD@YWJWKoH%XWmi_gJ z+Y_Uc9U#Q%(PD!U+N5;r5rW}47$#SR!&CLN71JTZZYMFzt@IWZ zM}-+98gb}2jPH?lX>gzwzK{>w_kZ##Dq`A4gKdsL727Rp;rn#PI3{C5H_;+B_>-*0 z0*?7yFwqNde|a+4)>~VwV=r7uP2As_1yWj+67Ske$(<}W#}}lul91*-&WHg8 zEtYzfV>Cdv^V!05HJOA<@GN zN|M3UPVGL{O=}yMrcnCXrZmaCAL2JR;m@=lqI_W$=J||YN}4ke zv@Iq*`$qBm@7n3Q)P~Vrs+%&m=n1blDrZhAUw{Y~-vtM6v*@b8TCvd6KKg1%gOaa55`F18(+ zZSLBbTj%DK_k?`uq`}UU%_>`HOJg=+{)P2SQ6)2A4VTKiSizYR$#j~&$8uQBuctQj$q$M*_dj|KxIS-Nd+xocSp zO8eO@Dk|E1BPvAUIG@ka5P^QthUdpb&p}5?&PuftpmzSF^kn&3HN9KHYg00jN4P_r z{ykFDD~R8lN}GhgXI?!bj1&l>Tfh14GBn4ObDw)^SinnBMd}r|Th~dbij~*-LD9Ii zHMk<@;~N^~>B`y5T7{xvPa@uTgo0}|i3BQF;~O7Q>99c?x$T&5=vqSHm5=P^YM1Hu zZ=hBUbDo}d9?Sp_)Bzbu$jVDfYzc?@DyX%qiyEYJ)dCNnV#~AaK?uA2&BYP)TS#7BIVV{X=09U;k7bl8nQibVRNyfg*~@N`_ADY4r>sw)(ZeE-Qwj3$n~ zUMTA~`etl&ZXHsdA{Q!~r40W`>C-wM{;Ukt<6uaU`rg;7ki!4UsIzj?T1I%mj^*QhMB! zd*wHP&~8+_9u2E!2m0 zg!Ffe?%gLRKa0H`RD2f4vyMQl_$GeD0y@tUSf6ZKZ^eVd$Y)87R-fr-`lxgk+X&!6 zl+^P54g|wC=PR(fX>eOzp3vt2qImsNc6;r&NRSS}wf~~mRjZfL(V>nG*cMJc&KA8@ zVV`mT>z8+*l$GTb5O)#|4-b>#-p8RTe6rLJxrG>L6TU>p(pl%}e6r&|gIsmL=y>v2 z@;-ulJB-cZ>Wm;oq|C>~-9vjcn3fDthD!Tsf%Y>q-w%D;-U^F|Rsh|}Dcq53H-#G@ z1$^$V`EcEPL^z7DuC|L5zh$wlCI&lZF8(4!g|086oh}!?~K;Xsm0T z>a_HmRFuDT43(&^vY93EXG)=J*!NlJpl&r0F}}f1ysl6AH6e%4=03VGIVnM)BeZcN zbP<%WudPis#=6_$sp|l3@cmf#>NX+>PnUc)p>&MIY>B zYGSH2hA{N}8R=JPSN+XDm}rCFYm=8?abxB?4W^k~N|pK}kDW}-P|tuBK-|VB?qi~0 z$AJ#M$ErI;`nIQ^8bC?|4o`x`l{a=aR(9H%Lv>Q-j_9VdX{NFTE2(ul^Fqhwq@vZn zxt4HxW_Wq(I>(cEw$`Byc26#jBlq}8*`ejTu{Xx=Wq7ra_I^aT>ySjU^2j_dDa*}i zh9)a7|Cv3v{Ob#QaJCzte&WI#|{OFy&e*0-!iHw?z}By!iLh=ELFjPx)@ zOKyuviLHuyxf(d5F*ECYlo7zjxu458?kchThlyqVTD1J4_41v;W{6B&8Ws5Na+q*o z;e6VE1)ee2b9bKx?S((;g)ZWbeac6X%~e?AVA*Qi{V!3G-luMBGb()i;4z< z?puT$(#;MTH%>B`-$?tfXu$EpX-gdo*N+4d4_iW4^N)W>NJYpR-JF$LJU#o{+gIXb zCArdsfnfcuMAX5f6PbZ!k0y0N3&f%+ZJp7;0Qc?Hyoqn?e;t;)&y`Y~|BB_XRWk>onb$V|$v>r`N$B{5K>0UK z{nKrY3+Tx6MO@q#3S4&3c_|w4sWgefizk-o$m0)1jCwgF7d5`y#Jk1UUR37^?kUQq z!w{PTqQ8VVgxm=ZTd1D_AV$sDF$+jp{U@= zmB!9Jfc&tpwJ5O2(<9f?()z?7=}FcM{$hLZcPID!;xdNo)9%p`ft@_ZN5z-PpvpkI z6TuS;w=yDG-7%iB3aHa`KMMuSPCt&cJo+^|+)f-qTW*Dy(e|p*cEzGIQut|@gl41B zw0{08u~o%6H)3lc_l>Aa6=r`o?>WeN(Go|U@6&I#I@k0J)2ZpG!N0S0W8*Ev;HTc9wiAkiYPSZfPY?r&FcN<g!&Ba{Q%K6HZv6BTnW+TUpNbFV#?M#nqW$+sZ(Uc zXu${E){VMPT5oF2ajh0oJ9ZH!Pty?t@J6yR#UmG;o|j0_11MI|LAz^iV%JU?gWB#(68E^+RgscHwxRkk6M7yKgF2_dq}%qbyIN=v@DscqYVyfITj+_-T@$B?6`cX3 zZ% z^A?FXLO!=x+OKvB3JT(0Rxmeps4jo8K7OYcqnxU)ZW z_ZwO<$}3v@KF-4Per(5awxOH^urL-BpN5~F3~^#wS_DDlHh#8osFihZGzn6t# zY5Jxs7h|qBHV7ka;1LU<_?4`NlM~<@;a`)j zV88vWiO{gMWy<|J{Cj3a1!I_$?mO$a#HqDcmB%u+B;`b&&ZB}#4!uqDMme;(_5L}Cj*ASkF5eiqIv#&XR*|YIw<`s|G7a?Y-5bo z{-oYNnY{&}{dE0_;`#oQb$`{Sa-*(Y=|e-PW8BTVZIa%JFtYZxzB_btcK$)#BZLdQ zM~)5~M^%-^W>u^0SDQzdZfIwL+0x7~{=l2<=(9gfb|~5Cg0zp#&mw6)3kFy~V6*_0 zd%k{_1eo=}kN=PB@6QeVpWpv;orLs!kK*%#@Bi!id7=X^6ctv)c>WjFYs24T1Aj70Xexw50C=Y^DG%%X1W_KA9 zCq!pP_(E|6TTg)KV`8?fPq3JtKetsfSQivSTfI%Pz++Y{H`0(O_W6U|X0zYj!lG9C zS2sJ}IqrgP;anT*re6`1*8`t`*FxLkBYcD@I>U}<&g_rvNyf&UB_iEq=tPXN&9+x!aJ#-M+0_uHkkarRxT{yeD6|~6zv!WBhq@OO+@*2#@5*l6COT?_>>u>9f=fSf zfGH2OA}&Hna{oVD3>_}^L=6q z7xpds-fkDeHkobeSHZwUM!6tYj_=uPomqCSY2PvTnw+{nFW;+)#O=Vl@Bc;7F27Tg z7H?ww{tFNL2{2R^X?PAJsWF`l|N8q6|NEG#GyB>z`)bFk*toRdwCG5q!asxs9Hpqb zQ|zZ#`6_wP;=`ZII01kQN{Rj1K|xj4)l+>&qmPD|3TI0u6iu6*WrBHP8#7f@O1sew z_cho1ns55YjeuyU0`<`RUY*9qHcKnn5rYHd^YOYUGHm_MLf-luK?dEvu`eDYF+*Z# zK%+uxi79*NbvtqVv;fswb~$3&eSw9r`yz=&GFVN23cECvlzRI7^;>y!fuDpJxiLDE z0pAh+b8iP=`88Z2JPxB9OJVzy+;H(QeTY-YEwF}KIc}#97uEQUP>$bC@6)@K4^12` zr8heA$R@-B^0kX^D!srkT;KT6OiA*r^#5f@{`>m>#i;zpc>FJ;@*kc3-}?K%booCP z>wkYJPdpfm_dhl${jc@2Qm4HSvoLf5Bz$iy{OP}>{3qG}M-%tIC4+i@5I)<43k($b z)zx9M!uIR`*y9_pltfnk;h`<;L&IUEYv9P+Y^*13KoG%DWnMl$A0Hn9u|=Tp)^E)E z&lVS)5<~0i>wo|L4J|Fr7WBCVpmlt1huQ#bF(CnC-x^?L+?;Nfs#PW@B)q!%C%HW6 zCjd(+gCAf3gHhQrH4Tcl^%H?)$GxXJ11_fIzmJUu;u_p!6G)&N@@u(7o|?~GL& z^-)QJM!eD^Te1~egsjY1pU1y{yW+8=v@|jz;#Jl+(N7FOVZN}iu&S~Wh*Xz55aW@W zTlpF*Swa%VzS!PpR{!TiwUVL$azas2QA`XWHxW#yCY#0UGWVk}H#fJSpx&@2A~pr7 zn<_d967~4?>9D`3ygA!x1^J(hz@x&#zlMinq^YjX&nG4(2D62pLP>mQYRyKL07@qj z?`z8&cW4}K(i*T&jhohSY5zvbqp>2vzo#?GA0_4FDvOK7#l`zaMjETCR<^f00Z8x3 zdLK~k<+5A4KT-6U{47gXj)I6NQ0l^a8|FA!=tHQ&==yh70fw=`B>#Cj{6m!m%8&uR1 z){qChA71@pCBL65pT~&08&~q|*ZKDjI~h3urLcJ=i^(byypP^Z;Oj&ISIrAfyYWD=@!C=jQzQQvfD+X zJAf8VyHTkOl3o)g9Lrs(!Pr;Ky>7f_sgj6z%alGsPUkO#^!mot(+}A5WOH+KvDM8E z=<3zgRW{SX_vq+AZw7uj9txERe&w8SX9%h9OD^Ew5rD$-lt5#w9Z}n7L@i zqGhxK@hGCOyJYA zv(F%89QxzS%Z8eo5-2o2F)>hobFC+mgbJHASeVh~@0H&ezBb`QQx`9TU+r!M9BQ9P zBoW^N;T@6ntxuV!Q&;~6BWxNII;fLxytNjup$S$~$yQQ8*ifC?Ruu>}JSmC)y)u2E z5m93RO`F}UcZ8KbCJ3QkA7-n|OM3}}z%Ftn1TSXrqz64!IsC?NJGrIfF{;l{`b#~D zA@K~~ke$i;8$V)OSXcmMyedR)n3!R`KBs6rpD}PLQ*IQM2 z6fHvX! z*a`WzfTfu-YBBNjs4v$0%!@Moh@A{m9P0!!y#VEzf-i1aJneexgwfI;$-qxvxR@p2=4BTH&lW$KM#7#C~U|`!9QVcn{5n!%`MMmm1 z*}uFzT53oBiA4DD0xI7^J4B%^hPTJtGP*rtf91cj#WQ@#idD=93>>CN^~l2`by!v`_#P-5?)mBcV&7y4Ij z%m0nl4XKeF4(1+AoqgkAKys`5)fnc3zb({Hqb+zaviRF1McfRP3 zpE;oKwTfw6c97QAJHSBE)6+NDF17#$12Df&*XywW=sKuzYinyg1*xFCv^G$9XJ_YN zZ?E9|Qr^?EwZ%)TwZC9!D4avZU~^MfF7mKoQ#&GD02dEWNmDo{j#jThxjTg^e1O(n zTh-~4NT5Z&C7}{E?ps6p1%Ow$6+?qfqnKj7XsvJX&@VK@!n^^Aet_xlk)FkI!e4Jy znBD$a;`&2NgH_>rJ|-&Y0sUCcK^2>6Mn_B7?EZ6@>jx#f;pSl$2|c)aSwX+!Ubnth zm3V0)w9)BX?aDT@6C`SG(yC$U@qGo}+)NjOF^1OwSKR812?O^4EzMx>04^&sO}ola z{mt=bWHmI7#Zn+B>gOWE2H+Edf`WkRe*XN^V!Ti$o!w%(LWh-=mCqH}(s3hf;s+<= zvlT@lA|g6Iip0&gRdjPRBk3=wtZXg167>>0JiE$h#>8Oi>rO8$a&d|8 zJQm*!ia`ycT^c>{c2p!cRqyc0XpZayd!cdXe+iX(v-U#9CvYP`vFU9-Tvei~c7@Z? z%e(C5lUWMU;XMKh?4%z#g#AjYY=5p5tkw^>L_UXf)Kk^gDK7uob_Nhb6|0n6(&o9Rnr%Q{0(_CSC273b7=to22W@{(|SU58?Gc!1CEP+1@3kh-Y_t@A( zManc;SdNbay{N1O)zBT$y|ZLBN|_-FNj)ZL(YA+{m#vJbxP_{;LZ*phg|6@#xG89! z_BR8?SPDHh%@JV2a{dHy8`0l{N^Sn|h#oXkfyxcorfSg7)mW?+qIgoFDMi9aPgXoo zG$A@ga%M@Y0ThSx0-DuUp@SvZq3j%awWr@n1-*1)A7S!H)VN8htseUde6^}B%b7iA zLee0kP9s?pA8FS|8MeqCEiO9&*9j)h@RGQNfSG;oi#<?`f z)|K=VA_~fzEOSfCr|!Az2^;v5DD#Cz*C&V9?YiYg4y4ULyk_i_}GCY7>V7r6G!+ zwT_J5|Qw^vpGTXuGF@ut4JuMYtpK1K?Zba*JsI=kgi=qKSIV@Y4d^wY@!4i2K|hVDfz z2`L!6)y*_z`ns1&)~~g3GBY@0G&VaQH$|#aQ_vh=^>=gNDtb{=YDq6oQ>B?P{na;j z-98j-7uelp%)^bCg%aK!(KOM_)7Ed?!9eCWjNEdfX&s#|js_Nv2I0rKH{VJAU}U;n z2W-Xh?Fur(N2YQ&|DgvQ>^M7l5HM2s`1nA62nh+P^Zqn4Iaw8OmjTaGMrLR`9SGsWPXt!@@#d+%1IUWKXP( zz|f9W@y)?WD|=gY%VcOCvH)6^_PP@?G-RgJt!hWq`zGN5fBRf|sBEEKM}!;3|W>c*(3L2iQG-#tva{Ni$* z@d@Uc@rP-NN}O(bJrni6JIg(~t5oIIRvx;sy6V_deg!n`!d7a`XeYrNa-7AFQszpd z&2B=CCB9YR5J+akT$$PAyex63bpY~~fW3V__>o2|*^F2{tU2jpb#+7(aM~eIyERZN{+3{FPNd8Y7?ch9qFm3n7opBb^+>=v!$;4{q)MhQhV zR+~_4B_BY;#we=$V58qR=uz{L7H`5dqtsp1ejhA4R040M;jS!7qFg0wTG;$rU_-5 zZ43?!mmYgJ>^77I@jzp zPU>r>o&qDk0D-JVlLxsE!b@bg5jo|Di+>`m9J~pfpLpyZJqeIRI{(^iX>7f|*?98j zB@k|WSN>{~eRO!38<#<|$Y6|=#+!|MQ4ebU$V)ZNauhD65N{_@JyJ{77~nl#mP8l3 zKCC+RXKwqWos*jt&1N+tL`2?k?8KitpX< zgNfkjf}Xr~fXj-Fq3_9N>s7ugBd$i9{r~`HcL=xV;LCo2YN6mtt8nKJ#U{Fk!?m4z z@un^pHiv*JOIi}2F(jl3B5~@g7B?Brz3~r+lDr)?g?JXg9ZxUuOQ&@7>1HotjSizU*WdD@{7!DG^G%g-MA3+uGY zYHpl$ZbxtwF|fTv^p3l0 zk{e;0qf1C{Y5PmmB&{jnyf1>&@i}oK;}Wm*R=ooc2RG5Wsb#~fr=&)PI?|lI$S7s= zPs*Ql%Rv76H46bMF|&)~#oO4+h=|%N6y;V=uy2PORW(nI zmFiM;gA-cpX|zn;8OlsvA*Y<^?82#4l=U6LTY>`!ci+^2*E&B04KJ zvqp_Gc`qj zc1IRMFPNU}9UnTo`hwi{&CP8KOA0f_l*h5~G`C2UcC%F1GcwmQFbr35HwX#UW=2l~ zS`1kVvc@{7HjbZ&I`D8KKgXMESg-pzgn<;~XH;uu5AO+1=eQl<%;A zjMf0x33OHY-Q~e|>{)DU+cD**IOiBZ_wm$#ui=lg6*Zgpytvke| ziOH5>2nV?Ya=N>sS9tk#1n~K-n=>=p7Pn?YAL}&}=T2MZ=KFPQ@-Nj67<7y+-KvP6 z(*k-7(i4;0Q_GxO95sy!t+^aCbG*^K4_Ra2R`_1j2DQVbkcXen{ct=Vg)-~i&pfG; zVBgjICv5fEspY-CZEkf&(CkXP4DDBoOSEq;*xaN#^HF^K-VN&<{sDEC@E8i0O~8dR z#7t<8idqu2n#u!|>1I&8Qg;g-11moBZ4hOz72ZN_4~af1GH%~-{%i42H7Q)` zrI*V;MhD=zK2jfGapP8eQ^eDyoLU_H9Mf-i0owPlOqnrC#6DcPfiZPnsTvEbgGIeV zUz2OFPMJ+1m15b0UmoV=BbN=EqjmJ+X#S|4E*az%Y!~`QlWTW+4 zi7+B*G?_51nqXeZC_hM)nE4-l-b`e*>%)9u4*~XqjInW!KNYfg9m;A3qGG)Rf_qUF zpwJoHy|RtsnG}hhb}H!3s7nJ+d!ZoHyDZoREITw_Q$~VeE`N$6tGp%4Mg|+)9v&`0 zSzx?NlN?+AR^(ZdO{pHt0^ye#pTtKmYFa6qT1O%GCEpevR8z_sS$XPX5*B96aAub_ z(zLBo(o!;})zwG0A2%et)2uW)CB#c7^VYiJMZf-EjJ*X=Tu~FK8QdjEfMCIcTY^h~ z-~<8;u1Sy(+@pLdAnioMd$4SS{YrG-!zV>%YS6seM@ z2G9LZml`|H+K-YMk%EosI*!nW>01j&>DH~Zc)YuItn8z<8vW8>5$W`Xsk%_q<%2U* z<1j}oVejGKcDeQ+_mmyc5Hh=?IZ-Xm=@qdb^7PfQHSe7p>Mu!>>De`=lHSihj;#7$ zg$u}O?C*aL9Qy^tYc|Zk=Nmv=AIZ-Hx~_OkrN8GxE!Pa)J8gXZ-Fk3LJ!`QpV~3`; zv|)>cr=(%SfubX;)&n>sp-e03(KSbXS4#m&v4+z=xd#@t*=xUl|IY2o4PQuLe2PP3 zzf(|c^h`;sn={g6AOPsTF6S!ADrldh2dEgL?B38)V!*&&|Ht-k*6iOx;hz)ni?d6L zM{Pi?Fw**uJZC`cTPH?}FZ*vC5f_BaTt0lC_lLb@Uu7H^a#4+qX$+ZnAOC$%Ayq5n zB*((Y3NR6+#l^rm$4yJ(4E3+9$EbP2^8TtinPvLU*96emgyz(!_UPye^p|PE= zjGmL5sxm&iZ!o^Up`m(+8?B{A0Mgag(6|Qto531-@dclvusqXpJj0hJ%_8vNi}9K4 z(!}4WOpiXLOnh0GB3nFxRMi)hXEo&)OXd|gUG?_`U=cQlsYMN5@*fVeZj_X>@BNP! z{DppBR}shvJU$>xX$2cPcsd^yrUeg)G_Eb-CNQv*L-6 zOAj#$cgxX#DA+6Xm4ysAM$pF0=?Dn!%O0v_D_&kD@ao4GfVR&P@*YcB;@-+?2U=$m=y;KATRm*ZB^}Z{J zt*xDbS~#*Ehq#ZzIQ|N^GF}#VYKH4+YM3cANxSs$=ss0~)h>&gC|U&nwpoGw}l)TppBbFltf z!9>fzdzDPGEi}e|ag7*deMEuC?*aoO)xrrT6YM{VnGaGZa1ajBO0;SROUI zEH9sfY=t4VytkbpQ!9xZZg#TLN+%)U_5(0CY)d!6cfxJ?rsz9|9N;r_)$`jf#5{*2 zoU-2!0*XxUyq`>bFGcuoKN*Nw+>1C_ac7lH>C&piH9 zkH8YpIDKJ$Tg0+@NBj0n=9~k4<>`IfRoxUsD>R8L^lxkQ) z!A?+F8}l^zi*X`#+ul}VV`ISCDVgI3wiMw}LPyxUKM1~q2&b#fqD&(HrYsEnN(NO% zZh}8W->j7JlUHx+7!-W7S+1%Xx&q%heBK|g-Gqnkw%p-h&kk$E9=d9MSyl=_am?2y zqwc5%4xd;(@VUu+XpUzDb?%Yco6AG;evUz=l>=jYw!U>+B8Wdmb3Lk7u)P-f6XNX~ zv8Q_)Ebkbh2su6gFZYr=LDp2lzZ-Z<+@xaxySSHRGJ4R&_lLI?(P!CN>=gyWf2)?? zWK4H$yM|RBhiD*kqBR2rlz`(CD-)Z-j*ZP=ggx7SCHs-{X-P@tdAS_s4HV}+I@WCP z0J_S&X=I9JNe7Kga$dHh)Od^C-v$ERc<^sgW|Y{*=F zY^7I!c5D+JFXiohd`yv6I3st@qY7~dD%Pwp+^1L6vc2=ip>K0je*{Gbc}k`R&EMq1T5otb{iK7nP_IuGdy)X?I~gV@c{kr;&@PI5%BwTEEUvo0;c5n|#g*zz!{{CI&b@rFS)8CwaLOl#A znRiUSbBtl6OVpR1uvd*4kiJ%w1b{*la=ZAsCCLmmO#X=r%^nz^whDc3h=(tbnS;eE zkl`K{4=*V?iS92YVRxji8gQX%ExKV-fXb4H(uymTxaRiP(keCEoqwdKO^zzt-fQUsS_}9=`5vDTfpbQb9$%MzyC-q?|ksb zG0QF}l-KckH8+{n%Aw#xKAbqb7uobExo%e4-=xU5u#KW}c}acASlw~8AfP0k{AaSflF|Jd zs$TgmER9AQDE5XPD_!p>RU9hW??!mc9A_*~XN)c+%BJ4zZ}xX}C2U~BnzxuAWy6Kb zu^Voc*SUjBS<7X@f5h&;{;0eViAlCuz;8YM^V1*D7K@5qcwprT_Mgjoc;E5kk>#TLm>wo;mjeRR5U2@|KuF4$6)@srS= zIEj)6FzB*CYwp&_9L9FJ1?Sg>=k*BYXKQK+Ei!*Wc6$*WeIfQR_2%{ML zcC;$b8XiHGYuxzbE4DR}^)ipkngbHad<)0#e;Nu!5>QfY*pHo))+CVHyAZ$#`NOQy z5Pm9gr%v`7(4rTCpLpy}H)1pP2U(~*4yhb}_Wu=bl#W8zJeP=j6vVXz|vmuRQI$AO5?}a26pWT zpr|@M-CywdixT_xHm5T4vw82>8DVLSH^^~%|8jI38J zv+WF2mQTopcNW25C}bQI^pc9I*Ir$)6RE>Zj0%$fsq!D#2 z`0P67cxRZ|9LwZSqb+q-_Mz?$nZK6?HyfR;_-z+D2Je++Cf-J&hxndw9x@vepnf!p z)xcoa;_Er^J4Z>j=b_+-$1c0V(gx%*)U|@kT%AP3F2~7js);AN+|wnwPnne*nvKbg z1I-bv8CHo!h(BDwwx&AGkLP(Zl>l%mBG$Nx3D9p)&*Gv9*02=M$7srOS3)YzfXFmK zsjL_9n`UaMhxlx9IfAtkN#N;LR~en2s_AbB1)jCo`gJme*$u{)IMx~OuEXUo!WsGrZKyXmAT-t1u?Cgfs@>a^o5hqx(Z-Awl%N==Ap-~ zimakyu9{bC{LJ0gv&OB4JXhlZDY&bt4XmUBWAW^Yrxhx_Z`>~Ar};j80(=chdcR)b zemJnZ88ZgN#V}z`%LSu`uh7}N%q&&b0vqOW>xhDVAe>ijH*BTvx@*axq?}{tXjCwe z!r8KJZFDPx$Pu|i$#a!;aP5POK1#lksFq@9l22k(`|QogxS&EZXu znRUdxL6dcQ_^8AjAK{F5J`zKV^z&FV!~rBhO-+rnY);ZbHH2$FHSI;IP@RBO)@b&lnfRQhviwBXEuI4<9Q8>x*ln@zG<%o9G zXNDI@O;Uf9-1j+H(!4z1X;RU$ZUjlnOOG=s zbN2WxtuUs1$UJ~oXNLwbTZJHGM|kxY!as%do7|AvzJLFo54^Cbh=q|6C>`^TgM-f# zC?jlP0e}&{EI{LxxbC5lZU$7u_B%?_qHh*z zW>I9u=$TGj3>0Hg))UW&G#K3&E+G@XdI$KQhzovdykUbY1)w8u?G|R-aEQxkW^*$I zNLgQBAE0db$~L#RMM-At0E6}HEI2q=PF}uM*znECNPjN%CY$w?23fT&byy@`qITwUmys#f3V zZ^-Z3#+P1%&u39!@y>-UeCo9_pHqo@94=?Q?9oUwxy>vJTHv31-TeV`_ zP=9k(yXw57hUWgpQ!yv<Nfw}Z>t{#8706sz;nLE)K@9k3)rpSq^tjj~=F3F8;`5;iSaB1#!N3*$pQu|@FzTrUXX~yw2mecja7=}jXlfq@AUVhb z&56=SIAY}&f!Kd$$e1NcjOm$q&ljm>cl$MI{Q*;?)U4yZUl-jj8)HC}U{$za1X>O@LB)lib$C7N7fJXHh|O>{I0xHap4GYp`)IVuV^EDT^3 z1~Bwf*%Do@SoWHv!h6HSAF^e{_zGY|=8sJ2e?IwnTLpL@vHnA&%&qcEX=nOTv8})U z=5CKw@M#KO`Ac8NgjV9>(;jOWPRECVYUQmomfLnyo9=ZVXVyhqGYh+kOY%PLR?2ww zJPtjkmiZ`tLbSPx#y6{!n3!^yFc*cgX}pD(h=RDXHnezTxA-GDF8Qp)#7|vxbutc% zT0xcqD#_K^QwA#uOBWGd3jg8G7AFlh5xb?jVCdWrRq?C5XkF<|fdzhu({x`e-g8bS z-q3`!FMI0Q4P%w-P1Du>=r3(5Dm(Kj+li+H-;g}7 zfHOjACS$~Hv##Ds;wSn~5AFi%%i?J996oeDD}gk8W2ii$KN7yO!FA!pq9^)#qBr_R zwynKKF`gz%Z+sX@aTU~b-N@M{k5R2>sg%|EaZZ?ol|Z0w5NnW0#q625i_;oZjQCG{ zJ*H%Gey{Yr(VQd(YZyM<3nOn4_z3?rg2gFP! z!(QsQ<=^wi!2M~V>(p{hh^ms)Ad;Wo_6J%!X0{$yL3Zd#O3?$FZ26*xu3d+(BHFW) z74f!6#Z}5?o}0rM*Mf0kiXiQ^Pi5kY0yW<^GKT&Aj zvSmu?CxJ1xoy2gr>Pp(=y(~f@21gm)G@`ag9E66JAnJ&;4vG%R7@vXI_RSU9ebS~} zL|wNY_?AUzrkF-o^?TmyT}%9UC4aRXzi1~3&dGgv;8uyg@9>=p8Pa0lF)a>Uk;9JX z*L-|tkqPB)zKMM?2h;nC>H*RFd*Lq!n#@qGf)SG&s-)W)gH#B!m)d9j!~Ki}vi)Ru__G2U zHjEsm@ncZ%(_?=EIyW;05e^P7B|EdA;1d-!U=5St71$J;+;oHeVRhUbop=`7n6Q3| ze7JsUJ|3=z?vO;DC0iy)CiA;0vg4(ba!$;l2zFXthl}dy`+5EGaq+)Q#f|1WT-LL$ zuASptTmiHlPQmDm!$~=Oj$FgOIBH3n1t&v^&D$q0?WHwp|JYnd$_OeMV3F&6;XECF zXk4zcul0*bo};q_xb0QxBu%Y}>Jm)>_XVEnm1KVViTwkg{g&>iR8dNp^mA+sNL^91 z^p_1G@p7V?sX)V~`5G)0eGkWM zl%c@zi92Dd9$&bO-|Dn1gsYDz)@d0POZL!g*F%TFp(?VWPp2gHeN^-e1670k*P~b1 zJflkGZ5tnD{gNYyf>$7a@5*8^-;O7g=yQAZjaLW3HT8HqELDg3z&xXkSd~1*p!Knr&rWxbf7#A+}$6Qfx9!N8BmXGgG z5oP(=-lu*nQf5IS2I94E+0iPWGs_HD1J#V~=uKSJhL;)Rrg3^X?hy z=(s{Gg#mI10>v%z>)$%1x_NC}MIY0s-q;)*@G}!BF+kst^0HTlBm~@;v8Z@szBG$i zRXa`_wc$iXAI4G|bkkMGkBQ^`$YgUDtyU<;kDDZXhE`|#B@1H4jcU?EIK$7+b2MXS zao;n_+cPwNU9~0Iti?T9U6Lr+z^bY*A9m>Cx|;Xps>kt4H>&m$^78Eaj;=99Zvucn zUQQaQA$F_!a0A(t-;i3hxXT(&RDBqN3@>RlDe+VM)`LJdw;IetqLz8v#9xUlz7Z1d zkk2kEWWI~h7q&ihn;o70__v3-lJ`3JQeN&#>Fp^yB*Ti0N17zDS1{rfBOG)^E*Pb% zOq3GIL``*CB2Iq zxzE#-zp=&a>icmE^qJJ+`ykIx1BZtPI{9vfxL|=r!E4F{wE)_j)8Qm;+F|8tdmF9c zvbVz{Z`}%h3bE-nM(KXIjwE#4?c6+JtaEWiQQF>Etv5FMt*n9;A=ZzaKG8NixYSR{ z)_6Q)yQlQ+>R=?>fseNEMxFguM;R~2O$T2mfWlM9FTw-i zL<=ZH#nRy}R`7_?bD(o{tk;pKZUtogo;-zBKUS#1h)Q*HsvLGVN$-MOB~OCb^r5Xn zHi9=R+f|NKbo@}w?v)`Q(d1Y)$IJsk;garqPtX zZv6(R*~`S)R#oFEgaNE{afG=4mjn%ig<#E~r)vO4cm3oO?a&QY)Aqyr{z5$9UcE*T zgfoyPm=LsC9PQb`6Sw>D03JmH*dK;r12PMGZ*r{E!ihktcfqE24l?|?C2-g$A;Eda zxh&LFXfN?}?T@3f)}zr0mOtM~2yN_;cC~I)o$nIpr%;hX`K*$A8#(S7BJ{m5<^KQ*}OO*1(nE@wYRi^dAZ|c=DLHi+566?+gp)(fBy_^-p47{d0uz_lop3sugCLRcF!ob zS0-kDuE@Dt8NIs*aT=e0`Uq95&NuuiI{|!*<%)ETMP0mHRvdGGIHf-leEofqx2bZ5 zZjnOvc|ttHV`pNQs&=aB6KmoWI9{Wz8)GetmS2*9=i}8tu z1m7@Kh#59KIF#^vr1)T5{m^Tmqfr*PCoO`8cJ9|6v)pI>J2_9bT zlreI!t=ChAM;iJE@^>58rVL15lxG^SD3Y4Gio6|?kk8T!26> zRFoUD-;a<`8Gf%6!r&HS5c=61L;?}c^F*qcb-%?*`uu`mlaL||z%GwO_Em%K(yTEJL_-j88gQBl0I zvU+v2`!ysxd|Hs>UYd{YLN78s>#JS?N zGSghGl|)*riKW{gg<+=RxdvzGpGXefV4m-`9Tsff8C6qf7XpQ4R;vXf))8>pV z`8GC)2723tKQdJXR5>_NF6lZfo`{gS?W_pN@i?)uF~AI;Cv#dQNs2?C8VnVGD3N$j zGBrdQ$<5*Tutsa?zdX$SAYzu%jwgmk4bL^ru0;vmD&LL%wZr51B$;elszNxM#eKbk z74?fZKBWO_TzIFT2uD#v0=qvpT8ps+>Q}slqmnbEP!E)7b-Ss09h*&&5w+mqaU6|) zI4|t`x-XeW0!_qsl9YFdfFP0XE%EPI8z+cOjwjOe<8=EgUXw_}lMX zO%<*&LtYp4yrz!hrRphLTbk*DqmC0aQAdCq-1HvC>v?iI{?VMo?UA*%BqMoJ4FYzZ zQB%R(k|>>^XI5632{Sa*y7Z=>V{8tJiP;fQ^#`_}2SYtYC!Z;-lx-J3sLSZ_Ce8di zy{fi%QcNg)8K-`XP>V<>v%}!Q#=e8|2b5AZLb(9&t7CKH ze<)ayrY51q&M`v9^^u85rzUJh6TadCH7MJOLC_t0sRWc!gVSlW86+z^Zkh~rk(Cnz z^8LS8yV{&ilI}>Wb4kdcfS=V8FMAx?QVW4QedQAFXI;9#eAa_-r&xKkJcU8ld{j`2 zfEI_Boa$*0Y$pP-z_n#%(U_B zL$-pUyRDM@oJhu}z{Y&B8s66CcM=C{SVhooir<>tKVe-Gq>gXiOb`2|cU_ZFoqP?L z)&Q?wxjhsnGuX#eY=;s4XKG@RH`mM}5Y$4qLR!{Va888WSS2=O>3;ny7|#xaZgV4F zc-CalClIlOo8!x7Xlwefq%3*;$>b#iT_K?ou`eevoE+~>@5d?65!(4FqB@G*`y&ec$b=1{cIOB|@25W|2oo5r(pL#-JC!%RfjPRC7q?}KdiH%b z&io5ix1NbiF;K9K!3mo2I`Ccapz}k*_|i?s5z7On_!=eV^&t^H=#GAiwDveQGA@U3 za8FyKqTd|vJeX~HBq3S@H!5-g;vS*oKt|BG@&Z-$O>$)5cSBvSlzlwh3a9h<`Zc_Y z;PcX@#&l&}xS~bKWqURkA0x&GQQLmpf)udHiM`>&AW%_XXr;TfSTpHc!z~Wt#f!Br6;3cW!Hk(>OK|+yQkWkgM)orkjbohaW z?gjq(a>7 zX)yg9OCOh>t8>Sx@xzQlzoe>1p{U5_3J{2FrhYWzKM-Oo$9o zM1R7#LI~#ikx-TL;DemQg-aFW;xE;jPPn(lIJ=yN>_Ur-ukS&g?km_o? zDtCTcQBI`Da5(?_k1O?$3HWx>;8$wUW?bwFMq{N{ znSp~4kcWWC%%aJWZ4y^a0pU<%sEUeOZy!sk2^3R@l`HhulHU5_j;HPh{ljU>+0 zn7Pp5vs)PCPsjGw`mX>Z5;NqlE_P)-EBW`+gG>mj>0&DttHU*vcj3qu#0h}NZLZJn z;n>15S?ZO+ep?VE)6E=^UvB@oJ?hZLE4;Mo@4YE;UtxFG5tyL{si0>2H?j3@+xohS zPk_+Z-Rci>^>+5#UsVjPSN!%}5LcFEd|;$P#UN$AmkE(>neKBAQ~LS9;-!ZOBa}fZ zY5t!3v+XxVg?aTBYvUURFWo?|Qx<(&ut-6@4|8lb=)LK{jNqn5tHD#}=|1!}_o<8X zb{cjcb=q%`^(2VmLrnfCxDwGl{CCl(ol8iyW@s3?{T2Q}njaA>8=PL=(HMntL*|?5O9o{XQvd4!w}QhdC_@ zZ;_F{O8=uxPR~IcUgo?8GghqS2$8Mz}6)pBRv=<5QU~hU7{QbjU4U)YR`h{?7iXncNN!Ty3_6=qHi`QxJ z<^C8IL9**-1MO>*cXP+@>vZXdcj= zZ;A3{{w}@JvQG{$TgEg&2^Y=GeVh(7-y2FAh$f4HU9RJFd<6ME2ODk&QYfJyA!Vjx z%WRk@*YK9!^w@N|=zCb;{6n$#87YqJ@l`U-cTSy~j20FY#a2Tn#bW-xu%VmsPb|;w=H`JtcKL2;>%Y*8k}sFjJNSgK zPr6?kyTv>`{T^$Lr&FRucNiNxB}}5W7L2FteOZx^h6W;;ZyzH<9_-FLohXy5N4zbg zN%sk8ju>Ba=O)>4F0Q6k*}s}^&|(Q*Q00HR=B9aeoJW@a%>Z|PkZ7vTnd@A|y5tSB zELw2{c+!1u|2x8&&!XaqZS_{mti#0#p2h^$@|KIV&Ro)9y%gUy&zHB6vyilhq6QD2 zW`%|DqJ@5>_`t!%w#WYLI0fjQjndhdv?Afu-2sOh_XSnV`lQhMywufiX_xy@(HZ9F zZUSLp4OT*ARMo9MYh|@c8{G;96@^wsr|Q~+c$DD3RMEjGk{~BEYs}xR;IFwmY>K06 zFa<3xJvomfdM76{WS>*0=#!0M-?2zh%==s=t-;}ff#G_`SN#YSuds$>p$|9zI*GgC z9)nKs2z?r=QB{N8w4&SvHzLpxs1btJx0vq-RLc`ISJ|muG-SFyd>#mj=#O>u#~7z& zjZa#GmzVVAf!)wJrj{6h4I!tn!=?ZO&~!r`m`D8?eMfVe%0*wFDxL< zLioMoUP^K9dVI2vQLwa|wI@7Dox^Y{wQd#%B~0|C$ezD}VW!uKD|;#4v~_U9735wo zp^CB@$2I~JBW?A7j1AJDny0yKU0WwOeCm+4x+&Dz&p(tmZ&X^6pbMUvKHRq)S}r|a zbPP`(N1QHsYV z5l?-M57ZkttB=lJo%hE}m8O*J>@SX1mWiJ!Et~AKYp7?n*;#mXh$4={i~t9C{^#m^ zmh>@VH70SB{fD-xo*u#s?-*Y*Z6TCU2dtC5&FRK~fI|D@rNw)fMnAu+)G1zps6%H9}8Op0OcW#bNtq^PS7hQ)dvcBX^2;8s$ZSZFiYt zx|9}3@f*!PA8aMICZlv&lXF-Ia@d?~Jw3icxevR#e!VfyFwpX!3(KMao)tSc|9(#X}O!L>&|gmP3Kc30~z*R9TqQ; zyR2UG-Q?{gUaq!QB6#}WHAuTOBD>Yy)By`}adB|6z?pJe1Ws5e;37zylEboe`NqrR zu6~gq-RX#IW+hSXzH1lk(Dwz(qV4>)TO{|>E$BpVSQxa$SZvhbpyL;#z zVZ?E+wFFLW|Eud-aKEzWxr_^=X=8BWF=wO6>hBC;oA!6C6w}tXJ%NP3+FwcChG9EL zVA^8sO^-9rb((&5E-hblW4|}O)-rHRda#VJ9a@qX4$#|$em`;cB(sEDMeSgQ-J}nD zJv>2$-Mn^>F9%&p+0aV#p6=Pxn_$svC5nqR=jkA^4=+AFV5EW`Y5wRr z=`xfjSE?FW=zcu58BYX(2s8XFxL-g7R&dE+Jk%jy&j6CSvVl5xgefRls8qbrl{ z4XJd2LQ>-kg6jQk?yGm03$wNl?+$!dkYO{=2#bCWyyrV-BS=l zR*mQ$;Iz1goKdW&2}HQgcurJP{EBJx%l6uLJ*O-&PQT3GJeo4XnU$X8t0?q34!9Plxi(d%rWMd!|o{C?;yc# zuiNFOS|H(|rJyFB5&iZ>ryw+mhdh%k(1I)n9_I@Kb9VBu%F?+dhm8I8?Ejb7wDk~dstb>G*T*WD;-)!mLG;eP_CnoJ^*#qr*hOZl zNg8MtKb|Nzug*vi)f=#ntLUvMFrnvP_|Jk`o8R7I~McBQT8Ge ziRd?{1RTzC^5-Kxk;#u;+R*pA7i!zkI@n>lCv4hP_gJhFcGU0yU7Ye*ZP>8SEBAW* z;u3)h8-|VMoNuQ)?oI6KnbZQ;44xazCw#gc%vqzvi#}i3&DrOb8H}?ZrjU+N0i+H& zf^1Q-pw88ej%aBJqO*5%N%{KXuv>Ad=!Zof2wtK+x`dD@etJL?GO(mm?XpN8*|&xz zTFuV~9^i8ky&tA)(qrN_Vc6PeZ}l^N|6)OQfbnOlo-%;)sN9yRn33u{Y^GBj(U5hX z?&f*zui}l?pBpT-PkKodu;?~X%&nz%D_$Q`ifNXaG|dR4@BQd^f@m={i+N(Cfl}*# zkB?7Io(|Y+h^LH}9I*qtsp{@5E^F~@^Smu??}2gQ2d2W>YP}iLi&!&p$8`Qy1 zFWT*BDoqm&ar_tq)g7h9Y|mY&v+6cJ`OnN>As75i!A^xD27LO>4ZH16ZD^I4aeaG= zhwc3lH&zZMx{nC9gU4Z0Gt+Nei1)O=e2<8Ae8Az-ye?1o^|M9m_})eG^>wPE1MRLh zXB7m0dw?e3P}Kb;1vbh1t|xJ4>tjII1cHP-JjSg>Wmuc{kJi_1&a9HJ$ie2<2_ebJ zpTpIZakgv@$MXg&#l8T^T471}Ix# z;MleZ0jp|#`I*`m2nIc!R;wyJP=vAvevb4aUR7t>i!SQn|GKL;i)|MK0%>^INif3>y&r`Wv%dqcZN$-M(|tNDBZ@pMi$x|ipVvtkC%AxrB+)CokbAPMQBksMZSlANeIPvbl+ zF92dz4ke-Q`rF#J$43?KSFbXCV+xWiyM}elYs1qZT9aiikMy=ijq{`O1%{~K!$bPa zy%!mV^%)_S333_?d4Mk^XPLzt|5eB@%Hiz|cFiVi(1kz&qQ5_(Q1PoVej9Cf0cqNE zeMZ!uWLpcB41ws1sHc6C^EhYe>aB1nLk4??yR%!q>W9=SVOxT6xz+MfN5!o6GhJ;%UmJkn;XGZWx=G%VBx91F3NO7l#a<@oX1s z03JJneSGoW`eBpRbTgJawn*uX-QL#Wv8dHu(Jk|Icp~5I;JE!=TaI;Pa6swu(stD2PX5?D2|u9CfywZIGkMiClwffy4~!0Z0kwDa=i5un=BRhh1$f`r}b`K}MV zksALs&e6X&!MSPtU9{J~s(rwrry1)%>0W2tJK!k4nVQE8@zD#|Gtxc@A;q4XHsBYKL7cDKmT{L{`>j)5dD9kNdJw@ z|K7y^6m|XIn)u&eq@OFzPL!}z0^_&&mzCa={(4%(DnKb!q2c#cy8X?CEZ=-LQ(T%= z$xDg#+)~{doBgf==2rpTT^BZvt;;w--<;dp=b5*ql+7GBy%dKAsrlxm-xB6oAxP1Z z4Jq{g1^-sPW~n_QSug>K%qDwq8hs#m3I@6Xi2-q8oF9ua-w%7Ad&H`skrR8CZ%FA& z7+dK{_Ql+9TN7o+i4GK(_7s*1sU_}{tOP*mfZiWw7pn47dO?4#G(R^Q_%`~uk|D)p zMG7Rp4-OSuBO=R@%~nfk-Y*|((nTemhL$1CgYpg6cDB3Y^RqeI{jgZzGL07-25?8H z0hb4-efv)PM+@VJx3WDLjr@ZAX~m85N<_8OX>h=#?Aoa8jy6t56@gGyRnnKPk4V2S zALB1vuS_ya+m8`h-WP;?ii3L;*PxuSe@k=gqQB)`)gMx8?G*YgGobtUQ*#~ftvHOT z9RJnOVuIbNi+)#gY<3iPo{xCie#fj?Pe-{Rdzx^(God2aw?TAhS){NATT4{TEA}@6 zXe7AxS3O{tS3_l@@6Arb2lc(5ZqB8EHtnc*e}6G&6)ERrc^hjS(jW*byRJJBAuK&fU#II9^bD-g6`g zkO;D=g62Ex{-pGb&H!QDy1GCZ^+%?%?H`?42)i2U2lHsH==@s;gZoWQ!mGqE05r!r zZb>WJ%d_#n6)j#)wtG1wONO8V7e+p)_$z=>NzSBHhmadcUeKFg@wK54<3Fx-e+fvK z9$Z$6e0{WMR{rAwaOG^d;C5C5IwJFkOPfaED-`bNUe4@Di>Iy2`Zd=nilyU; z%0?T7^rc32hPj-zt@SvUJX8uFZ8vvx4d=aC1o8YNa}8{=v=PE*a*eVi`(~r_W1eU< zNG2@{wlXsra78xUFXN?-{`Av?+zQ7u*9G~;(a~wn^^^sT-6xCsp!7A5KlR<(++pBJL71rItH)6xesR_GFJ_M4&-}wz{H=Ze+Rts@@R_wJ{ zp&DQ1>G}9S$?cMVUq0+ck(M@@M$PE0InPVm2JNMN(y5;S4^%A97pag?;Ba;~$I|kN z6d!nhTNC~&lk!cG&Sc`GlJ`7lrpG+yXj#|Y2-=vA^VCd@dd9g4@_6##CnTy}Lc{OI zNfrXMK=wKqSE?@t@B37QQQoDU^L(-g+)-Xaf`EFUWETzCZ#{ zHISmD>EQmcT_Ok+zLkCLVDI-q>}~g7R!TwV_{A8%vauUefptA#Q}m=NE6W#4`ak!{)h=iRZ5iVF(?B*wp4nL=Zhq0D4@& zCVQ;=&_y4)*WTjc)}~{G50K@Xpmktgqy53{rxGJ~(m14Y)c)MOCGDJmc9LKSmv8XG zxYBiVndi;67R-S!rMv84jF5k2HBTUFCKR~wx44b?NCMHm{sJ44w3sj?0U4{THDAsT zJg>#@lP5BuDR2jrFLuAV^u#sR-sJpc1Un%Y8|`JUr#n1NVQwga?dGIL3RDeuLR2@D zp`mmR8gih-?uB|UOKt;5sn)(mRAQE}iuVVj-I8LhN#ks3jPQWGD>?&@wEW}E3gOdt zByG_ydAMuY%sI}zB(E&*(F&n}_PLofFghy5dwKs+|GF=|WUlN9$jJX*K=qX`HcG+& zSERqlrfhFGMjkmstnZ&Mty7pv?cOP*= zBGS1sV39LR`f1_AKCB)-zg~1tE$1j%SBw_~RC;P+esAwAb_TQppmq6O9*9*Qt6jQZ znFm!SY6XQ-3OTIt;U*{7GGM!Z#NEjHpsBQZ!91l}ADJfLxs)Or1zN&sRepJ|8p9@B zSM4wj)bX+o1b?6aG(4d|-H*P2PpQ7pC;)9dfS7D{>noCjA&cD%gs4{GoBDJ}hQNMD zF{lXD{F$t(AU&SQCk%39N|yIXN> zUXEV=-KjxA64%$CPpW>64M}9BZ@B&FsgrN8_2+W$T^=nm!DUyhEt zK=ni9>~L*szVbF*UkM7zJ2}=dBHyZ>f zI%n&tGP6T|sSaScz8Ci5Vnh-SF7|}K^_^oBau(&91m~L?S03gczF!ACwBA14YoAmw!50P2M>N8KWCN20g z`VlZ#zZsv9L77Pa{BFKF^x=m2)TFccVafMCEp-#Y6&{$nBY{YDTuVva`BT%`a82>e zmd_a-t~dIevqXChP;Z*4B!YinHq)DxZ7Mj*_oI~3BMJA;kS1-+-7c!6*9_xdPr6q* zbh7X0;H|{7f-Vo2$VIg7VJb{?-1k9*B}a_7HxdfHB=wPe8E6IzBAd}!D~(t>d{mJT zPBLY8_j>R7x56TKOPSa9`~4;c>e1kr*a;xz(<+^j3#%j0X_X1|D^i#aby=jbb&dUP zu*Ejb2wmKIRg8$F7cTlXA-7+yS-wMZa|T`CdFO8XGq51qC-)!b#e*8|&9_Gp7dKTadv+&U~Hh~vNcb5h~flgi=RHEA%$53b>-j(wzUqo{dK=|M}C`d zJu@&+6EyHj@1`z+$*iXNe3r+|(s@0@@J-~M64q^M z%(i!woMEugYHZXKez04dUV7i$?^d%|Cx37Y<&LswUqrSp;yycYZlorpsUOQ9-|-Q8 z*uVf|4P>K+K`}f&%8G5A1rxlku-B|9=0%D4(8WtS;2K2Vg@}SFx4_L`Ev5&xrS;>dKJbk%6N&knY= z)TpRw;B2e>0t4Tp-=tOy9Rd#{gv8fZJQcSuvg9 zW=a$Blu6p~i80F5=)KWD#k2n*LTzo^Hyn>v3=%tJz)6^SU{`x!UweQC?{bXxx#bxf z@UJ}}_v?BMe4g3OH#U&P6%JMLi@A|89yk)KP+oKJs_y#S|IarE%K?M+#Ij*Bly`{v+oxi+MFh|wjdWwQRXt}{{-VSrMDwVIs_AC>cAA@ z6p;2j8=^YVKE>`C{Afdt6XRAw)zY3fretrFUp!s%CDUhQrFaHe7)HF<<#5oC^yRGZ(&r>IyH zF=+zLz&dAKuu2N4G-n*piQ#3J=J?2UGo6Snv3EXN|{aN{N45z^~8V#|ERsE>L5?dI%awy+vi6__2 zAFkA1#MnUQn03q-7C)!3O$t=Mi>PuEuYL6GV{v)SL&1XL`n*rpdS&Vl_q3)3E)O~P z(w}d<&#|!|wO;!+oia;2xm)vYc6`uoy}eDWXdC_9u^#X#_whZ>yt844Plf$zSHPhb zBj(9&GAJ1W1i+^9!Ajk1b`7Z|A-tiA@+!f*ZrM}D!Xb?I{kqt8S4Oh?vHQz_{<@`Fo9J_#^IXI!^@M~J;?Zcv~i zWmX$lrREebLYWSY^XDXpj<6e-3Rlj52x{1$j&}N9Q64bE)~ThRm1rVvJ~VaFG_$$$ zwOt2{JqoQYG(x!NKSXlCuL)@LRFr}%ns%8dTMdX(J1)P_t;VtH~dsVEzg6f>UFR&r+4jidF6p8HU!|s@D6Y^|visj0#hT`aRCb z2Q)^R|E?L8i1Mi{*cXgZ7}-N2<4QcT!-`;;H9wl(0FPj|Ds~vi6wovewt2<3i6>1i zoq>D@uRc`e80$Ct2dk{&R)4RtcJ_-qq@jkKHB(oC@6Iv|Tvoj>z?33~J<7%yD=uKK z6~C)?{YJ|yZD3*V%K6_BD~*FB&*90CG5?ylly^G^wq{xhT5uM(Q^b^^7sl`Ge3?tl zY!pLvig$DbRGRuY@Er<1ZxYNJF)%o|H5im8{F1J^8~TzVHo9-GdV8!SmTX!nSo-n& z#4(j7yVw8(Td6R<$uwZ$>+fpM&zCYBSgNO5ktZI*vovo4)J6xZAX9xDT4Y?mZNLe8 zqe_eO!~$;A`n8t(sO$Vmv?dZT*2?@mU=KhVfy%6HeJ?K57)$|$PGFUQmq>sUanT9b z)ni8%T)ZTIwT@}ZJ6tw(_Slo zAEX=@%lzKIoK%cWR;j<*myzEuo|L-C*cEW_t%Zba^b?I#F7@jO#Rxj7fcvZK)4M{3 zWx;AmO`;kTZ=e`}3d=~;dwRCZx?p8c^o$5#$kt*E@oM)ofzTpIv z;+B(z%vr*p@oyk;QvYYkvi zr;Us=%_A^-N6orgk{Rt0qz{`~GOL}@%zPXDvu(&bMkgGJRD?QaAxLnIlWqCA$gCSz z9r+GLvasppCimH+*kSt((DKAx7?Z%bD^e$GuQJbo=c&2i!Jf?$pUL$J#r+JyM;knSQ!Ad_7z(eWJuCr_+f>6ict^ z^7Zfba6*HwdO`w#BzO4SH%*{Ou8xD0ZyQLy_wnn7%*8*a@ZbI&54n&opUvH`T%24< zM=WkV3Ul#3w=4Oz<_FJ44eBwtf!kw^Ph%F}fiPmIrSXqqV?>iuO#`Hk9_I$1^tbS{5LH1>{>r=`MCwMgbhYZV-$oaU&E(sLFXSdSsU5p zBQ*E=sCbua-l(nIpF>bfs>$fE7KK4z&{U~b8s~B3kHb5&Ec}Ar>xxB}N6n_AmHHN? z0T4(A1YK2J6#i~CUwDke2b0G~W1eY!?9=n@6kukt-EZwVbbRW!X`l=h?TT%?hW9C% zVQDygB=+gG9*bEpVoC2B^PjrokL=3>u9Dfe`5PAGty*-oFY_CfoNU6vnYGVNWifgm zyP6N^0NEKP?Jw!N-#BfkVr%W6Kl}7uCWu}ka8P9w*pLhn+4|Q0P^^US{Jf{R|G7xJ z1JU0w@sJacQUjj_HR#wQ?(5X%6@U7uUG_X8s+A9acCA8cvMOx1 zLe=Wa(l+xPN*G5icv|tJLYdRiFnN|uv7*|({M|nIg;Ix{LbmCC#OA?iUA#zdEkKOM zdJK*<&mR7mz^J^>T-+btc%>P9V0Zkh;q&E1EagJ~$kHzX*X}30*wpR^8Q_wV5bczg4`;(0$vh$$JzY(#IY)M0$) zmQZB`blC|CSFc(PaM%SK`{%{Kji{<3TMc^3BzD@^JMf|`XrGpR9BW>}l^O4#`Ogub zH)iEx!!k4Pqm?glMFZ=M!Bn@*eSViX0eY-~0^fXf0HgwhYM7G-lSL=DLU^}dvxC$A zY1=9SV@Oa;2Ek<5wSd|^-%Q+Kb*+KKn}8`_cVHY5%hMu+YKxG@n#Uau+^?Ghh)|Nx zie-6P>dwjUFZpD3%3ow(S$8&2% z_wZwZeEweU13Ay9A4DiGOjz|%A6hYj2a#6wtu5iv;e7gd5Md1YR2=D^g21)~@T!`PZ zjn`;I*oIswm1owwrI=WRDCc*+v0-al&Ncfk@Yj+0paP5%gMqn6S+VRi%aMc#jYjEn zx6DaBIx&2wd8}g`jxf%~L|!iQ4G)Y|YYVE|k8f(-53Y&h1;zXK$HdwrcU~a*sJ>F2 zyQ|!UD%^(|^Hh>!nPMkI{UAs+x8)NVUrbT>y?6C)1THGPSm0Va;=kle|EjvUHIS_! zcTQSP226C6LbtSmp+G0#D=1j!f;;*2r?0P({pa%|qFd&+p58SMskS{edDYh*qzTx9 z8xzS=Qu&QUUOQ6nr`+G){S-6rW34TG1>~n=^{U ziZ}2V0gu+kUy5?G_-?tpI-01(*hM^h@lXXJ1+^6Fz~gby-(tg{u#&#m;(f~I0`|nShQ)z&;gX9d^6_?w60HQth90-|#uIo+lHLFB11|x*ku;0WX33 z;?ywksTaJBTT{xlTPAVvWva&_pKY>^x7H7mP1Sh7YbTDxrp-Z6tOe@lu!)Pw$AUOTJgLH9aNp6B~6ro_i`5Mzy;B z_6m%7NZ0ZB)gPxM6QnZG3)|JbT zEDXfOQ%i=>aQZFhr}57iWww;gi~PL$tdJB~>8zegDNIEf?35-g(wqNTiOi{17Z?oo zPTi18s}Y8a9}*Vn8_y{KoilDaOzt$U_;VlDj;r$2#r#Q*KgGNUYDUiLr#P9>)Wo9` z?f2Ckev`^g<`ite1~Lhwetkszlxa!7O7Un(wdgOL0FuDd*dGsB%FI@y6ui%<>IuCa&t_F3^==w*WHefv$6p~vv*2iSQXk@B z>6LL7NBkfmjsZ*GQ-GC*+n`V*TUK_CUgKY85-%#o9_zl!gnap$E$J{7;#5r;I1xrq z^2KOSG5k}D)p9BUQ;N-JNHV`qJA{QO>D;aX1cM=wj`iw`J+-*3Re}d)WHvi+-xg>& z((=TIKQ5$TnEf0twPOBfa&k#Jj00C@jX}kaQ%%gaSX1{BZ=2%}nfqodI)4t5vPsj&hYnZT+v;{M%s#vT`G^^I>w!H(TV;;fIGa)=eG`aWFB^?*U{B+ zADtE%PuL^ns~1hmUCb5l4q6j+UHD#&mjoQ9hdv+v>V3Y zFpHO1!cxGag3z$*k1ezbE7Xr-7;B|ym{mR3NRl->{nG z)teC^;stc4GDHqpOgP%S9*Arsx;ZKu9p6=wRKUEa7|~+c1>x1tHJ#HyB-TK!(=+w5dNsjbBAbUF1o=JL_Qvo8t0h)PoiAf4y|ggalZA zYp(Y=-D&uI@y%aHb!8XHtW2NmGxH)ie0(eOJF6O(Ugvt>Hl!ly_qTUgxTqRG1>A#h z&f)t>P5FlOc0F>xg|S4<-+hol5msleD9@cXXes2Wibq(-wnwGS-=Yd6m9^E2&7_d0 z?qnDx8J!}vrx|(Twf>^KmOX4%LsfNHJhe!Pv}jU%4y^95$?Qq&S(TcAIfDGrO_Sp5 zpJ1%Z2z+CWhD$hrxxc(@-S+O_p4du-?2wn5XI_%$k^Qb)&%LJR(f8~==D2#>+36eE zU7n7k3bG%0ewTp_Yvr0dILhU5glfA48;Z7~O7d|+y-(+wO%4SuSg(7X2a&t4dj zR;ig9dbc91NGY_feOaBh{iVgvM_?j%pj(RycXg$`BBG2-i!G)2*(l1;rTs#zR=Hm? z7)Oq`4a)JYfHGFW{ukY*UZy5ch>|d2;bwpW{GJnpZtR+}4w2*aHNqM{m_AOX&7B1+ z&>S{zw3|P!H7_)##u(yYTS7^k#+(tX&lh;v^=`BM2lhUfARJoBu@?6Sh}+T+N&%Bx zRk!7Y&0>1B(NWhsJ6?z3WaXFhJiCT@qVHCs5x+WyaLA<76a5a?Q|zw=(zx7-#}~h^ zzPlVRef!W|A9iG5u+zvMP&C;IHu zX44~%b{c!%JS{9TzUEsNmm`dUNJc=JQwh)Qe|M(8bSLGn)vUw~cH zO{Vxpo`BbZnaJ6xn{oxhq=tY|XhpxM8u?DMg~WB|f_wkZD| zkI{}7wM7tW<)~2a(4s9!`v(U8g=;nY860%t!21^zCpqn7^%DoJ=$Gwo2*MbK^|2&! zZr@(`()-YGI)Ut#tnpg|kK&3?Y07UO8J>B1#&0~nf19sM`{>pBX%D4K0Vx`6>X;Gm zsv1XMEFQ9Tf)OgiVD)2GMom5$`#4IQzECs5yz)MuvO`*FWVUDO!tLd0PYK3gwo#)) zRPKDBHB$Udg6l~>22EZiL{eu^J0@}CIRK;G)1t$eKsfQ0S3pAo_JA#9DZEg#^5&-q z+m6WfNlkv*ZW}B_Epga-U}RhOaa?MP z%|F$lCPR$t{PrZnlOTHAO!cs&> zD+1s$iiT}LDj!F_r4*7MvIQws7gyw}>!#EAP z-Qm;~8+sA86ghgrR{N90CtQW8dZt*W^qgS+Gjkf}9AMaPv0w^)_Vi$4AP?#S8Nvx( z`L3U|oK=oJBM9xo-#cmC3|GxaOioEl&Ji*FZFu)w0W}F%>LDL*0o$)W(J~yPER#{9 zTG2hNdTA z%`rah(cY`)k3M2ZzTX!z^mc9SzLxKbHRQ!$o+^+De7t9qfSpm8t|$*<5es~%=)>oY zG|bd9q0urCFoqA#+fnH$^iRzuUkJ-ety2i8+OxNYm28`>QpoY+$pPmy!m2a{V|A6t zwIDP+UdHEbgE0we1%4m6`$`Y+7)R>xyzJh z$XdxO&botZNHaViME+^527gceGNs(wcK%esYcxAbg+*(S2^-F<8a>>&h|?uV)ocPw zE>(VhDJ;a5G~%U|DU+KN$+)bK*uhermyYJyF+$p@CLzcu<14n60S!3-JT<#(Xxy|Y z(;bZ3FVj+}(29yemHkavn+pi)C(FKE9bW|*x{yxqsq<{pOK_koHd9|q((d_Nv_u*J zb(cr~oF_u~B@6d>s&#b&v^`=JtTvE}HPlnIC;!>rhA9;uh~Ju98i7WB-k zO$pZ&giR?nG^!SHC(OS~oA(0Gd?>Wplh^v>C_j`OR=%~sQ5kPYbg2OoG~({#m`h-N zi$7;9Trw?T^76{(S^5Ibn;pViJQCC~*>^3lsr3PP8}K#CgjCDCr=->|4V3YvJ;*QV zdO~WMhiej}yrvndlk~L~8bN0PHk~Mjusg?VYeExe=F7BUmfNNTzsCcPT&#S2q+(05 zJ>t6&3I_R;JH7H-ZZHae4JuEJ-sD$bFD*Y?4%&U7E#%e3(s(sSJPA_)0En>+m9r$!LVH` zi=pN5krGx=)WfVSf?)On_q%!f*ftO`yO&c-7!d(8`~drC|1L`4PxzNsqQo0?RnO@C zif1$8?sjFA)$yexup!X3eC!SF2vGmG&zD@q?Hsb7sERo3gh(FP!`c6duH)tFpRd8L zsAeDF1P(x)KESlwH>KQ|zLVQl{JY-g##%3Ftv@=UoEcL(d@s-tnFS4Vlb@&%A9&k8 zq!*`eu|B{X@Us+d<}>R>yCimedcdEgd&>Wk4DK6XKoY&ZD>v1`rt2GbtlGh>sy9Xfy8fjV&1mTO8G?)TCZdh(C?b2j z^`vMu+~Aroa}?~LI>WKWjO{ZyqTN3SH082Xj*2*KcwG$6?N24=gE6b9a>*{v)3Rvy zB4;@=fryfrM%%EdVLe|QS-L{@+VaZsFn{k)K2W(7%ZVW8+;IV`EZc|cgTbEB!6iRj ztyL>E-trINm)^&qi{R4Sb!TTsRxV@cS*6$D)==mE;qakHO1kSN%4V!?)JnZfx}}bP zHyhrEPsMB2Ok3Wdv%uOEvWRUT~un}{=`k}#Y*Q+$zlF<*0$`I9s&)A6~}WUVWZ zh*1IM{C|1fn8W%oHr{a#tdSCzl(d<6AzJsxI!t|rRglTzXf3{3^tLk1#kwyhLwv=Jq@IT8k=k{g+|(coo5Iyn)GM9lz&iRie;ITQJh`AH<@ z`Jj8(l~*#Ua{c_onXQbD6zp*2{DyawB?%?lxGBR^$G9oD)t1S?;mo;tn`Acx)UlI# zLH-9C$y|m7TSkede@ZQv2)jq<lK?|~J>x6`Xvgj-`eDTVg+S-^Vw17uN?0B@U?s>JXT(m; zdMlKGW+_)7IC}@N9PhU2B%N{8ql_-{W;}Dbf5dz~Qb2yYp~;?UEoEOFNK&HO+xZwx zdd2=cO&jPI3NA<PpApAK#UOpXLC4#H(`lw$!9NlIg zjcek_y~l=3URxe~USE&Y>EVZl>C~KI$I+_TPuVs^P3Zv`K=K8e@X+qt4TLw=q_LZ) zxwd{1XC!k5Aak7}mBhk4r=Ga@$+J%T;tg7|%;W%p`mN)j*sM2WneFFS@UvG0&Jf`) z@;hO0AR~`4$5$VXMvIY)5cu@mFRA1Qpb1kdD%%eM)$2+H1`I%EFeHy=5NFOzKy5o7FU=OT>s3m_BcGOWwOKdMo zHG`K!Vxnt)6p6a?4J@@iNz;JKtZD*rL>$AM&q&C*f17djoh-Uv^o;2$S+3l~9w7q0 zL}?e70%Xu^{;W3~Yn8N}qlSH3LH+98Vgx4#_v%Fyr>q2_r+#t1;``&rTBJ zuWatT?fYJe;?Ky;WrpM&?UZsEM0Wl`d9d=cqbilK*Z`_-@<}r6F(Rmtpr`@0$wExs zKhClh+EPHN1D*`OAS3F<oxem3V;3d0~zCf_@INeQl3b3fn2F7PIdXrX{_GQ3vi7ZlT-)`iiY7gXvq{e+4O7_^kOsrXVDW(c%;y1Z>x^H4zP0irWP<1KDeAw?Gl z5^4mpPw8XJCus)k1d!gsy$dk)xFk8vLrDL;(0TQ7&Po&NO37<4$ABNHiMaqPY_Rs| z%e3NuoRO|dgfYaM?9Ig&{B#&)(EjcZ?-IG$WT%{9xOB6LeilQib(RP*_9@ow^lu>; zxyV+#6UqIst$i@=v=Go-R;i#^(|J6~=u>sRFINvH9&KE$qcbkRaX#&JmCS!rvq?h$XYfcE34F)tF4f3_Fsdy z2OZ+Y2i!R;NUZcH-Ok22B_KcU3o-a07-bPt6ZzbtpP+CeSRvX$H}=rJP2X3(#8&dE z(iR>S^U98D4WTkN1;jcVbA>G=RN+mhR`HWd)Pav~)T4Z^>l+(Ux^lqq#KdVQ36~h* zAdG&})OiPS?=8f5<0+6wd3(&d@zC|x^BEp`)9x_UG;F7B312cHGp4@@XaZo$V@)s| zq9-XIqn)e@Op)XA5=-Ef0Vb7d@i!OM?TR~qiC#R8HPiuCXtG&o$LI`4Jr0SR$HMvg zJjORi4cCw&?!)lpw4N`faxlb9-;uv4^kcAhC^4*%1x&OW`4M!zp+_kV1S4)Dpta>n z{$&dOO$umB8-ciu;p+WH@?u?-i1w zq()HrrXvJE?PH4XM~FLa@BRDH0PbcwT3jUgj7$#E>4OeDqC0Adwh}Y+DR#^74&XC% zO#;N;%YUdbJ)SPf z`8qhp@qtVT909p2EUJZ_XiINnc}Gp0>Hc_x`h zdOh}cEW27Io}JL?-k?^G^U?O;+u!1zzn3<#O+PC8s1C>a)$3F>%tvyqHVQN$+tzH) z(G2o~Q5DR420#i0Lp)Yg3U=tP4p6hEpr9~JNO&8&0w)-fJih;-pEs1OqSJ=Y5ubY! z%Qf-^fc`c8h#ln_ahy@tq%6I{y8a}a3g?^gO^R5(=Pa+=%#xx&)FO;p!zrK^P?=}r z*pT{4LHYvET$y6#n~2n8>}}MBFV!JfU{T!>`2Bsm(JhAbQ@nR!>UpMz(!M_BZz2dn z(%uQ_h#hWy?#CAhGr^6ojef3YtNxE1|Lht0Ex+va2ygwzEH?y~^Bc}& z0d8A@GGbyXngAy3ic;x&KBou{`zN?aK*I+9PV z@`{SV*D`&O@VS9#YTehCC#948F8-KlT|n^S(}r)*_!7c)h9*ZBf(?p>9$}S4ri<;N zf~W6S&-1=8^S92!4mB+rTdq{@jqV)ofM=fgS>U6|S;)*UkdqMe2%&4@Y{&j?6}DoYYk^~dXw8*|auk1s8O{aOw0 z?G`=;1T^kl3QP-qEsaGbNu+_fcKV{QDDbe?AoC)&Qk9IaD%M~X+`=Vmn(6cWiw{d| zJod>c-Hu6xhJ|ctKn}}ts*F}_r%)M-h_`}fDb=2K@9|_!=3S#b?#v{bBCJ_l&lKY* z$AaZ6yN5N5DGk!)g5t)*4YNq*0@^m8c9Z1T>@yXHu$I zD$$ht!}<>?<}Uy*vnWUgLMo^^(op@miTAe>i*J>um@Ymp0y_@s{juPrnUw?bVH2lj z=e(%1gRAFUDSe$nb4}p$2)-XWKU>hnoEmctDmQnI4&-uky7-Fp#NvtyDSlzbv9E_@ z7fpun_vu zqUGzzl9XQd}{+a2#xq4md%! zmg=x`^z*53Vwm2U{Gc!nH8r9k^Qxtym6_n#&>9;l404S{|U4@Ia>sBN+|D5D9q zQ=QC{FoL~xQtF9^o(PRB1Tzo`x*O>IPV;!bh$;?2Q>!fCo8khR11f?p?s#`HjGZ&1 z)qFpNDLnVPn1igYU}8>Hxp~eLvS{_W)~xOjYIQsdc(<=tYwq$mAAnjmI(Q4A6{S57 zOHJm2m%Zdk4PbXjh=S1jm1xqI3i2VvLUb@Rqh6;Vo!L-QhHxubnOStW2wsu~{#~Sv zp;gX{0LoPyRA^pkbKF*JMU&Qz*vZ48%=7>gq!5uh+1gYMG6Z%wu_RmRCHSWQXp}8C zr_X)*F^!1!faCI9#KgFt&&82`7x{g~y}&8WejY z>0L~`J*l_PkkkwZ+!I#v?zuqGL%`*4p+v>0&&s!C1&8I6Zyq63OW;p3fmZPMacY+E zcBC&x5!LGoS$#)Q#ZdDvQDPbB+(bk(h2gL6sGG;@*V6Sd{A)Fvz=Lq!UQu5P*?!lv zx4BuS6I}DB(H`HP)~0aYhvh#s(vXMyV4sD6`O;^kvcc z!yoJGyS#$Du-EgNOaFEN&&W8|t?pU8Nm`DH>3*qiUSFqRX&pN}s-mcj6IM~ma$CxB zsjT`lEt48`?rdr2qr{dSbQMY3j0}L5OMMFrPU5-t30?aKZje<*-NgR^ znf>nw$$#Tf?_6QTTMuKY+7HdX7j*i&L}=IMi9WxxdY2`_zWBet7MYg)5`#%Y`{@M_3BN;`pWIadFolkx_m#5p z@gbh{J??u2T@3D=a0Kg#*v%J$-y56fc}kXViH z0g#Te*6jv+^N0n{xEGEeDbQo4Bqk0;fb95KccyRq2>4KGTWLy)WAo1VDoJC^6R{<& zo6ya_US>k-y9gBY`kyTBe}4XFS=RtwRe$_1|MdTt48H4j!NaBy`LHx4WS?3kFNwdy zV=~9X-5A}(dVj&d1}HXPr=1evk?L${IM@9qS+0th<%HH!2Lz&=TT`_Qt$y=7o%c5n z>lZ{$vkeW#1@u?1V)7yJ_;SC`_ZP$hinA8YP!%PNfbhRguLNu-3iw|r*&Wzys)qiT z+tvv%JtzO)9>TwG{4bRB-vOonD?aphohRHP=zkzk!XnG};oB{LgnB8-tI1W#ybbyv DEDcez From 58989f8568bb4ce03202b7adb534d219bb209f15 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 19:58:38 -0600 Subject: [PATCH 69/72] build: revert to navigation 2.5.3 I would have to duplicate the workaround for every fragment in the project. Easier to just roll back until it's fixed. --- app/src/main/java/org/oxycblt/auxio/MainFragment.kt | 6 ------ build.gradle | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 2163721de..c86037eb1 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -148,12 +148,6 @@ class MainFragment : } } - // Workaround for a bug where fast navigation ends up desynchronizing the current - // destination in the main navigation graph. - findNavController().apply { - findDestination(R.id.main_fragment)?.let { currentBackStackEntry?.destination = it } - } - // --- VIEWMODEL SETUP --- collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) collectImmediately(homeModel.showOuter.flow, ::handleShowOuter) diff --git a/build.gradle b/build.gradle index e5bc1dc66..8713d9839 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { kotlin_version = '1.9.0' - navigation_version = "2.7.0" + navigation_version = "2.5.3" hilt_version = '2.47' } From d297c10b0ad8f4e69742b0fe4eb48ea8e0f1174e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 17 Aug 2023 20:24:17 -0600 Subject: [PATCH 70/72] detail: fix crash on multi-artist navigation Caused by an unimplemented navigation branch in ArtistDetailFragment. --- .../org/oxycblt/auxio/detail/ArtistDetailFragment.kt | 12 ++++++++++-- .../auxio/music/decision/AddToPlaylistDialog.kt | 2 +- app/src/main/res/navigation/inner.xml | 3 +++ 3 files changed, 14 insertions(+), 3 deletions(-) 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 a611af6f4..b0bc09386 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -221,8 +221,16 @@ class ArtistDetailFragment : .navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid)) } } - is Show.SongArtistDecision, - is Show.AlbumArtistDecision, + is Show.SongArtistDecision -> { + logD("Navigating to artist choices for ${show.song}") + findNavController() + .navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.song.uid)) + } + is Show.AlbumArtistDecision -> { + logD("Navigating to artist choices for ${show.album}") + findNavController() + .navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.album.uid)) + } is Show.GenreDetails, is Show.PlaylistDetails -> { error("Unexpected show command $show") diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt index 57e2bdf7c..7e7aec2ac 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt @@ -76,7 +76,7 @@ class AddToPlaylistDialog : // --- VIEWMODEL SETUP --- pickerModel.setSongsToAdd(args.songUids) - collect(musicModel.playlistDecision.flow, ::handleDecision) + musicModel.playlistDecision.consume() collectImmediately(pickerModel.currentSongsToAdd, ::updatePendingSongs) collectImmediately(pickerModel.playlistAddChoices, ::updatePlaylistChoices) } diff --git a/app/src/main/res/navigation/inner.xml b/app/src/main/res/navigation/inner.xml index b1f834f95..a974b3360 100644 --- a/app/src/main/res/navigation/inner.xml +++ b/app/src/main/res/navigation/inner.xml @@ -263,6 +263,9 @@ + From 20c34fd888934562d49374327fcd5c49faef554c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 17 Aug 2023 20:39:05 -0600 Subject: [PATCH 71/72] music: fix crash on adding to new playlist Apparently dialog fragments do not change the state of the fragment it is overlaid on, resulting in it still having active StateFlow collectors that will intercept new playlist requests before AddToPlaylistDialog. Once again sharing StateFlows across views has bit me. In the future I may try to preserve the navigation idioms by not stacking NewPlaylistDialog on AddToPlaylistDialog and instead simply swap them out. I think this would also be better design too (It's not like I'm allowing other decision dialogs to be exitable back to their prior dialog). --- .../music/decision/AddToPlaylistDialog.kt | 32 ++++++------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt index 7e7aec2ac..51dcfcf6c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt @@ -32,10 +32,8 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.music.MusicViewModel -import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment -import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.navigateSafe @@ -93,26 +91,16 @@ class AddToPlaylistDialog : } override fun onNewPlaylist() { - musicModel.createPlaylist(songs = pickerModel.currentSongsToAdd.value ?: return) - } - - private fun handleDecision(decision: PlaylistDecision?) { - when (decision) { - is PlaylistDecision.Add -> { - logD("Navigated to playlist add dialog") - musicModel.playlistDecision.consume() - } - is PlaylistDecision.New -> { - logD("Navigating to new playlist dialog") - findNavController() - .navigateSafe( - AddToPlaylistDialogDirections.newPlaylist( - decision.songs.map { it.uid }.toTypedArray())) - } - is PlaylistDecision.Rename, - is PlaylistDecision.Delete -> error("Unexpected decision $decision") - null -> {} - } + // TODO: This is a temporary fix. Eventually I want to make this navigate away and + // instead have primary fragments launch navigation to the new playlist dialog. + // This should be better design (dialog layering is uh... probably not good) and + // preserves the existing navigation system. + // I could also roll some kind of new playlist textbox into the dialog, but that's + // a lot harder. + val songs = pickerModel.currentSongsToAdd.value ?: return + findNavController() + .navigateSafe( + AddToPlaylistDialogDirections.newPlaylist(songs.map { it.uid }.toTypedArray())) } private fun updatePendingSongs(songs: List?) { From d0b34a14e4f7a4c6c460c9e7d6e942c293f6c2c4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 17 Aug 2023 20:42:12 -0600 Subject: [PATCH 72/72] playback: fix broken item navigation Caused yet again by sharing StateFlows leading to a strange out-of-order collector notification, which then allows detail fragments to consume item navigation requests before the playback panel can even get them. SharedFlow doesn't help here, so we are just forced to move this to MainFragment which does not have this issue for some reason. --- .../java/org/oxycblt/auxio/MainFragment.kt | 21 ++++++++++++++++ .../auxio/playback/PlaybackPanelFragment.kt | 24 +------------------ 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index c86037eb1..297bad95c 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 kotlin.math.max import kotlin.math.min import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.detail.DetailViewModel +import org.oxycblt.auxio.detail.Show import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.Outer import org.oxycblt.auxio.list.ListViewModel @@ -49,6 +50,7 @@ import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior import org.oxycblt.auxio.ui.DialogAwareNavigationListener import org.oxycblt.auxio.ui.ViewBindingFragment +import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.coordinatorLayoutBehavior @@ -149,6 +151,11 @@ class MainFragment : } // --- VIEWMODEL SETUP --- + // This has to be done here instead of the playback panel to make sure that it's prioritized + // by StateFlow over any detail fragment. + // FIXME: This is a consequence of sharing events across several consumers. There has to be + // a better way of doing this. + collect(detailModel.toShow.flow, ::handleShow) collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) collectImmediately(homeModel.showOuter.flow, ::handleShowOuter) collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled) @@ -287,6 +294,20 @@ class MainFragment : return true } + private fun handleShow(show: Show?) { + when (show) { + is Show.SongAlbumDetails, + is Show.ArtistDetails, + is Show.AlbumDetails -> playbackModel.openMain() + is Show.SongDetails, + is Show.SongArtistDecision, + is Show.AlbumArtistDecision, + is Show.GenreDetails, + is Show.PlaylistDetails, + null -> {} + } + } + private fun handleShowOuter(outer: Outer?) { val directions = when (outer) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index b43b3330e..b7228d508 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -33,7 +33,6 @@ import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.detail.DetailViewModel -import org.oxycblt.auxio.detail.Show import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song @@ -41,7 +40,6 @@ import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.ui.ViewBindingFragment -import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.overrideOnOverflowMenuClick @@ -105,12 +103,7 @@ class PlaybackPanelFragment : // respective item. binding.playbackSong.apply { isSelected = true - setOnClickListener { - playbackModel.song.value?.let { - detailModel.showAlbum(it) - playbackModel.openMain() - } - } + setOnClickListener { playbackModel.song.value?.let(detailModel::showAlbum) } } binding.playbackArtist.apply { isSelected = true @@ -138,7 +131,6 @@ class PlaybackPanelFragment : collectImmediately(playbackModel.repeatMode, ::updateRepeat) collectImmediately(playbackModel.isPlaying, ::updatePlaying) collectImmediately(playbackModel.isShuffled, ::updateShuffled) - collect(detailModel.toShow.flow, ::handleShow) } override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) { @@ -220,20 +212,6 @@ class PlaybackPanelFragment : requireBinding().playbackShuffle.isActivated = isShuffled } - private fun handleShow(show: Show?) { - when (show) { - is Show.SongAlbumDetails, - is Show.ArtistDetails, - is Show.AlbumDetails -> playbackModel.openMain() - is Show.SongDetails, - is Show.SongArtistDecision, - is Show.AlbumArtistDecision, - is Show.GenreDetails, - is Show.PlaylistDetails, - null -> {} - } - } - private fun navigateToCurrentArtist() { playbackModel.song.value?.let(detailModel::showArtist) }