From bc04cf2e179f8736738dc09122886a1d3dc64346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sun, 29 Oct 2023 19:16:11 +0100 Subject: [PATCH 01/14] Add compose dependencies Following these instructions: https://developer.android.com/jetpack/compose/setup#kotlin --- android/app/build.gradle.kts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 06cb3a205..5c4875c6a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -85,6 +85,14 @@ android { } namespace = "com.simplecityapps.shuttle" + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = rootProject.extra["compose_version"] as String + } + dependencies { implementation(fileTree("dir" to "libs", "include" to listOf("*.jar"))) @@ -126,6 +134,27 @@ android { // AppCompat implementation("androidx.appcompat:appcompat:1.6.1") + val composeBom = platform("androidx.compose:compose-bom:2023.10.01") + + implementation(composeBom) + androidTestImplementation(composeBom) + + // Material Design 3 + implementation("androidx.compose.material3:material3") + + // Android Studio Preview support + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + + // UI Tests + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") + + // Optional - Integration with activities + implementation("androidx.activity:activity-compose:1.6.1") + // Optional - Integration with LiveData + implementation("androidx.compose.runtime:runtime-livedata") + // Material implementation("com.google.android.material:material:1.10.0") From 4d1655cdcabedf398ebdb31595a345ce6eeb7544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Fri, 1 Dec 2023 19:52:51 +0100 Subject: [PATCH 02/14] Implement list of genres without menu nor fast scroll --- .../ui/screens/library/genres/GenreBinder.kt | 6 +- .../library/genres/GenreListFragment.kt | 96 ++++++++++++------- .../src/main/res/layout/fragment_genres.xml | 10 +- 3 files changed, 66 insertions(+), 46 deletions(-) diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt index 699d7ae3c..722110c4a 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt @@ -7,15 +7,13 @@ import android.widget.ImageButton import android.widget.TextView import com.simplecityapps.adapter.ViewBinder import com.simplecityapps.shuttle.R -import com.simplecityapps.shuttle.model.Genre import com.simplecityapps.shuttle.ui.common.recyclerview.ViewTypes import com.squareup.phrase.Phrase class GenreBinder(val genre: com.simplecityapps.shuttle.model.Genre, private val listener: Listener) : ViewBinder { interface Listener { fun onGenreSelected( - genre: com.simplecityapps.shuttle.model.Genre, - viewHolder: ViewHolder + genre: com.simplecityapps.shuttle.model.Genre ) fun onOverflowClicked( @@ -58,7 +56,7 @@ class GenreBinder(val genre: com.simplecityapps.shuttle.model.Genre, private val private val overflowButton: ImageButton = itemView.findViewById(R.id.overflowButton) init { - itemView.setOnClickListener { viewBinder?.listener?.onGenreSelected(viewBinder!!.genre, this) } + itemView.setOnClickListener { viewBinder?.listener?.onGenreSelected(viewBinder!!.genre) } overflowButton.setOnClickListener { viewBinder?.listener?.onOverflowClicked(it, viewBinder!!.genre) } } 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 c8bc2aa96..1e1288474 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 @@ -7,21 +7,32 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.appcompat.widget.PopupMenu +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.RecyclerView -import com.simplecityapps.adapter.RecyclerAdapter -import com.simplecityapps.adapter.RecyclerListener -import com.simplecityapps.adapter.ViewBinder import com.simplecityapps.mediaprovider.Progress import com.simplecityapps.shuttle.R +import com.simplecityapps.shuttle.model.Genre import com.simplecityapps.shuttle.ui.common.TagEditorMenuSanitiser import com.simplecityapps.shuttle.ui.common.autoCleared import com.simplecityapps.shuttle.ui.common.dialog.TagEditorAlertDialog import com.simplecityapps.shuttle.ui.common.dialog.showExcludeDialog import com.simplecityapps.shuttle.ui.common.error.userDescription -import com.simplecityapps.shuttle.ui.common.recyclerview.SectionedAdapter import com.simplecityapps.shuttle.ui.common.view.CircularLoadingView import com.simplecityapps.shuttle.ui.common.view.HorizontalLoadingView import com.simplecityapps.shuttle.ui.screens.library.genres.detail.GenreDetailFragmentArgs @@ -31,6 +42,7 @@ import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistMenuPresenter import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistMenuView import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint +import timber.log.Timber import javax.inject.Inject @AndroidEntryPoint @@ -39,9 +51,7 @@ class GenreListFragment : GenreBinder.Listener, GenreListContract.View, CreatePlaylistDialogFragment.Listener { - private var adapter: RecyclerAdapter by autoCleared() - - private var recyclerView: RecyclerView by autoCleared() + private var composeView: ComposeView by autoCleared() private var circularLoadingView: CircularLoadingView by autoCleared() private var horizontalLoadingView: HorizontalLoadingView by autoCleared() @@ -73,17 +83,9 @@ class GenreListFragment : playlistMenuView = PlaylistMenuView(requireContext(), playlistMenuPresenter, childFragmentManager) - adapter = - object : SectionedAdapter(viewLifecycleOwner.lifecycleScope) { - override fun getSectionName(viewBinder: ViewBinder?): String? { - return (viewBinder as? GenreBinder)?.genre?.let { genre -> - presenter.getFastscrollPrefix(genre) - } - } - } - recyclerView = view.findViewById(R.id.recyclerView) - recyclerView.adapter = adapter - recyclerView.setRecyclerListener(RecyclerListener()) + composeView = view.findViewById(R.id.composeView) + presenter.loadGenres(false) + circularLoadingView = view.findViewById(R.id.circularLoadingView) horizontalLoadingView = view.findViewById(R.id.horizontalLoadingView) @@ -102,8 +104,6 @@ class GenreListFragment : override fun onPause() { super.onPause() - - recyclerViewState = recyclerView.layoutManager?.onSaveInstanceState() } override fun onSaveInstanceState(outState: Bundle) { @@ -120,20 +120,47 @@ class GenreListFragment : // GenreListContract.View Implementation - override fun setGenres( - genres: List, - resetPosition: Boolean - ) { - if (resetPosition) { - adapter.clear() + override fun setGenres(genres: List, resetPosition: Boolean) { + composeView.setContent { + GenreList(genres) } + } - val data = genres.map { genre -> GenreBinder(genre, this) }.toMutableList() + @Composable + private fun GenreList(genres: List, modifier: Modifier = Modifier) { + LazyColumn( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(genres) { genre -> + GenreListItem(genre) + } + } + } - adapter.update(data) { - recyclerViewState?.let { - recyclerView.layoutManager?.onRestoreInstanceState(recyclerViewState) - recyclerViewState = null + @Composable + private fun GenreListItem(genre: Genre, modifier: Modifier = Modifier) { + Row(modifier) { + Column( + Modifier + .fillMaxWidth() + .clickable { this@GenreListFragment.onGenreSelected(genre) }, + ) { + Text( + text = genre.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = Phrase + .fromPlural(LocalContext.current, R.plurals.songsPlural, genre.songCount) + .put("count", genre.songCount) + .format() + .toString(), + style = MaterialTheme.typography.bodySmall, + ) } } } @@ -180,8 +207,7 @@ class GenreListFragment : // GenreBinder.Listener Implementation override fun onGenreSelected( - genre: com.simplecityapps.shuttle.model.Genre, - viewHolder: GenreBinder.ViewHolder + genre: com.simplecityapps.shuttle.model.Genre ) { if (findNavController().currentDestination?.id != R.id.genreDetailFragment) { findNavController().navigate( diff --git a/android/app/src/main/res/layout/fragment_genres.xml b/android/app/src/main/res/layout/fragment_genres.xml index aa64eb374..00acc1358 100644 --- a/android/app/src/main/res/layout/fragment_genres.xml +++ b/android/app/src/main/res/layout/fragment_genres.xml @@ -7,16 +7,12 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + /> Date: Tue, 2 Jan 2024 20:10:54 +0100 Subject: [PATCH 03/14] Implement overflow menu on genre items --- .../library/genres/GenreListFragment.kt | 134 +++++++++++++++++- 1 file changed, 131 insertions(+), 3 deletions(-) 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 1e1288474..c1d706050 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 @@ -15,12 +15,25 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment @@ -42,7 +55,6 @@ import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistMenuPresenter import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistMenuView import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint -import timber.log.Timber import javax.inject.Inject @AndroidEntryPoint @@ -142,10 +154,13 @@ class GenreListFragment : @Composable private fun GenreListItem(genre: Genre, modifier: Modifier = Modifier) { - Row(modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { Column( Modifier - .fillMaxWidth() + .weight(1f) .clickable { this@GenreListFragment.onGenreSelected(genre) }, ) { Text( @@ -162,6 +177,119 @@ class GenreListFragment : style = MaterialTheme.typography.bodySmall, ) } + GenreMenu(genre) + } + } + + @Composable + private fun GenreMenu(genre: Genre) { + var isMenuOpened by remember { mutableStateOf(false) } + var isAddToPlaylistSubmenuOpen by remember { mutableStateOf(false) } + + IconButton( + onClick = { isMenuOpened = true }, + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = "Genre menu", + ) + DropdownMenu( + expanded = isMenuOpened, + onDismissRequest = { isMenuOpened = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_play)) }, + onClick = { + presenter.play(genre) + isMenuOpened = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_add_to_queue)) }, + onClick = { + presenter.addToQueue(genre) + isMenuOpened = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_add_to_playlist)) }, + onClick = { + isMenuOpened = false + isAddToPlaylistSubmenuOpen = true + }, + trailingIcon = { + Icon(Icons.Default.KeyboardArrowRight, contentDescription = null) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_play_next)) }, + onClick = { + presenter.playNext(genre) + isMenuOpened = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_exclude)) }, + onClick = { + presenter.exclude(genre) + isMenuOpened = false + }, + ) + + val supportsTagEditing = genre.mediaProviders.all { + mediaProvider -> mediaProvider.supportsTagEditing + } + + if (supportsTagEditing) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_edit_tags)) }, + onClick = { + presenter.editTags(genre) + isMenuOpened = false + }, + ) + } + } + AddToPlaylistSubmenu( + genre = genre, + expanded = isAddToPlaylistSubmenuOpen, + onDismiss = { isAddToPlaylistSubmenuOpen = false }, + ) + } + } + + @Composable + private fun AddToPlaylistSubmenu( + genre: Genre, + expanded: Boolean = false, + onDismiss: () -> Unit = {}, + ) { + val playlistData = PlaylistData.Genres(genre) + + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + ) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.playlist_menu_create_playlist)) }, + onClick = { + CreatePlaylistDialogFragment.newInstance( + playlistData, + context?.getString(R.string.playlist_create_dialog_playlist_name_hint) + ).show(childFragmentManager) + onDismiss() + }, + ) + + for (playlist in playlistMenuPresenter.playlists) { + DropdownMenuItem( + text = { Text(playlist.name) }, + onClick = { + playlistMenuPresenter.addToPlaylist(playlist, playlistData) + onDismiss() + }, + ) + } } } From 7d4db9dc6989af55324177cbc4f490af1efc7208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sat, 20 Jan 2024 20:00:25 +0100 Subject: [PATCH 04/14] Replace presenter with view model --- .../ui/screens/library/genres/GenreBinder.kt | 81 -------- .../library/genres/GenreListFragment.kt | 174 ++++++++-------- .../library/genres/GenreListPresenter.kt | 188 ------------------ .../library/genres/GenreListViewModel.kt | 115 +++++++++++ 4 files changed, 209 insertions(+), 349 deletions(-) delete mode 100644 android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt delete mode 100644 android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListPresenter.kt create mode 100644 android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListViewModel.kt diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt deleted file mode 100644 index 722110c4a..000000000 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.simplecityapps.shuttle.ui.screens.library.genres - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageButton -import android.widget.TextView -import com.simplecityapps.adapter.ViewBinder -import com.simplecityapps.shuttle.R -import com.simplecityapps.shuttle.ui.common.recyclerview.ViewTypes -import com.squareup.phrase.Phrase - -class GenreBinder(val genre: com.simplecityapps.shuttle.model.Genre, private val listener: Listener) : ViewBinder { - interface Listener { - fun onGenreSelected( - genre: com.simplecityapps.shuttle.model.Genre - ) - - fun onOverflowClicked( - view: View, - genre: com.simplecityapps.shuttle.model.Genre - ) {} - } - - override fun createViewHolder(parent: ViewGroup): ViewHolder { - return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.list_item_genre, parent, false)) - } - - override fun viewType(): Int { - return ViewTypes.Genre - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as GenreBinder - - if (genre != other.genre) return false - - return true - } - - override fun hashCode(): Int { - return genre.hashCode() - } - - override fun areContentsTheSame(other: Any): Boolean { - return genre.name == (other as? GenreBinder)?.genre?.name && - genre.songCount == (other as? GenreBinder)?.genre?.songCount - } - - class ViewHolder(itemView: View) : ViewBinder.ViewHolder(itemView) { - private val titleTextView: TextView = itemView.findViewById(R.id.title) - private val subtitleTextView: TextView = itemView.findViewById(R.id.subtitle) - private val overflowButton: ImageButton = itemView.findViewById(R.id.overflowButton) - - init { - itemView.setOnClickListener { viewBinder?.listener?.onGenreSelected(viewBinder!!.genre) } - overflowButton.setOnClickListener { viewBinder?.listener?.onOverflowClicked(it, viewBinder!!.genre) } - } - - override fun bind( - viewBinder: GenreBinder, - isPartial: Boolean - ) { - super.bind(viewBinder, isPartial) - - titleTextView.text = viewBinder.genre.name - if (viewBinder.genre.songCount == 0) { - subtitleTextView.text = itemView.resources.getString(R.string.song_list_empty) - } else { - subtitleTextView.text = - Phrase - .fromPlural(itemView.context, R.plurals.songsPlural, viewBinder.genre.songCount) - .put("count", viewBinder.genre.songCount) - .format() - } - } - } -} 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 c1d706050..f570ebcc5 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 @@ -6,7 +6,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast -import androidx.appcompat.widget.PopupMenu import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -25,6 +24,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,14 +37,15 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController +import com.simplecityapps.mediaprovider.MediaImporter import com.simplecityapps.mediaprovider.Progress import com.simplecityapps.shuttle.R import com.simplecityapps.shuttle.model.Genre -import com.simplecityapps.shuttle.ui.common.TagEditorMenuSanitiser +import com.simplecityapps.shuttle.model.MediaProviderType import com.simplecityapps.shuttle.ui.common.autoCleared import com.simplecityapps.shuttle.ui.common.dialog.TagEditorAlertDialog -import com.simplecityapps.shuttle.ui.common.dialog.showExcludeDialog import com.simplecityapps.shuttle.ui.common.error.userDescription import com.simplecityapps.shuttle.ui.common.view.CircularLoadingView import com.simplecityapps.shuttle.ui.common.view.HorizontalLoadingView @@ -60,15 +61,12 @@ import javax.inject.Inject @AndroidEntryPoint class GenreListFragment : Fragment(), - GenreBinder.Listener, - GenreListContract.View, CreatePlaylistDialogFragment.Listener { private var composeView: ComposeView by autoCleared() private var circularLoadingView: CircularLoadingView by autoCleared() private var horizontalLoadingView: HorizontalLoadingView by autoCleared() - @Inject - lateinit var presenter: GenreListPresenter + private val viewModel: GenreListViewModel by viewModels() @Inject lateinit var playlistMenuPresenter: PlaylistMenuPresenter @@ -77,6 +75,17 @@ class GenreListFragment : private var recyclerViewState: Parcelable? = null + private val mediaImporterListener = + object : MediaImporter.Listener { + override fun onSongImportProgress( + providerType: MediaProviderType, + message: String, + progress: Progress?, + ) { + this@GenreListFragment.setLoadingProgress(progress) + } + } + // Lifecycle override fun onCreateView( @@ -96,7 +105,7 @@ class GenreListFragment : playlistMenuView = PlaylistMenuView(requireContext(), playlistMenuPresenter, childFragmentManager) composeView = view.findViewById(R.id.composeView) - presenter.loadGenres(false) + loadGenres() circularLoadingView = view.findViewById(R.id.circularLoadingView) @@ -104,14 +113,51 @@ class GenreListFragment : savedInstanceState?.getParcelable(ARG_RECYCLER_STATE)?.let { recyclerViewState = it } - presenter.bindView(this) playlistMenuPresenter.bindView(playlistMenuView) } + fun loadGenres() { + composeView.setContent { + val viewState by viewModel.viewState.collectAsState() + + Genres(viewState) + } + } + + @Composable + private fun Genres(viewState: GenreListViewModel.ViewState) { + when (viewState) { + is GenreListViewModel.ViewState.Scanning -> { + this.setLoadingState(LoadingState.Scanning) + } + + is GenreListViewModel.ViewState.Loading -> { + this.setLoadingState(LoadingState.Loading) + } + + is GenreListViewModel.ViewState.Ready -> { + if (viewState.genres.isEmpty()) { + if (viewModel.isImportingMedia()) { + viewModel.addMediaImporterListener(mediaImporterListener) + this.setLoadingState(LoadingState.Scanning) + } else { + viewModel.removeMediaImporterListener(mediaImporterListener) + this.setLoadingState(LoadingState.Empty) + } + } else { + viewModel.removeMediaImporterListener(mediaImporterListener) + this.setLoadingState(LoadingState.None) + } + + GenreList(genres = viewState.genres) + } + } + } + override fun onResume() { super.onResume() - presenter.loadGenres(false) + loadGenres() } override fun onPause() { @@ -124,7 +170,6 @@ class GenreListFragment : } override fun onDestroyView() { - presenter.unbindView() playlistMenuPresenter.unbindView() super.onDestroyView() @@ -132,12 +177,6 @@ class GenreListFragment : // GenreListContract.View Implementation - override fun setGenres(genres: List, resetPosition: Boolean) { - composeView.setContent { - GenreList(genres) - } - } - @Composable private fun GenreList(genres: List, modifier: Modifier = Modifier) { LazyColumn( @@ -200,14 +239,20 @@ class GenreListFragment : DropdownMenuItem( text = { Text(stringResource(id = R.string.menu_title_play)) }, onClick = { - presenter.play(genre) + viewModel.play(genre) { result -> + result.onFailure { error -> showLoadError(error as Error) } + } isMenuOpened = false }, ) DropdownMenuItem( text = { Text(stringResource(id = R.string.menu_title_add_to_queue)) }, onClick = { - presenter.addToQueue(genre) + viewModel.addToQueue(genre) { result -> + result.onSuccess { genre -> + onAddedToQueue(genre) + } + } isMenuOpened = false }, ) @@ -224,27 +269,35 @@ class GenreListFragment : DropdownMenuItem( text = { Text(stringResource(id = R.string.menu_title_play_next)) }, onClick = { - presenter.playNext(genre) + viewModel.playNext(genre) { result -> + result.onSuccess { genre -> + onAddedToQueue(genre) + } + } isMenuOpened = false }, ) DropdownMenuItem( text = { Text(stringResource(id = R.string.menu_title_exclude)) }, onClick = { - presenter.exclude(genre) + viewModel.exclude(genre) isMenuOpened = false }, ) - val supportsTagEditing = genre.mediaProviders.all { - mediaProvider -> mediaProvider.supportsTagEditing + val supportsTagEditing = genre.mediaProviders.all { mediaProvider -> + mediaProvider.supportsTagEditing } if (supportsTagEditing) { DropdownMenuItem( text = { Text(stringResource(id = R.string.menu_title_edit_tags)) }, onClick = { - presenter.editTags(genre) + viewModel.editTags(genre) { result -> + result.onSuccess { songs -> + showTagEditor(songs) + } + } isMenuOpened = false }, ) @@ -293,48 +346,46 @@ class GenreListFragment : } } - override fun onAddedToQueue(genre: com.simplecityapps.shuttle.model.Genre) { + fun onAddedToQueue(genre: com.simplecityapps.shuttle.model.Genre) { Toast.makeText(context, Phrase.from(requireContext(), R.string.queue_item_added).put("item_name", genre.name).format(), Toast.LENGTH_SHORT).show() } - override fun setLoadingState(state: GenreListContract.LoadingState) { + fun setLoadingState(state: LoadingState) { when (state) { - is GenreListContract.LoadingState.Scanning -> { + is LoadingState.Scanning -> { horizontalLoadingView.setState(HorizontalLoadingView.State.Loading(getString(R.string.library_scan_in_progress))) circularLoadingView.setState(CircularLoadingView.State.None) } - is GenreListContract.LoadingState.Loading -> { + is LoadingState.Loading -> { horizontalLoadingView.setState(HorizontalLoadingView.State.None) circularLoadingView.setState(CircularLoadingView.State.Loading(getString(R.string.loading))) } - is GenreListContract.LoadingState.Empty -> { + is LoadingState.Empty -> { horizontalLoadingView.setState(HorizontalLoadingView.State.None) circularLoadingView.setState(CircularLoadingView.State.Empty(getString(R.string.genre_list_empty))) } - is GenreListContract.LoadingState.None -> { + is LoadingState.None -> { horizontalLoadingView.setState(HorizontalLoadingView.State.None) circularLoadingView.setState(CircularLoadingView.State.None) } } } - override fun setLoadingProgress(progress: Progress?) { + fun setLoadingProgress(progress: Progress?) { progress?.let { horizontalLoadingView.setProgress(progress.asFloat()) } } - override fun showLoadError(error: Error) { + fun showLoadError(error: Error) { Toast.makeText(context, error.userDescription(resources), Toast.LENGTH_LONG).show() } - override fun showTagEditor(songs: List) { + fun showTagEditor(songs: List) { TagEditorAlertDialog.newInstance(songs).show(childFragmentManager) } - // GenreBinder.Listener Implementation - - override fun onGenreSelected( + fun onGenreSelected( genre: com.simplecityapps.shuttle.model.Genre ) { if (findNavController().currentDestination?.id != R.id.genreDetailFragment) { @@ -345,50 +396,6 @@ class GenreListFragment : } } - override fun onOverflowClicked( - view: View, - genre: com.simplecityapps.shuttle.model.Genre - ) { - val popupMenu = PopupMenu(requireContext(), view) - popupMenu.inflate(R.menu.menu_popup) - TagEditorMenuSanitiser.sanitise(popupMenu.menu, genre.mediaProviders) - - playlistMenuView.createPlaylistMenu(popupMenu.menu) - - popupMenu.setOnMenuItemClickListener { menuItem -> - if (playlistMenuView.handleMenuItem(menuItem, PlaylistData.Genres(genre))) { - return@setOnMenuItemClickListener true - } else { - when (menuItem.itemId) { - R.id.play -> { - presenter.play(genre) - return@setOnMenuItemClickListener true - } - R.id.queue -> { - presenter.addToQueue(genre) - return@setOnMenuItemClickListener true - } - R.id.playNext -> { - presenter.playNext(genre) - return@setOnMenuItemClickListener true - } - R.id.exclude -> { - showExcludeDialog(requireContext(), genre.name) { - presenter.exclude(genre) - } - return@setOnMenuItemClickListener true - } - R.id.editTags -> { - presenter.editTags(genre) - return@setOnMenuItemClickListener true - } - } - } - false - } - popupMenu.show() - } - // CreatePlaylistDialogFragment.Listener Implementation override fun onSave( @@ -407,4 +414,11 @@ class GenreListFragment : fun newInstance() = GenreListFragment() } + + sealed class LoadingState { + object Scanning : LoadingState() + object Loading : LoadingState() + object Empty : LoadingState() + object None : LoadingState() + } } diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListPresenter.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListPresenter.kt deleted file mode 100644 index 988c22243..000000000 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListPresenter.kt +++ /dev/null @@ -1,188 +0,0 @@ -package com.simplecityapps.shuttle.ui.screens.library.genres - -import com.simplecityapps.mediaprovider.MediaImporter -import com.simplecityapps.mediaprovider.Progress -import com.simplecityapps.mediaprovider.repository.genres.GenreQuery -import com.simplecityapps.mediaprovider.repository.genres.GenreRepository -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.query.SongQuery -import com.simplecityapps.shuttle.ui.common.mvp.BasePresenter -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch - -class GenreListContract { - sealed class LoadingState { - object Scanning : LoadingState() - - object Loading : LoadingState() - - object Empty : LoadingState() - - object None : LoadingState() - } - - interface View { - fun setGenres( - genres: List, - resetPosition: Boolean - ) - - fun onAddedToQueue(genre: Genre) - - fun setLoadingState(state: LoadingState) - - fun setLoadingProgress(progress: Progress?) - - fun showLoadError(error: Error) - - fun showTagEditor(songs: List) - } - - interface Presenter { - fun loadGenres(resetPosition: Boolean) - - fun addToQueue(genre: Genre) - - fun playNext(genre: Genre) - - fun exclude(genre: Genre) - - fun editTags(genre: Genre) - - fun play(genre: Genre) - - fun getFastscrollPrefix(genre: Genre): String? - } -} - -class GenreListPresenter -@Inject -constructor( - private val genreRepository: GenreRepository, - private val songRepository: SongRepository, - private val playbackManager: PlaybackManager, - private val mediaImporter: MediaImporter, - private val queueManager: QueueManager -) : GenreListContract.Presenter, - BasePresenter() { - private var genres: List? = null - - private val mediaImporterListener = - object : MediaImporter.Listener { - override fun onSongImportProgress( - providerType: MediaProviderType, - message: String, - progress: Progress? - ) { - view?.setLoadingProgress(progress) - } - } - - override fun unbindView() { - super.unbindView() - - mediaImporter.listeners.remove(mediaImporterListener) - } - - override fun loadGenres(resetPosition: Boolean) { - if (genres == null) { - if (mediaImporter.isImporting) { - view?.setLoadingState(GenreListContract.LoadingState.Scanning) - } else { - view?.setLoadingState(GenreListContract.LoadingState.Loading) - } - } - launch { - genreRepository.getGenres(GenreQuery.All()) - .distinctUntilChanged() - .flowOn(Dispatchers.IO) - .collect { genres -> - if (genres.isEmpty()) { - if (mediaImporter.isImporting) { - mediaImporter.listeners.add(mediaImporterListener) - view?.setLoadingState(GenreListContract.LoadingState.Scanning) - } else { - mediaImporter.listeners.remove(mediaImporterListener) - view?.setLoadingState(GenreListContract.LoadingState.Empty) - } - } else { - mediaImporter.listeners.remove(mediaImporterListener) - view?.setLoadingState(GenreListContract.LoadingState.None) - } - this@GenreListPresenter.genres = genres - view?.setGenres(genres, resetPosition) - } - } - } - - override fun addToQueue(genre: Genre) { - launch { - val songs = - genreRepository.getSongsForGenre(genre.name, SongQuery.All()) - .firstOrNull() - .orEmpty() - playbackManager.addToQueue(songs) - view?.onAddedToQueue(genre) - } - } - - override fun playNext(genre: Genre) { - launch { - val songs = - genreRepository.getSongsForGenre(genre.name, SongQuery.All()) - .firstOrNull() - .orEmpty() - playbackManager.playNext(songs) - view?.onAddedToQueue(genre) - } - } - - override fun exclude(genre: Genre) { - launch { - val songs = - genreRepository.getSongsForGenre(genre.name, SongQuery.All()) - .firstOrNull() - .orEmpty() - songRepository.setExcluded(songs, true) - queueManager.remove(queueManager.getQueue().filter { queueItem -> songs.contains(queueItem.song) }) - } - } - - override fun editTags(genre: Genre) { - launch { - val songs = - genreRepository.getSongsForGenre(genre.name, SongQuery.All()) - .firstOrNull() - .orEmpty() - view?.showTagEditor(songs) - } - } - - override fun play(genre: Genre) { - launch { - val songs = - genreRepository.getSongsForGenre(genre.name, SongQuery.All()) - .firstOrNull() - .orEmpty() - if (queueManager.setQueue(songs)) { - playbackManager.load { result -> - result.onSuccess { playbackManager.play() } - result.onFailure { error -> view?.showLoadError(error as Error) } - } - } - } - } - - override fun getFastscrollPrefix(genre: Genre): String? { - return genre.name.first().toString() - } -} 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 new file mode 100644 index 000000000..00dfdd2bd --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListViewModel.kt @@ -0,0 +1,115 @@ +package com.simplecityapps.shuttle.ui.screens.library.genres + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.simplecityapps.mediaprovider.MediaImporter +import com.simplecityapps.mediaprovider.repository.genres.GenreQuery +import com.simplecityapps.mediaprovider.repository.genres.GenreRepository +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.Song +import com.simplecityapps.shuttle.query.SongQuery +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch + +@HiltViewModel +class GenreListViewModel @Inject constructor( + private val genreRepository: GenreRepository, + private val songRepository: SongRepository, + private val playbackManager: PlaybackManager, + private val mediaImporter: MediaImporter, + private val queueManager: QueueManager, +) : ViewModel() { + private val _viewState = MutableStateFlow(ViewState.Loading) + val viewState = _viewState.asStateFlow() + + init { + genreRepository.getGenres(GenreQuery.All()) + .onStart { + if (isImportingMedia()) { + _viewState.emit(ViewState.Scanning) + } else { + _viewState.emit(ViewState.Loading) + } + } + .onEach { genres -> + _viewState.emit(ViewState.Ready(genres)) + } + .launchIn(viewModelScope) + } + + fun play(genre: Genre, completion: (Result) -> Unit) { + viewModelScope.launch { + val songs = getSongsForGenreOrEmpty(genre) + if (queueManager.setQueue(songs)) { + playbackManager.load { result -> + result.onSuccess { playbackManager.play() } + completion(result) + } + } + } + } + + fun addToQueue(genre: Genre, completion: (Result) -> Unit) { + viewModelScope.launch { + val songs = getSongsForGenreOrEmpty(genre) + playbackManager.addToQueue(songs) + completion(Result.success(genre)) + } + } + + fun playNext(genre: Genre, completion: (Result) -> Unit) { + viewModelScope.launch { + val songs = getSongsForGenreOrEmpty(genre) + playbackManager.playNext(songs) + completion(Result.success(genre)) + } + } + + fun exclude(genre: Genre) { + viewModelScope.launch { + val songs = getSongsForGenreOrEmpty(genre) + songRepository.setExcluded(songs, true) + queueManager.remove( + queueManager + .getQueue() + .filter { queueItem -> songs.contains(queueItem.song) } + ) + } + } + + fun editTags(genre: Genre, completion: (Result>) -> Unit) { + viewModelScope.launch { + val songs = getSongsForGenreOrEmpty(genre) + completion(Result.success(songs)) + } + } + + private suspend fun getSongsForGenreOrEmpty(genre: Genre) = + genreRepository.getSongsForGenre(genre.name, SongQuery.All()) + .firstOrNull() + .orEmpty() + + fun isImportingMedia() = mediaImporter.isImporting + + fun addMediaImporterListener(listener: MediaImporter.Listener) = + mediaImporter.listeners.add(listener) + + fun removeMediaImporterListener(listener: MediaImporter.Listener) = + mediaImporter.listeners.remove(listener) + + sealed class ViewState { + data object Scanning : ViewState() + data object Loading : ViewState() + data class Ready(val genres: List) : ViewState() + } +} From 527e80c1223c8c7b31d57dce03db704f0e093df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Tue, 6 Feb 2024 11:13:14 +0100 Subject: [PATCH 05/14] Clean up GenreListFragment --- .../library/genres/GenreListFragment.kt | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) 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 f570ebcc5..567698576 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 @@ -82,7 +82,7 @@ class GenreListFragment : message: String, progress: Progress?, ) { - this@GenreListFragment.setLoadingProgress(progress) + setLoadingProgress(progress) } } @@ -116,7 +116,7 @@ class GenreListFragment : playlistMenuPresenter.bindView(playlistMenuView) } - fun loadGenres() { + private fun loadGenres() { composeView.setContent { val viewState by viewModel.viewState.collectAsState() @@ -128,25 +128,25 @@ class GenreListFragment : private fun Genres(viewState: GenreListViewModel.ViewState) { when (viewState) { is GenreListViewModel.ViewState.Scanning -> { - this.setLoadingState(LoadingState.Scanning) + setLoadingState(LoadingState.Scanning) } is GenreListViewModel.ViewState.Loading -> { - this.setLoadingState(LoadingState.Loading) + setLoadingState(LoadingState.Loading) } is GenreListViewModel.ViewState.Ready -> { if (viewState.genres.isEmpty()) { if (viewModel.isImportingMedia()) { viewModel.addMediaImporterListener(mediaImporterListener) - this.setLoadingState(LoadingState.Scanning) + setLoadingState(LoadingState.Scanning) } else { viewModel.removeMediaImporterListener(mediaImporterListener) - this.setLoadingState(LoadingState.Empty) + setLoadingState(LoadingState.Empty) } } else { viewModel.removeMediaImporterListener(mediaImporterListener) - this.setLoadingState(LoadingState.None) + setLoadingState(LoadingState.None) } GenreList(genres = viewState.genres) @@ -156,14 +156,9 @@ class GenreListFragment : override fun onResume() { super.onResume() - loadGenres() } - override fun onPause() { - super.onPause() - } - override fun onSaveInstanceState(outState: Bundle) { outState.putParcelable(ARG_RECYCLER_STATE, recyclerViewState) super.onSaveInstanceState(outState) @@ -175,8 +170,6 @@ class GenreListFragment : super.onDestroyView() } - // GenreListContract.View Implementation - @Composable private fun GenreList(genres: List, modifier: Modifier = Modifier) { LazyColumn( @@ -200,7 +193,7 @@ class GenreListFragment : Column( Modifier .weight(1f) - .clickable { this@GenreListFragment.onGenreSelected(genre) }, + .clickable { onGenreSelected(genre) }, ) { Text( text = genre.name, @@ -346,24 +339,27 @@ class GenreListFragment : } } - fun onAddedToQueue(genre: com.simplecityapps.shuttle.model.Genre) { + 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() } - fun setLoadingState(state: LoadingState) { + private fun setLoadingState(state: LoadingState) { when (state) { is LoadingState.Scanning -> { horizontalLoadingView.setState(HorizontalLoadingView.State.Loading(getString(R.string.library_scan_in_progress))) circularLoadingView.setState(CircularLoadingView.State.None) } + is LoadingState.Loading -> { horizontalLoadingView.setState(HorizontalLoadingView.State.None) circularLoadingView.setState(CircularLoadingView.State.Loading(getString(R.string.loading))) } + is LoadingState.Empty -> { horizontalLoadingView.setState(HorizontalLoadingView.State.None) circularLoadingView.setState(CircularLoadingView.State.Empty(getString(R.string.genre_list_empty))) } + is LoadingState.None -> { horizontalLoadingView.setState(HorizontalLoadingView.State.None) circularLoadingView.setState(CircularLoadingView.State.None) @@ -385,9 +381,7 @@ class GenreListFragment : TagEditorAlertDialog.newInstance(songs).show(childFragmentManager) } - fun onGenreSelected( - genre: com.simplecityapps.shuttle.model.Genre - ) { + private fun onGenreSelected(genre: Genre) { if (findNavController().currentDestination?.id != R.id.genreDetailFragment) { findNavController().navigate( R.id.action_libraryFragment_to_genreDetailFragment, From 48b402b1593e7efed0f8b0079d58b1bdc446b594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Tue, 6 Feb 2024 11:35:18 +0100 Subject: [PATCH 06/14] Move GenreListFragment.mediaImporterListener to Genres --- .../library/genres/GenreListFragment.kt | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) 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 567698576..7f518c3e5 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 @@ -75,17 +75,6 @@ class GenreListFragment : private var recyclerViewState: Parcelable? = null - private val mediaImporterListener = - object : MediaImporter.Listener { - override fun onSongImportProgress( - providerType: MediaProviderType, - message: String, - progress: Progress?, - ) { - setLoadingProgress(progress) - } - } - // Lifecycle override fun onCreateView( @@ -136,6 +125,17 @@ class GenreListFragment : } is GenreListViewModel.ViewState.Ready -> { + val mediaImporterListener = + object : MediaImporter.Listener { + override fun onSongImportProgress( + providerType: MediaProviderType, + message: String, + progress: Progress?, + ) { + setLoadingProgress(progress) + } + } + if (viewState.genres.isEmpty()) { if (viewModel.isImportingMedia()) { viewModel.addMediaImporterListener(mediaImporterListener) From 4b35b5380423d198fcbe7f382725357dcd8fac5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Tue, 6 Feb 2024 11:40:58 +0100 Subject: [PATCH 07/14] Remove unused GenreListFragment.recyclerViewState --- .../ui/screens/library/genres/GenreListFragment.kt | 12 ------------ 1 file changed, 12 deletions(-) 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 7f518c3e5..c2d062f7a 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 @@ -1,7 +1,6 @@ package com.simplecityapps.shuttle.ui.screens.library.genres import android.os.Bundle -import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -73,8 +72,6 @@ class GenreListFragment : private lateinit var playlistMenuView: PlaylistMenuView - private var recyclerViewState: Parcelable? = null - // Lifecycle override fun onCreateView( @@ -100,8 +97,6 @@ class GenreListFragment : circularLoadingView = view.findViewById(R.id.circularLoadingView) horizontalLoadingView = view.findViewById(R.id.horizontalLoadingView) - savedInstanceState?.getParcelable(ARG_RECYCLER_STATE)?.let { recyclerViewState = it } - playlistMenuPresenter.bindView(playlistMenuView) } @@ -159,11 +154,6 @@ class GenreListFragment : loadGenres() } - override fun onSaveInstanceState(outState: Bundle) { - outState.putParcelable(ARG_RECYCLER_STATE, recyclerViewState) - super.onSaveInstanceState(outState) - } - override fun onDestroyView() { playlistMenuPresenter.unbindView() @@ -404,8 +394,6 @@ class GenreListFragment : companion object { const val TAG = "GenreListFragment" - const val ARG_RECYCLER_STATE = "recycler_state" - fun newInstance() = GenreListFragment() } From 3e93bf88a02d1d2ae55ca00f3e778cd206890f07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Wed, 21 Feb 2024 19:27:49 +0100 Subject: [PATCH 08/14] Initialize view in the proper order --- .../ui/screens/library/genres/GenreListFragment.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) 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 c2d062f7a..a4bdb64fc 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 @@ -88,16 +88,14 @@ class GenreListFragment : ) { super.onViewCreated(view, savedInstanceState) - playlistMenuView = PlaylistMenuView(requireContext(), playlistMenuPresenter, childFragmentManager) - - composeView = view.findViewById(R.id.composeView) - loadGenres() - - circularLoadingView = view.findViewById(R.id.circularLoadingView) horizontalLoadingView = view.findViewById(R.id.horizontalLoadingView) + playlistMenuView = PlaylistMenuView(requireContext(), playlistMenuPresenter, childFragmentManager) playlistMenuPresenter.bindView(playlistMenuView) + + composeView = view.findViewById(R.id.composeView) + loadGenres() } private fun loadGenres() { From cfede2020be432bd44bf25c77a558643d695518b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sat, 30 Mar 2024 11:13:22 +0100 Subject: [PATCH 09/14] Try to implement some View/compose hybrid unit test for Genres list I haven't managed to mock GenreListViewModel. --- android/app/build.gradle.kts | 18 ++- .../library/genres/GenreListFragmentTest.kt | 108 ++++++++++++++++++ android/app/src/debug/AndroidManifest.xml | 25 ++++ .../shuttle/HiltTestActivity.kt | 23 ++++ .../library/genres/GenreListFragment.kt | 2 + .../library/genres/GenreListViewModel.kt | 2 + buildSrc/build.gradle.kts | 2 +- 7 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 android/app/src/androidTest/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragmentTest.kt create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/debug/java/com/simplecityapps/shuttle/HiltTestActivity.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5c4875c6a..8f2dd832c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -10,6 +10,8 @@ plugins { } android { + val fragment_version = "1.6.2" + val mockk_version = "1.13.11" compileSdk = 34 @@ -138,6 +140,7 @@ android { implementation(composeBom) androidTestImplementation(composeBom) + testImplementation(composeBom) // Material Design 3 implementation("androidx.compose.material3:material3") @@ -219,7 +222,7 @@ android { implementation("androidx.drawerlayout:drawerlayout:1.2.0") // New fragment manager - implementation("androidx.fragment:fragment-ktx:1.6.2") + implementation("androidx.fragment:fragment-ktx:$fragment_version") // Glide implementation("com.github.bumptech.glide:glide:4.16.0") @@ -259,6 +262,11 @@ android { androidTestImplementation("androidx.test:rules:1.5.0") androidTestImplementation("androidx.test:core-ktx:1.5.0") androidTestImplementation("org.hamcrest:hamcrest-library:1.3") + implementation("androidx.test.ext:junit-ktx:1.1.5") + debugImplementation("androidx.fragment:fragment-testing:$fragment_version") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation("io.mockk:mockk-android:${mockk_version}") + androidTestImplementation("io.mockk:mockk-agent:${mockk_version}") // Remote config implementation(project(":android:remote-config")) @@ -273,6 +281,14 @@ android { } buildFeatures.buildConfig = true + + packaging { + resources.excludes.addAll( + listOf( + "META-INF/*", + ) + ) + } } apply(plugin = "com.google.gms.google-services") diff --git a/android/app/src/androidTest/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragmentTest.kt b/android/app/src/androidTest/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragmentTest.kt new file mode 100644 index 000000000..60c673168 --- /dev/null +++ b/android/app/src/androidTest/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragmentTest.kt @@ -0,0 +1,108 @@ +package com.simplecityapps.shuttle.ui.screens.library.genres + +import android.content.ComponentName +import android.content.Intent +import android.os.Bundle +import androidx.annotation.StyleRes +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.core.util.Preconditions +import androidx.fragment.app.Fragment +import androidx.navigation.Navigation.findNavController +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.simplecityapps.shuttle.HiltTestActivity +import com.simplecityapps.shuttle.R +import com.simplecityapps.shuttle.ui.MainActivity +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.MockKAnnotations +import io.mockk.impl.annotations.MockK +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain + +@HiltAndroidTest +class GenreListFragmentTest { + @MockK(relaxed = true) + lateinit var viewModel: GenreListViewModel + + private val hiltRule = HiltAndroidRule(this) + + private val composeTestRule = createAndroidComposeRule() + + @get:Rule + val rule: RuleChain = RuleChain + .outerRule(hiltRule) + .around(composeTestRule) + + @get:Rule + val composeTestRule2 = createComposeRule() + + @Before + fun goToNonOrganizerFragment() { + MockKAnnotations.init(this) + hiltRule.inject() + launchFragmentInHiltContainer() + + composeTestRule.activityRule.scenario.onActivity { + // FIXME: I guess there's a better way to do this + it.preferenceManager.hasSeenThankYouDialog = true + it.preferenceManager.showChangelogOnLaunch = false + + findNavController(it, R.id.onboardingNavHostFragment) + .navigate(R.id.mainFragment) + } + } + + @Test + fun testEventFragment2() { + onView(withText("Genres")).perform(click()) + composeTestRule.onNodeWithTag("genres-list-lazy-column") + .assertIsDisplayed() + } +} + +/** + * launchFragmentInContainer from the androidx.fragment:fragment-testing library + * is NOT possible to use right now as it uses a hardcoded Activity under the hood + * (i.e. [EmptyFragmentActivity]) which is not annotated with @AndroidEntryPoint. + * + * As a workaround, use this function that is equivalent. It requires you to add + * [HiltTestActivity] in the debug folder and include it in the debug + * AndroidManifest.xml file as can be found in this project. + * + * See https://developer.android.com/training/dependency-injection/hilt-testing#launchfragment + */ +inline fun launchFragmentInHiltContainer( + fragmentArgs: Bundle? = null, + @StyleRes themeResId: Int = R.style.AppTheme_Light, + crossinline action: Fragment.() -> Unit = {}, +) { + val startActivityIntent = Intent.makeMainActivity( + ComponentName( + ApplicationProvider.getApplicationContext(), + HiltTestActivity::class.java, + ) + ).putExtra( + "androidx.fragment.app.testing.FragmentScenario.EmptyFragmentActivity.THEME_EXTRAS_BUNDLE_KEY", + themeResId, + ) + + ActivityScenario + .launch(startActivityIntent) + .onActivity { activity -> + val fragment = activity.supportFragmentManager.fragmentFactory.instantiate( + Preconditions.checkNotNull(T::class.java.classLoader), + T::class.java.name, + ) + fragment.arguments = fragmentArgs + activity.supportFragmentManager.beginTransaction() + } +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..695b7ea6f --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/android/app/src/debug/java/com/simplecityapps/shuttle/HiltTestActivity.kt b/android/app/src/debug/java/com/simplecityapps/shuttle/HiltTestActivity.kt new file mode 100644 index 000000000..643249c40 --- /dev/null +++ b/android/app/src/debug/java/com/simplecityapps/shuttle/HiltTestActivity.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2022 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 + * + * https://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.simplecityapps.shuttle + +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class HiltTestActivity : AppCompatActivity() 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 a4bdb64fc..90cd69b21 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 @@ -32,6 +32,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -163,6 +164,7 @@ class GenreListFragment : LazyColumn( modifier = modifier .fillMaxWidth() + .testTag("genres-list-lazy-column") .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { 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 00dfdd2bd..b0576828b 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 @@ -1,5 +1,6 @@ package com.simplecityapps.shuttle.ui.screens.library.genres +import androidx.annotation.OpenForTesting import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.simplecityapps.mediaprovider.MediaImporter @@ -21,6 +22,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +@OpenForTesting @HiltViewModel class GenreListViewModel @Inject constructor( private val genreRepository: GenreRepository, diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 08fdc1b71..6748905e4 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -4,4 +4,4 @@ repositories { plugins { `kotlin-dsl` -} \ No newline at end of file +} From 8e46a2891b7a575818dc06803413f6faf1bcaf1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sun, 6 Oct 2024 12:11:19 +0200 Subject: [PATCH 10/14] Implement fast scrollbar with Compose --- android/app/build.gradle.kts | 7 +- .../shuttle/ui/common/view/FastScrollBar.kt | 222 ++++++++++++++++++ .../library/genres/GenreListFragment.kt | 33 ++- .../src/main/res/layout/fragment_genres.xml | 1 - android/app/src/main/res/values/colors.xml | 3 + 5 files changed, 255 insertions(+), 11 deletions(-) create mode 100644 android/app/src/main/java/com/simplecityapps/shuttle/ui/common/view/FastScrollBar.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8f2dd832c..c2dbc360b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -133,6 +133,11 @@ android { // RecyclerView FastScroll implementation("com.github.timusus:RecyclerView-FastScroll:dev-SNAPSHOT") + // FastScroller for Compose Multiplatform + implementation("io.github.oikvpqya.compose.fastscroller:fastscroller-core:0.2.0") + implementation("io.github.oikvpqya.compose.fastscroller:fastscroller-material3:0.2.0") + implementation("io.github.oikvpqya.compose.fastscroller:fastscroller-indicator:0.2.0") + // AppCompat implementation("androidx.appcompat:appcompat:1.6.1") @@ -186,7 +191,7 @@ android { implementation("androidx.viewpager2:viewpager2:1.1.0-beta02") // ViewPager Circle Indicator - implementation("me.relex:circleindicator:2.1.4") + implementation("me.relex:circleindicator:2.1.6") // AndroidX Media implementation("androidx.media:media:1.6.0") diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/view/FastScrollBar.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/view/FastScrollBar.kt new file mode 100644 index 000000000..9fbe1da5e --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/view/FastScrollBar.kt @@ -0,0 +1,222 @@ +package com.simplecityapps.shuttle.ui.common.view + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.simplecityapps.shuttle.R +import io.github.oikvpqya.compose.fastscroller.ThumbStyle +import io.github.oikvpqya.compose.fastscroller.TrackStyle +import io.github.oikvpqya.compose.fastscroller.VerticalScrollbar +import io.github.oikvpqya.compose.fastscroller.indicator.IndicatorConstants +import io.github.oikvpqya.compose.fastscroller.material3.defaultMaterialScrollbarStyle +import io.github.oikvpqya.compose.fastscroller.rememberScrollbarAdapter +import kotlinx.coroutines.delay + +@Composable +fun FastScrollableListContainer( + listState: LazyListState, + indicatorTextProvider: (currentElementIndex: Int) -> String, + modifier: Modifier = Modifier, + content: @Composable (BoxScope.() -> Unit), +) { + Box( + modifier = modifier + .fillMaxSize() + .padding(start = 16.dp), + ) { + content() + + Row(Modifier.align(Alignment.TopEnd)) { + FastScrollbar( + listState = listState, + indicatorTextProvider = indicatorTextProvider, + modifier = Modifier + .fillMaxHeight(), + ) + } + } +} + +@Composable +fun FastScrollbar( + listState: LazyListState, + indicatorTextProvider: (currentElementIndex: Int) -> String, + modifier: Modifier = Modifier, +) { + val scrollbarInteractionSource = remember { MutableInteractionSource() } + val isDragging by scrollbarInteractionSource.collectIsDraggedAsState() + + AnimatedScrollbarVisibility( + isScrolling = listState.isScrollInProgress, + isDragging = isDragging, + modifier = modifier, + ) { + VerticalScrollbar( + modifier = Modifier + .align(Alignment.TopEnd) + .background(colorResource(R.color.fast_scrollbar_track)), + adapter = rememberScrollbarAdapter(listState), + interactionSource = scrollbarInteractionSource, + style = scrollbarStyle(), + enablePressToScroll = false, + indicator = { position, isVisible -> + ScrollBarIndicator( + listState = listState, + position = position, + isVisible = isVisible, + scrollbarInteractionSource = scrollbarInteractionSource, + textProvider = indicatorTextProvider, + ) + }, + ) + } +} + +@Composable +fun AnimatedScrollbarVisibility( + isScrolling: Boolean, + isDragging: Boolean, + modifier: Modifier = Modifier, + scrollbarContent: @Composable (BoxScope.() -> Unit), +) { + var isVisible by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + isVisible = false + } + + LaunchedEffect(isScrolling, isDragging) { + if (isVisible) { + // Delay hiding after scrolling or dragging has stopped + delay(1000) + } + + isVisible = isScrolling || isDragging + } + + AnimatedVisibility( + visible = isVisible, + enter = slideInHorizontally { fullWidth -> fullWidth / 2 }, + exit = slideOutHorizontally( + animationSpec = tween(durationMillis = 500, delayMillis = 1000) + ) { fullWidth -> fullWidth / 2 }, + modifier = modifier, + ) { + Box { + scrollbarContent() + } + } +} + +private val INDICATOR_SIZE = 64.dp + +@Composable +private fun BoxScope.ScrollBarIndicator( + listState: LazyListState, + position: Float, + isVisible: Boolean, + scrollbarInteractionSource: InteractionSource, + textProvider: (currentElementIndex: Int) -> String, + modifier: Modifier = Modifier, +) { + val isDragging by scrollbarInteractionSource.collectIsDraggedAsState() + val isHovered by scrollbarInteractionSource.collectIsHoveredAsState() + val indicatorAlpha by animateFloatAsState( + if (isDragging || isHovered) 1f else 0f, + label = "indicator-alpha-animation", + ) + val listPositionState = remember { derivedStateOf { listState.firstVisibleItemIndex } } + + Box( + modifier = modifier + .align(Alignment.TopEnd) + .padding(end = 5.dp) + .graphicsLayer { + val y = -((INDICATOR_SIZE / 2).toPx()) + translationY = (y + position).coerceAtLeast(0f) + alpha = indicatorAlpha + }, + ) { + val backgroundColor = if (isVisible) colorResource(R.color.colorPrimary) else Color.Transparent + val textColor = if (isVisible) colorResource(R.color.fast_scrollbar_text) else Color.Transparent + + Box( + modifier = Modifier + .defaultMinSize( + minHeight = INDICATOR_SIZE, + minWidth = INDICATOR_SIZE, + ) + .graphicsLayer { + clip = true + shape = RoundedCornerShape( + topStartPercent = 50, + topEndPercent = 50, + bottomStartPercent = 50, + bottomEndPercent = 0, + ) + } + .drawBehind { drawRect(backgroundColor) }, + ) + Text( + text = textProvider(listPositionState.value), + color = textColor, + fontSize = 32.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.CenterEnd) + .wrapContentHeight() + .padding(end = IndicatorConstants.Default.PADDING) + .width(IndicatorConstants.Default.MIN_HEIGHT), + ) + } +} + +@Composable +fun scrollbarStyle() = defaultMaterialScrollbarStyle().copy( + thickness = 7.dp, + trackStyle = TrackStyle( + shape = RoundedCornerShape(4.dp), + unhoverColor = Color.Transparent, + hoverColor = Color.Transparent, + ), + thumbStyle = ThumbStyle( + shape = RoundedCornerShape(4.dp), + unhoverColor = colorResource(id = R.color.colorPrimary), + hoverColor = colorResource(id = R.color.colorPrimary), + ), +) 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 90cd69b21..9e658745f 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 @@ -9,12 +9,14 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -48,6 +50,7 @@ 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.FastScrollableListContainer import com.simplecityapps.shuttle.ui.common.view.HorizontalLoadingView import com.simplecityapps.shuttle.ui.screens.library.genres.detail.GenreDetailFragmentArgs import com.simplecityapps.shuttle.ui.screens.playlistmenu.CreatePlaylistDialogFragment @@ -161,15 +164,27 @@ class GenreListFragment : @Composable private fun GenreList(genres: List, modifier: Modifier = Modifier) { - LazyColumn( + val state = rememberLazyListState() + + FastScrollableListContainer( + listState = state, + indicatorTextProvider = { index -> + genres[index].name[0].uppercaseChar().toString() + }, modifier = modifier - .fillMaxWidth() - .testTag("genres-list-lazy-column") - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .fillMaxSize() + .padding(start = 0.dp), ) { - items(genres) { genre -> - GenreListItem(genre) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .testTag("genres-list-lazy-column"), + verticalArrangement = Arrangement.spacedBy(16.dp), + state = state, + ) { + items(genres) { genre -> + GenreListItem(genre) + } } } } @@ -246,7 +261,7 @@ class GenreListFragment : isAddToPlaylistSubmenuOpen = true }, trailingIcon = { - Icon(Icons.Default.KeyboardArrowRight, contentDescription = null) + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, ) DropdownMenuItem( diff --git a/android/app/src/main/res/layout/fragment_genres.xml b/android/app/src/main/res/layout/fragment_genres.xml index 00acc1358..65082a060 100644 --- a/android/app/src/main/res/layout/fragment_genres.xml +++ b/android/app/src/main/res/layout/fragment_genres.xml @@ -11,7 +11,6 @@ android:id="@+id/composeView" android:layout_width="match_parent" android:layout_height="match_parent" - android:paddingTop="8dp" /> #10000000 #20FFFFFF + + #28000000 + #ffffffff #E65100 From 6443ec8a1464c98078d837a3e0a1105705f15b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sat, 26 Oct 2024 19:30:23 +0200 Subject: [PATCH 11/14] Fix 'inspection unavailable' error in the layout inspector The full error was: Compose inspection unavailable. Could not determine the version of the androidx.compose.ui:ui artifact. Try a different version of compose --- android/app/build.gradle.kts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c2dbc360b..ffb1598b6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -286,13 +286,11 @@ android { } buildFeatures.buildConfig = true +} - packaging { - resources.excludes.addAll( - listOf( - "META-INF/*", - ) - ) +androidComponents { + onVariants(selector().withBuildType("release")) { + it.packaging.resources.excludes.add("META-INF/**") } } From a0d9ddb462411e0582b3f50a630d54b05bc564b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sat, 23 Nov 2024 13:44:10 +0100 Subject: [PATCH 12/14] Disable KtLint rules about trailing commas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They are useful. From Kotlin coding conventions (*): Using trailing commas has several benefits: - It makes version-control diffs cleaner – as all the focus is on the changed value. - It makes it easy to add and reorder elements – there is no need to add or delete the comma if you manipulate elements. - It simplifies code generation, for example, for object initializers. The last element can also have a comma. Trailing commas are entirely optional – your code will still work without them. The Kotlin style guide encourages the use of trailing commas at the declaration site and leaves it at your discretion for the call site. (*) https://kotlinlang .org/docs/coding-conventions.html#trailing-commas --- .editorconfig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index 7555f4db3..c92a3c446 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,4 +7,6 @@ max_line_length=off ktlint_standard_property-naming=disabled ktlint_standard_filename=disabled ktlint_standard_package-name=disabled -ktlint_code_style=android_studio \ No newline at end of file +ktlint_code_style=android_studio +ktlint_standard_trailing-comma-on-declaration-site=disabled +ktlint_standard_trailing-comma-on-call-site=disabled From 05c304bbe1e9cd86545813a8619a3ac23055c1bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Magaz=20Gra=C3=A7a?= Date: Sat, 23 Nov 2024 19:14:03 +0100 Subject: [PATCH 13/14] Stop Ktlint from complaining about function names when annotated with @Compose --- .editorconfig | 1 + 1 file changed, 1 insertion(+) diff --git a/.editorconfig b/.editorconfig index c92a3c446..b2cbe318e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,3 +10,4 @@ ktlint_standard_package-name=disabled ktlint_code_style=android_studio ktlint_standard_trailing-comma-on-declaration-site=disabled ktlint_standard_trailing-comma-on-call-site=disabled +ktlint_function_naming_ignore_when_annotated_with=Composable From e42f45d5622ed4a13272bd9a76678e23855b85cc Mon Sep 17 00:00:00 2001 From: Tim Malseed Date: Sat, 11 Jan 2025 23:20:14 +1100 Subject: [PATCH 14/14] Fix a crash when genre list is empty Check to ensure we have items in the list before calling the `indicatorTextProvider`. --- .../shuttle/ui/common/view/FastScrollBar.kt | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/view/FastScrollBar.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/view/FastScrollBar.kt index 9fbe1da5e..3316445d2 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/view/FastScrollBar.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/view/FastScrollBar.kt @@ -61,13 +61,16 @@ fun FastScrollableListContainer( ) { content() - Row(Modifier.align(Alignment.TopEnd)) { - FastScrollbar( - listState = listState, - indicatorTextProvider = indicatorTextProvider, - modifier = Modifier - .fillMaxHeight(), - ) + val totalItemsCount by remember { derivedStateOf { listState.layoutInfo.totalItemsCount } } + if (totalItemsCount != 0) { + Row(Modifier.align(Alignment.TopEnd)) { + FastScrollbar( + listState = listState, + indicatorTextProvider = indicatorTextProvider, + modifier = Modifier + .fillMaxHeight(), + ) + } } } }