diff --git a/.editorconfig b/.editorconfig index 7555f4db3..b2cbe318e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,4 +7,7 @@ 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 +ktlint_function_naming_ignore_when_annotated_with=Composable diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 06cb3a205..ffb1598b6 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 @@ -85,6 +87,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"))) @@ -123,9 +133,36 @@ 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") + val composeBom = platform("androidx.compose:compose-bom:2023.10.01") + + implementation(composeBom) + androidTestImplementation(composeBom) + testImplementation(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") @@ -154,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") @@ -190,7 +227,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") @@ -230,6 +267,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")) @@ -246,6 +288,12 @@ android { buildFeatures.buildConfig = true } +androidComponents { + onVariants(selector().withBuildType("release")) { + it.packaging.resources.excludes.add("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/common/view/FastScrollBar.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/view/FastScrollBar.kt new file mode 100644 index 000000000..3316445d2 --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/view/FastScrollBar.kt @@ -0,0 +1,225 @@ +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() + + val totalItemsCount by remember { derivedStateOf { listState.layoutInfo.totalItemsCount } } + if (totalItemsCount != 0) { + 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/GenreBinder.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt deleted file mode 100644 index 699d7ae3c..000000000 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt +++ /dev/null @@ -1,83 +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.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 - ) - - 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, this) } - 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 c8bc2aa96..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 @@ -1,28 +1,56 @@ 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 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.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.automirrored.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.collectAsState +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.platform.testTag +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.lifecycle.lifecycleScope +import androidx.fragment.app.viewModels 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.MediaImporter import com.simplecityapps.mediaprovider.Progress import com.simplecityapps.shuttle.R -import com.simplecityapps.shuttle.ui.common.TagEditorMenuSanitiser +import com.simplecityapps.shuttle.model.Genre +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.recyclerview.SectionedAdapter 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 @@ -36,25 +64,18 @@ import javax.inject.Inject @AndroidEntryPoint class GenreListFragment : Fragment(), - 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() - @Inject - lateinit var presenter: GenreListPresenter + private val viewModel: GenreListViewModel by viewModels() @Inject lateinit var playlistMenuPresenter: PlaylistMenuPresenter private lateinit var playlistMenuView: PlaylistMenuView - private var recyclerViewState: Parcelable? = null - // Lifecycle override fun onCreateView( @@ -71,118 +92,301 @@ class GenreListFragment : ) { super.onViewCreated(view, savedInstanceState) - 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()) - circularLoadingView = view.findViewById(R.id.circularLoadingView) horizontalLoadingView = view.findViewById(R.id.horizontalLoadingView) - savedInstanceState?.getParcelable(ARG_RECYCLER_STATE)?.let { recyclerViewState = it } - - presenter.bindView(this) + playlistMenuView = PlaylistMenuView(requireContext(), playlistMenuPresenter, childFragmentManager) playlistMenuPresenter.bindView(playlistMenuView) + + composeView = view.findViewById(R.id.composeView) + loadGenres() } - override fun onResume() { - super.onResume() + private fun loadGenres() { + composeView.setContent { + val viewState by viewModel.viewState.collectAsState() - presenter.loadGenres(false) + Genres(viewState) + } } - override fun onPause() { - super.onPause() + @Composable + private fun Genres(viewState: GenreListViewModel.ViewState) { + when (viewState) { + is GenreListViewModel.ViewState.Scanning -> { + setLoadingState(LoadingState.Scanning) + } + + is GenreListViewModel.ViewState.Loading -> { + setLoadingState(LoadingState.Loading) + } - recyclerViewState = recyclerView.layoutManager?.onSaveInstanceState() + 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) + setLoadingState(LoadingState.Scanning) + } else { + viewModel.removeMediaImporterListener(mediaImporterListener) + setLoadingState(LoadingState.Empty) + } + } else { + viewModel.removeMediaImporterListener(mediaImporterListener) + setLoadingState(LoadingState.None) + } + + GenreList(genres = viewState.genres) + } + } } - override fun onSaveInstanceState(outState: Bundle) { - outState.putParcelable(ARG_RECYCLER_STATE, recyclerViewState) - super.onSaveInstanceState(outState) + override fun onResume() { + super.onResume() + loadGenres() } override fun onDestroyView() { - presenter.unbindView() playlistMenuPresenter.unbindView() super.onDestroyView() } - // GenreListContract.View Implementation + @Composable + private fun GenreList(genres: List, modifier: Modifier = Modifier) { + val state = rememberLazyListState() + + FastScrollableListContainer( + listState = state, + indicatorTextProvider = { index -> + genres[index].name[0].uppercaseChar().toString() + }, + modifier = modifier + .fillMaxSize() + .padding(start = 0.dp), + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .testTag("genres-list-lazy-column"), + verticalArrangement = Arrangement.spacedBy(16.dp), + state = state, + ) { + items(genres) { genre -> + GenreListItem(genre) + } + } + } + } - override fun setGenres( - genres: List, - resetPosition: Boolean - ) { - if (resetPosition) { - adapter.clear() + @Composable + private fun GenreListItem(genre: Genre, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + Modifier + .weight(1f) + .clickable { 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, + ) + } + 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 = { + 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 = { + viewModel.addToQueue(genre) { result -> + result.onSuccess { genre -> + onAddedToQueue(genre) + } + } + isMenuOpened = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_add_to_playlist)) }, + onClick = { + isMenuOpened = false + isAddToPlaylistSubmenuOpen = true + }, + trailingIcon = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_play_next)) }, + onClick = { + viewModel.playNext(genre) { result -> + result.onSuccess { genre -> + onAddedToQueue(genre) + } + } + isMenuOpened = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_exclude)) }, + onClick = { + viewModel.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 = { + viewModel.editTags(genre) { result -> + result.onSuccess { songs -> + showTagEditor(songs) + } + } + isMenuOpened = false + }, + ) + } + } + AddToPlaylistSubmenu( + genre = genre, + expanded = isAddToPlaylistSubmenuOpen, + onDismiss = { isAddToPlaylistSubmenuOpen = false }, + ) } + } - val data = genres.map { genre -> GenreBinder(genre, this) }.toMutableList() + @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() + }, + ) - adapter.update(data) { - recyclerViewState?.let { - recyclerView.layoutManager?.onRestoreInstanceState(recyclerViewState) - recyclerViewState = null + for (playlist in playlistMenuPresenter.playlists) { + DropdownMenuItem( + text = { Text(playlist.name) }, + onClick = { + playlistMenuPresenter.addToPlaylist(playlist, playlistData) + onDismiss() + }, + ) } } } - override 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() } - override fun setLoadingState(state: GenreListContract.LoadingState) { + private 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( - genre: com.simplecityapps.shuttle.model.Genre, - viewHolder: GenreBinder.ViewHolder - ) { + private fun onGenreSelected(genre: Genre) { if (findNavController().currentDestination?.id != R.id.genreDetailFragment) { findNavController().navigate( R.id.action_libraryFragment_to_genreDetailFragment, @@ -191,50 +395,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( @@ -249,8 +409,13 @@ class GenreListFragment : companion object { const val TAG = "GenreListFragment" - const val ARG_RECYCLER_STATE = "recycler_state" - 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..b0576828b --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListViewModel.kt @@ -0,0 +1,117 @@ +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 +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 + +@OpenForTesting +@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() + } +} diff --git a/android/app/src/main/res/layout/fragment_genres.xml b/android/app/src/main/res/layout/fragment_genres.xml index aa64eb374..65082a060 100644 --- a/android/app/src/main/res/layout/fragment_genres.xml +++ b/android/app/src/main/res/layout/fragment_genres.xml @@ -7,16 +7,11 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + /> #10000000 #20FFFFFF + + #28000000 + #ffffffff #E65100 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 +}