diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 55a72f76c..332ed552b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -264,6 +264,8 @@ android { testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) // WorkManager implementation(libs.androidx.work.runtime.ktx) diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/SortPreferenceManager.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/SortPreferenceManager.kt index 7535dbad0..82ea8fef8 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/SortPreferenceManager.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/SortPreferenceManager.kt @@ -4,6 +4,7 @@ import android.content.SharedPreferences import com.simplecityapps.shuttle.persistence.get import com.simplecityapps.shuttle.persistence.put import com.simplecityapps.shuttle.sorting.AlbumSortOrder +import com.simplecityapps.shuttle.sorting.GenreSortOrder import com.simplecityapps.shuttle.sorting.SongSortOrder import timber.log.Timber @@ -32,4 +33,17 @@ class SortPreferenceManager(private val sharedPreferences: SharedPreferences) { AlbumSortOrder.AlbumName } } + + var sortOrderGenreList: GenreSortOrder + set(value) { + sharedPreferences.put("sort_order_genre_list", value.name) + } + get() { + return try { + GenreSortOrder.valueOf(sharedPreferences.get("sort_order_genre_list", GenreSortOrder.Name.name)) + } catch (e: IllegalArgumentException) { + Timber.e(e, "Failed to retrieve sort order") + GenreSortOrder.Name + } + } } diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreList.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreList.kt index a6930cd16..97a9bbbce 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreList.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreList.kt @@ -16,12 +16,14 @@ import androidx.compose.ui.unit.dp import com.simplecityapps.mediaprovider.Progress import com.simplecityapps.shuttle.model.Genre import com.simplecityapps.shuttle.model.Playlist +import com.simplecityapps.shuttle.sorting.GenreSortOrder import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData @Composable fun GenreList( viewState: GenreListViewModel.ViewState, playlists: List, + setToolbarMenu: (sortOrder: GenreSortOrder) -> Unit, setLoadingState: (GenreListFragment.LoadingState) -> Unit, setLoadingProgress: (progress: Progress?) -> Unit, onSelectGenre: (genre: Genre) -> Unit, @@ -37,6 +39,7 @@ fun GenreList( is GenreListViewModel.ViewState.Scanning -> { setLoadingState(GenreListFragment.LoadingState.Scanning) setLoadingProgress(viewState.progress) + setToolbarMenu(viewState.sortOrder) } is GenreListViewModel.ViewState.Loading -> { @@ -50,6 +53,8 @@ fun GenreList( setLoadingState(GenreListFragment.LoadingState.None) } + setToolbarMenu(viewState.sortOrder) + GenreList( genres = viewState.genres, playlists = playlists, diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragment.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragment.kt index 3cd486b28..0cf61c5bd 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragment.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragment.kt @@ -2,6 +2,9 @@ package com.simplecityapps.shuttle.ui.screens.library.genres import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Toast @@ -16,11 +19,13 @@ import com.simplecityapps.mediaprovider.Progress import com.simplecityapps.shuttle.R import com.simplecityapps.shuttle.model.Genre import com.simplecityapps.shuttle.model.Song +import com.simplecityapps.shuttle.sorting.GenreSortOrder import com.simplecityapps.shuttle.ui.common.autoCleared import com.simplecityapps.shuttle.ui.common.dialog.TagEditorAlertDialog import com.simplecityapps.shuttle.ui.common.error.userDescription import com.simplecityapps.shuttle.ui.common.view.CircularLoadingView import com.simplecityapps.shuttle.ui.common.view.HorizontalLoadingView +import com.simplecityapps.shuttle.ui.common.view.findToolbarHost import com.simplecityapps.shuttle.ui.screens.library.genres.detail.GenreDetailFragmentArgs import com.simplecityapps.shuttle.ui.screens.playlistmenu.CreatePlaylistDialogFragment import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData @@ -48,6 +53,12 @@ class GenreListFragment : // Lifecycle + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setHasOptionsMenu(true) + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -82,6 +93,9 @@ class GenreListFragment : ) { GenreList( viewState = viewState, + setToolbarMenu = { sortOrder -> + updateToolbarMenu(sortOrder) + }, playlists = playlistMenuPresenter.playlists, setLoadingState = { setLoadingState(it) @@ -135,12 +149,47 @@ class GenreListFragment : } } + override fun onCreateOptionsMenu( + menu: Menu, + inflater: MenuInflater + ) { + super.onCreateOptionsMenu(menu, inflater) + + inflater.inflate(R.menu.menu_genre_list, menu) + } + override fun onDestroyView() { playlistMenuPresenter.unbindView() super.onDestroyView() } + // Toolbar item selection + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + R.id.sortGenreName -> { + viewModel.setSortOrder(GenreSortOrder.Name) + true + } + R.id.sortSongCount -> { + viewModel.setSortOrder(GenreSortOrder.SongCount) + true + } + else -> false + } + + private fun updateToolbarMenu(sortOrder: GenreSortOrder) { + findToolbarHost()?.toolbar?.menu?.let { menu -> + when (sortOrder) { + GenreSortOrder.Name -> menu.findItem(R.id.sortGenreName)?.isChecked = true + GenreSortOrder.SongCount -> menu.findItem(R.id.sortSongCount)?.isChecked = true + else -> { + // Nothing to do + } + } + } + } + fun onAddedToQueue(genre: Genre) { Toast.makeText(context, Phrase.from(requireContext(), R.string.queue_item_added).put("item_name", genre.name).format(), Toast.LENGTH_SHORT).show() } diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListViewModel.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListViewModel.kt index edca4ddd4..eff9f57e7 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListViewModel.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListViewModel.kt @@ -8,6 +8,7 @@ import com.simplecityapps.mediaprovider.Progress import com.simplecityapps.mediaprovider.SongImportState import com.simplecityapps.mediaprovider.repository.genres.GenreQuery import com.simplecityapps.mediaprovider.repository.genres.GenreRepository +import com.simplecityapps.mediaprovider.repository.genres.comparator import com.simplecityapps.mediaprovider.repository.songs.SongRepository import com.simplecityapps.playback.PlaybackManager import com.simplecityapps.playback.queue.QueueManager @@ -15,8 +16,11 @@ import com.simplecityapps.shuttle.model.Genre import com.simplecityapps.shuttle.model.Song import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager import com.simplecityapps.shuttle.query.SongQuery +import com.simplecityapps.shuttle.sorting.GenreSortOrder +import com.simplecityapps.shuttle.ui.screens.library.SortPreferenceManager import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine @@ -24,6 +28,8 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber @OpenForTesting @HiltViewModel @@ -32,6 +38,7 @@ class GenreListViewModel @Inject constructor( private val songRepository: SongRepository, private val playbackManager: PlaybackManager, private val queueManager: QueueManager, + private val sortPreferenceManager: SortPreferenceManager, preferenceManager: GeneralPreferenceManager, mediaImportObserver: MediaImportObserver ) : ViewModel() { @@ -40,13 +47,13 @@ class GenreListViewModel @Inject constructor( init { combine( - genreRepository.getGenres(GenreQuery.All()), + genreRepository.getGenres(GenreQuery.All(sortOrder = sortPreferenceManager.sortOrderGenreList)), mediaImportObserver.songImportState ) { genres, songImportState -> if (songImportState is SongImportState.ImportProgress) { - _viewState.emit(ViewState.Scanning(songImportState.progress)) + _viewState.emit(ViewState.Scanning(songImportState.progress, sortPreferenceManager.sortOrderGenreList)) } else { - _viewState.emit(ViewState.Ready(genres)) + _viewState.emit(ViewState.Ready(genres, sortPreferenceManager.sortOrderGenreList)) } } .onStart { @@ -106,13 +113,31 @@ class GenreListViewModel @Inject constructor( } } + fun setSortOrder(sortOrder: GenreSortOrder) { + if (sortPreferenceManager.sortOrderGenreList == sortOrder) return + + Timber.i("Updating sort order: $sortOrder") + viewModelScope.launch { + withContext(Dispatchers.IO) { + sortPreferenceManager.sortOrderGenreList = sortOrder + } + when (val state = _viewState.value) { + is ViewState.Scanning -> _viewState.emit(ViewState.Scanning(state.progress, sortOrder)) + is ViewState.Ready -> _viewState.emit(ViewState.Ready(state.genres.sortedWith(sortOrder.comparator), sortOrder)) + else -> { + // View is not created + } + } + } + } + private suspend fun getSongsForGenreOrEmpty(genre: Genre) = genreRepository.getSongsForGenre(genre.name, SongQuery.All()) .firstOrNull() .orEmpty() sealed class ViewState { - data class Scanning(val progress: Progress?) : ViewState() data object Loading : ViewState() - data class Ready(val genres: List) : ViewState() + data class Scanning(val progress: Progress?, val sortOrder: GenreSortOrder) : ViewState() + data class Ready(val genres: List, val sortOrder: GenreSortOrder) : ViewState() } } diff --git a/android/app/src/main/res/menu/menu_genre_list.xml b/android/app/src/main/res/menu/menu_genre_list.xml new file mode 100644 index 000000000..3c655748c --- /dev/null +++ b/android/app/src/main/res/menu/menu_genre_list.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values-de/strings_menu.xml b/android/app/src/main/res/values-de/strings_menu.xml index ea1c77b9b..701bb1b09 100644 --- a/android/app/src/main/res/values-de/strings_menu.xml +++ b/android/app/src/main/res/values-de/strings_menu.xml @@ -46,6 +46,10 @@ Song-Name Jahr + + Name + + "Liedanzahl" Dauer diff --git a/android/app/src/main/res/values-en-rGB/strings_menu.xml b/android/app/src/main/res/values-en-rGB/strings_menu.xml index 95873c821..0f1811540 100644 --- a/android/app/src/main/res/values-en-rGB/strings_menu.xml +++ b/android/app/src/main/res/values-en-rGB/strings_menu.xml @@ -46,6 +46,10 @@ Song Name Year + + Name + + Song Count Duration diff --git a/android/app/src/main/res/values-es-rES/strings_menu.xml b/android/app/src/main/res/values-es-rES/strings_menu.xml index 558d26791..002750aef 100644 --- a/android/app/src/main/res/values-es-rES/strings_menu.xml +++ b/android/app/src/main/res/values-es-rES/strings_menu.xml @@ -46,6 +46,10 @@ Nombre de canción Año + + Nombre + + Número de canciones Duración diff --git a/android/app/src/main/res/values-es/strings_menu.xml b/android/app/src/main/res/values-es/strings_menu.xml index 30ea08900..e725d9183 100644 --- a/android/app/src/main/res/values-es/strings_menu.xml +++ b/android/app/src/main/res/values-es/strings_menu.xml @@ -46,6 +46,10 @@ Nombre de canción Año + + Nombre + + Número de canciones Duración diff --git a/android/app/src/main/res/values-fr/strings_menu.xml b/android/app/src/main/res/values-fr/strings_menu.xml index bb7f510c6..4ac5f6071 100644 --- a/android/app/src/main/res/values-fr/strings_menu.xml +++ b/android/app/src/main/res/values-fr/strings_menu.xml @@ -46,6 +46,10 @@ Titre de chanson Année + + Nom + + Nombre de chansons Durée diff --git a/android/app/src/main/res/values-hi/strings_menu.xml b/android/app/src/main/res/values-hi/strings_menu.xml index 4da581e2c..a337dded7 100644 --- a/android/app/src/main/res/values-hi/strings_menu.xml +++ b/android/app/src/main/res/values-hi/strings_menu.xml @@ -46,6 +46,10 @@ गीत का नाम साल + + "नाम " + + गानों की संख्या समयांतराल diff --git a/android/app/src/main/res/values-it/strings_menu.xml b/android/app/src/main/res/values-it/strings_menu.xml index ef795ec1e..12f759cb4 100644 --- a/android/app/src/main/res/values-it/strings_menu.xml +++ b/android/app/src/main/res/values-it/strings_menu.xml @@ -46,6 +46,10 @@ Nome della canzone Anno + + Nome + + Numero di canzoni Durata diff --git a/android/app/src/main/res/values-ja/strings_menu.xml b/android/app/src/main/res/values-ja/strings_menu.xml index b3fb5c849..cea88817b 100644 --- a/android/app/src/main/res/values-ja/strings_menu.xml +++ b/android/app/src/main/res/values-ja/strings_menu.xml @@ -46,6 +46,10 @@ 曲名 + + "名前" + + 曲数 再生時間 diff --git a/android/app/src/main/res/values-nl/strings_menu.xml b/android/app/src/main/res/values-nl/strings_menu.xml index af5785ff8..85f6309e9 100644 --- a/android/app/src/main/res/values-nl/strings_menu.xml +++ b/android/app/src/main/res/values-nl/strings_menu.xml @@ -46,6 +46,10 @@ Titel van een liedje Jaar + + Naam + + Aantal nummers Looptijd diff --git a/android/app/src/main/res/values-pl/strings_menu.xml b/android/app/src/main/res/values-pl/strings_menu.xml index 28e6dcd65..0abdf4120 100644 --- a/android/app/src/main/res/values-pl/strings_menu.xml +++ b/android/app/src/main/res/values-pl/strings_menu.xml @@ -46,6 +46,10 @@ Nazwa piosenki Rok + + Imię + + Liczba piosenek Trwanie diff --git a/android/app/src/main/res/values-pt-rBR/strings_menu.xml b/android/app/src/main/res/values-pt-rBR/strings_menu.xml index bf57199e8..4142df9d4 100644 --- a/android/app/src/main/res/values-pt-rBR/strings_menu.xml +++ b/android/app/src/main/res/values-pt-rBR/strings_menu.xml @@ -46,6 +46,10 @@ Nome da música Ano + + Nome + + Quantidade de músicas Duração diff --git a/android/app/src/main/res/values-pt-rPT/strings_menu.xml b/android/app/src/main/res/values-pt-rPT/strings_menu.xml index 88c707fc1..17b37a07a 100644 --- a/android/app/src/main/res/values-pt-rPT/strings_menu.xml +++ b/android/app/src/main/res/values-pt-rPT/strings_menu.xml @@ -46,6 +46,10 @@ Nome da música Ano + + Nome + + Número de músicas Duração diff --git a/android/app/src/main/res/values-ru/strings_menu.xml b/android/app/src/main/res/values-ru/strings_menu.xml index 95ea630f7..35488f002 100644 --- a/android/app/src/main/res/values-ru/strings_menu.xml +++ b/android/app/src/main/res/values-ru/strings_menu.xml @@ -46,6 +46,10 @@ Название песни Год + + "Имя" + + Количество песен Продолжительность diff --git a/android/app/src/main/res/values-tr/strings_menu.xml b/android/app/src/main/res/values-tr/strings_menu.xml index ce5e017c7..129d1ccd2 100644 --- a/android/app/src/main/res/values-tr/strings_menu.xml +++ b/android/app/src/main/res/values-tr/strings_menu.xml @@ -46,6 +46,10 @@ Şarkı Adı Yıl + + İsim + + Şarkı sayısı Şarkı Süresi diff --git a/android/app/src/main/res/values-zh-rCN/strings_menu.xml b/android/app/src/main/res/values-zh-rCN/strings_menu.xml index f59bf91e7..95ad2322d 100644 --- a/android/app/src/main/res/values-zh-rCN/strings_menu.xml +++ b/android/app/src/main/res/values-zh-rCN/strings_menu.xml @@ -46,6 +46,10 @@ 歌曲名称 年份 + + 名字 + + 歌曲数量 时长 diff --git a/android/app/src/main/res/values/strings_menu.xml b/android/app/src/main/res/values/strings_menu.xml index c582463cb..3bd87349b 100644 --- a/android/app/src/main/res/values/strings_menu.xml +++ b/android/app/src/main/res/values/strings_menu.xml @@ -46,6 +46,10 @@ Song Name Year + + Name + + Song Count Duration diff --git a/android/app/src/test/java/com/simplecityapps/app/GenreComparatorTest.kt b/android/app/src/test/java/com/simplecityapps/app/GenreComparatorTest.kt new file mode 100644 index 000000000..164b5766a --- /dev/null +++ b/android/app/src/test/java/com/simplecityapps/app/GenreComparatorTest.kt @@ -0,0 +1,52 @@ +package com.simplecityapps.app + +import com.simplecityapps.mediaprovider.repository.genres.comparator +import com.simplecityapps.shuttle.model.Genre +import com.simplecityapps.shuttle.model.MediaProviderType +import com.simplecityapps.shuttle.sorting.GenreSortOrder +import junit.framework.TestCase.assertEquals +import org.junit.Test + +class GenreComparatorTest { + private val mediaProviders = listOf(MediaProviderType.MediaStore) + + @Test + fun sortsByName() { + val genres = listOf( + Genre(name = "Pop", 1, 10, mediaProviders), + Genre(name = "Ambient", 2, 10, mediaProviders), + Genre(name = "Metal", 3, 10, mediaProviders) + ) + + val sortedGenres = genres.sortedWith(GenreSortOrder.Name.comparator) + + assertEquals( + listOf( + Genre(name = "Ambient", 2, 10, mediaProviders), + Genre(name = "Metal", 3, 10, mediaProviders), + Genre(name = "Pop", 1, 10, mediaProviders) + ), + sortedGenres + ) + } + + @Test + fun sortsBySongCount() { + val genres = listOf( + Genre(name = "Pop", 1, 10, mediaProviders), + Genre(name = "Ambient", 2, 10, mediaProviders), + Genre(name = "Metal", 3, 10, mediaProviders) + ) + + val sortedGenres = genres.sortedWith(GenreSortOrder.SongCount.comparator) + + assertEquals( + listOf( + Genre(name = "Metal", 3, 10, mediaProviders), + Genre(name = "Ambient", 2, 10, mediaProviders), + Genre(name = "Pop", 1, 10, mediaProviders) + ), + sortedGenres + ) + } +} diff --git a/android/app/src/test/java/com/simplecityapps/app/GenreListTest.kt b/android/app/src/test/java/com/simplecityapps/app/GenreListTest.kt new file mode 100644 index 000000000..0f9e83195 --- /dev/null +++ b/android/app/src/test/java/com/simplecityapps/app/GenreListTest.kt @@ -0,0 +1,152 @@ +package com.simplecityapps.app + +import com.simplecityapps.mediaprovider.MediaImportObserver +import com.simplecityapps.mediaprovider.SongImportState +import com.simplecityapps.mediaprovider.repository.genres.GenreRepository +import com.simplecityapps.mediaprovider.repository.genres.comparator +import com.simplecityapps.mediaprovider.repository.songs.SongRepository +import com.simplecityapps.playback.PlaybackManager +import com.simplecityapps.playback.queue.QueueManager +import com.simplecityapps.shuttle.model.Genre +import com.simplecityapps.shuttle.model.MediaProviderType +import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager +import com.simplecityapps.shuttle.sorting.GenreSortOrder +import com.simplecityapps.shuttle.ui.screens.library.SortPreferenceManager +import com.simplecityapps.shuttle.ui.screens.library.genres.GenreListViewModel +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class GenreListTest { + private val testDispatcher = StandardTestDispatcher() + + private val genreRepository = mockk() + private val songRepository = mockk() + private val playbackManager = mockk() + private val queueManager = mockk() + private val sortPreferenceManager = mockk() + private val preferenceManager = mockk() + private val mediaImportObserver = mockk() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + every { mediaImportObserver.songImportState } returns + MutableStateFlow(SongImportState.ImportComplete(MediaProviderType.MediaStore, "")) + every { preferenceManager.theme(any()) } returns MutableStateFlow(GeneralPreferenceManager.Theme.Dark) + every { preferenceManager.accent(any()) } returns MutableStateFlow(GeneralPreferenceManager.Accent.Default) + every { preferenceManager.extraDark(any()) } returns MutableStateFlow(false) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun sortsOnInitPerPreferences() = runTest { + val sortedGenres = generateGenres() + // Mock the output of the repository, matching the mocked sortOrderGenreList value + every { genreRepository.getGenres(match { it.sortOrder == GenreSortOrder.Default }) } returns flowOf(sortedGenres) + every { sortPreferenceManager.sortOrderGenreList } returns GenreSortOrder.Default + + val viewModel = GenreListViewModel( + genreRepository, + songRepository, + playbackManager, + queueManager, + sortPreferenceManager, + preferenceManager, + mediaImportObserver + ) + + // Capture ready state + val result = viewModel.viewState.first { it is GenreListViewModel.ViewState.Ready } + + assertTrue(result is GenreListViewModel.ViewState.Ready) + result as GenreListViewModel.ViewState.Ready + // Assert that the view model state genres equal the repository output + assertEquals(sortedGenres, result.genres) + } + + @Test + fun setsSortOrderIfReady() = runTest { + val genres = generateGenres() + every { genreRepository.getGenres(any()) } returns flowOf(genres) + every { sortPreferenceManager.sortOrderGenreList } returns GenreSortOrder.Default + every { sortPreferenceManager.sortOrderGenreList = any() } just Runs + + val viewModel = GenreListViewModel( + genreRepository, + songRepository, + playbackManager, + queueManager, + sortPreferenceManager, + preferenceManager, + mediaImportObserver + ) + + // Wait until ready + viewModel.viewState.first { it is GenreListViewModel.ViewState.Ready } + + viewModel.setSortOrder(GenreSortOrder.Name) + + // Capture next emitted state + val result = viewModel.viewState.drop(1).first() + + assertTrue(result is GenreListViewModel.ViewState.Ready) + result as GenreListViewModel.ViewState.Ready + assertEquals(genres.sortedWith(GenreSortOrder.Name.comparator), result.genres) + } + + @Test + fun skipsRedundantSort() = runTest { + every { genreRepository.getGenres(any()) } returns flowOf(generateGenres()) + every { sortPreferenceManager.sortOrderGenreList } returns GenreSortOrder.Default + + val viewModel = GenreListViewModel( + genreRepository, + songRepository, + playbackManager, + queueManager, + sortPreferenceManager, + preferenceManager, + mediaImportObserver + ) + + // Wait until ready + viewModel.viewState.first { it is GenreListViewModel.ViewState.Ready } + + viewModel.setSortOrder(GenreSortOrder.Default) + + verify(exactly = 0) { sortPreferenceManager.sortOrderGenreList = any() } + } + + private fun generateGenres(count: Int = 5): List = List(count) { index -> + Genre( + name = "Genre $index", + songCount = (5..20).random(), + duration = (10..100).random(), + mediaProviders = listOf(MediaProviderType.MediaStore) + ) + } +} diff --git a/android/data/src/main/kotlin/com/simplecityapps/shuttle/sorting/GenreSortOrder.kt b/android/data/src/main/kotlin/com/simplecityapps/shuttle/sorting/GenreSortOrder.kt index 23b40536c..30f3e8fed 100644 --- a/android/data/src/main/kotlin/com/simplecityapps/shuttle/sorting/GenreSortOrder.kt +++ b/android/data/src/main/kotlin/com/simplecityapps/shuttle/sorting/GenreSortOrder.kt @@ -1,5 +1,7 @@ package com.simplecityapps.shuttle.sorting enum class GenreSortOrder { - Default + Default, + Name, + SongCount } diff --git a/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/repository/genres/GenreComparator.kt b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/repository/genres/GenreComparator.kt index d81d4d1e1..3f1ebb930 100644 --- a/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/repository/genres/GenreComparator.kt +++ b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/repository/genres/GenreComparator.kt @@ -4,12 +4,17 @@ import com.simplecityapps.shuttle.model.Genre import com.simplecityapps.shuttle.sorting.GenreSortOrder val GenreSortOrder.comparator: Comparator - get() { - return when (this) { - GenreSortOrder.Default -> GenreComparator.defaultComparator - } + get() = when (this) { + GenreSortOrder.Default -> GenreComparator.defaultComparator + GenreSortOrder.Name -> GenreComparator.nameComparator + GenreSortOrder.SongCount -> GenreComparator.songCountComparator } object GenreComparator { - val defaultComparator: Comparator by lazy { compareBy { genre -> genre.name } } + val defaultComparator: Comparator = compareBy { it.name } + + val nameComparator: Comparator = compareBy { it.name } + + val songCountComparator: Comparator = compareByDescending { it.songCount } + .then(defaultComparator) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4a029fd60..99eed15c4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,7 @@ kotlin = "2.1.0" kotlinx-coroutines-android = "1.8.1" kotlinx-coroutines-core = "1.8.1" kotlinx-coroutines-play-services = "1.8.1" +kotlinx-coroutines-test = "1.8.1" kotlinx-datetime = "0.4.1" ktaglib = "1.5" leakcanary-android = "2.10" @@ -44,6 +45,7 @@ lifecycle-viewmodel-compose = "2.8.7" logging-interceptor = "4.12.0" material = "1.12.0" media = "1.7.0" +mockk = "1.14.2" moshi = "1.15.1" moshi-adapters = "1.15.0" moshi-kotlin = "1.15.0" @@ -138,9 +140,11 @@ kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = " kotlinx-coroutinesAndroid = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" } kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines-core" } kotlinx-coroutinesPlayServices = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines-play-services" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary-android" } mikepenz-aboutlibrariesCore = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "moshi-adapters" } moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi-kotlin" }